diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..ca18e69e --- /dev/null +++ b/.dockerignore @@ -0,0 +1,24 @@ +.git +**/node_modules +**/target +**/dist +**/*.log +.env +.env.* +*.key +nyaterm-web-data +web-test-data +.codex +.claude +.cursor +.trae +temp +docs-site +src-tauri/binaries +src-tauri/resources +src-tauri/vendor/portable-pty +src-tauri/vendor/picky +src-tauri/vendor/sspi + +artifacts +playwright-report diff --git a/.github/workflows/web-merge-checks.yml b/.github/workflows/web-merge-checks.yml new file mode 100644 index 00000000..c716b384 --- /dev/null +++ b/.github/workflows/web-merge-checks.yml @@ -0,0 +1,92 @@ +name: Web merge checks +on: + pull_request: + push: + branches: [main, feat/web-deployment] + workflow_dispatch: +permissions: + contents: read +concurrency: + group: web-checks-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true +jobs: + frontend: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: pnpm/action-setup@v5 + with: + version: 11.10.0 + - uses: actions/setup-node@v6 + with: + node-version: 24 + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm lint + - run: pnpm i18n:check && pnpm i18n:keys + - run: pnpm test --run --maxWorkers=4 + - run: pnpm build:web + rust: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: dtolnay/rust-toolchain@stable + - run: sudo apt-get update && sudo apt-get install -y pkg-config libssl-dev libfontconfig1-dev libfreetype-dev + - uses: Swatinem/rust-cache@v2 + with: + workspaces: | + src-tauri/crates/nyaterm-core -> target + src-tauri/crates/nyaterm-web -> target + - run: cargo test --manifest-path src-tauri/crates/nyaterm-core/Cargo.toml --locked + - run: cargo test --manifest-path src-tauri/crates/nyaterm-web/Cargo.toml --locked + desktop: + strategy: + fail-fast: false + matrix: + os: [windows-latest, macos-latest, ubuntu-latest] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v6 + - uses: dtolnay/rust-toolchain@stable + - uses: pnpm/action-setup@v5 + with: + version: 11.10.0 + - uses: actions/setup-node@v6 + with: + node-version: 24 + cache: pnpm + - name: Linux Tauri dependencies + if: runner.os == 'Linux' + run: sudo apt-get update && sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev libssl-dev libudev-dev libfontconfig1-dev libfreetype-dev pkg-config + - uses: Swatinem/rust-cache@v2 + with: + workspaces: | + src-tauri -> target + src-tauri/crates/nyaterm-mcp -> target + - run: pnpm install --frozen-lockfile + - run: pnpm build:mcp-sidecar + - run: pnpm prepare:conpty + if: runner.os == 'Windows' + - run: cargo check --manifest-path src-tauri/Cargo.toml --locked + deployment: + runs-on: ubuntu-latest + timeout-minutes: 45 + steps: + - uses: actions/checkout@v6 + - uses: pnpm/action-setup@v5 + with: + version: 11.10.0 + - uses: actions/setup-node@v6 + with: + node-version: 24 + cache: pnpm + - uses: docker/setup-buildx-action@v3 + - run: pnpm install --frozen-lockfile + - run: pnpm exec playwright install --with-deps chromium + - run: pnpm test:web:e2e + - uses: actions/upload-artifact@v4 + if: failure() + with: + name: web-deployment-failure + path: artifacts/web-e2e/ + retention-days: 7 diff --git a/.gitignore b/.gitignore index 62d96c26..a46998e5 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,14 @@ temp/ .claude .codex .trae + +# Web runtime secrets and data +.env +.env.* +nyaterm-web-data/ +web-test-data/ + +# Isolated deployment test traces and container logs +artifacts/web-e2e/ +artifacts/web-local-e2e/ +playwright-report/ diff --git a/README.md b/README.md index d2b3d389..5d9961ab 100644 --- a/README.md +++ b/README.md @@ -322,6 +322,10 @@ AUR package: [`nyaterm-bin`](https://aur.archlinux.org/packages/nyaterm-bin) > `nyaterm-bin` is a community-maintained binary AUR package. If the AUR package has not yet caught up with the latest release, download the official package from [Releases](https://github.com/nyakang/nyaterm/releases). +## Web / Docker Deployment + +The Web Beta serves the browser interface and API from a single container for one trusted administrator. See [deploy/web/](./deploy/web/README.md) for build and Compose commands, and the [deployment guide](./docs/web-deployment.md) for configuration and supported capabilities. + ## Prerequisites for Development - Node.js 18+ @@ -351,6 +355,7 @@ pnpm tauri dev │ ├── src/core/ # SSH, SFTP, PTY, Telnet, Serial, AI, backup logic │ ├── src/config/ # Persistent config models │ └── crates/otp/ # Local OTP implementation +├── deploy/web/ # Web Dockerfile, Compose, and deployment entry point ├── docs-site/ # Docusaurus documentation site ├── public/ # Static assets └── scripts/ # Checks, version sync, and demo helper scripts diff --git a/README.zh-CN.md b/README.zh-CN.md index ee753489..72bfe337 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -329,6 +329,10 @@ AUR 软件包:[`nyaterm-bin`](https://aur.archlinux.org/packages/nyaterm-bin) > `nyaterm-bin` 为社区维护的 AUR 二进制包。如果 AUR 版本暂未同步到最新发布版,可以从 [Releases](https://github.com/nyakang/nyaterm/releases) 下载官方安装包。 +## Web / Docker 部署 + +Web Beta 通过单个容器提供浏览器界面与 API,面向一个可信管理员。构建与 Compose 命令见 [deploy/web/](./deploy/web/README.md),配置和能力范围见 [部署指南](./docs/web-deployment.md)。 + ## 开发环境要求 - Node.js 18+ @@ -358,6 +362,7 @@ pnpm tauri dev │ ├── src/core/ # SSH、SFTP、PTY、Telnet、串口、AI、备份逻辑 │ ├── src/config/ # 持久化配置模型 │ └── crates/otp/ # 本地 OTP 实现 +├── deploy/web/ # Web Dockerfile、Compose 与部署入口 ├── docs-site/ # Docusaurus 文档站点 ├── public/ # 静态资源 └── scripts/ # 检查、版本同步与演示辅助脚本 diff --git a/deploy/web/Dockerfile b/deploy/web/Dockerfile new file mode 100644 index 00000000..1daf7249 --- /dev/null +++ b/deploy/web/Dockerfile @@ -0,0 +1,35 @@ +# syntax=docker/dockerfile:1 +# Build from the repository root: docker build -f deploy/web/Dockerfile -t nyaterm-web . +FROM node:24-bookworm-slim AS frontend +WORKDIR /app +RUN corepack enable && corepack prepare pnpm@11.10.0 --activate +COPY . . +RUN pnpm install --frozen-lockfile +ARG NYATERM_WEB_BASE_PATH=/ +ENV NYATERM_WEB_BASE_PATH=$NYATERM_WEB_BASE_PATH +RUN pnpm build:web + +FROM rust:1.97.1-bookworm AS backend +RUN apt-get update && apt-get install -y --no-install-recommends pkg-config libssl-dev libfontconfig1-dev libfreetype-dev && rm -rf /var/lib/apt/lists/* +WORKDIR /app +COPY src-tauri/crates/nyaterm-core src-tauri/crates/nyaterm-core +COPY src-tauri/crates/nyaterm-web src-tauri/crates/nyaterm-web +COPY src-tauri/crates/nyaterm-gpu src-tauri/crates/nyaterm-gpu +COPY src-tauri/crates/otp src-tauri/crates/otp +COPY src-tauri/vendor/russh src-tauri/vendor/russh +COPY src-tauri/vendor/russh-sftp src-tauri/vendor/russh-sftp +COPY src-tauri/vendor/vnc-rs src-tauri/vendor/vnc-rs +RUN cargo build --manifest-path src-tauri/crates/nyaterm-web/Cargo.toml --release --locked + +FROM debian:bookworm-slim AS runtime +RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates libssl3 libfontconfig1 libfreetype6 && rm -rf /var/lib/apt/lists/* \ + && useradd --system --uid 10001 --create-home nyaterm && mkdir -p /data && chown nyaterm:nyaterm /data +WORKDIR /app +COPY --from=frontend /app/dist /app/dist +COPY --from=backend /app/src-tauri/crates/nyaterm-web/target/release/nyaterm-web /usr/local/bin/nyaterm-web +USER 10001:10001 +ENV NYATERM_WEB_BIND=0.0.0.0:8080 NYATERM_WEB_DATA_DIR=/data NYATERM_WEB_DIST=/app/dist +EXPOSE 8080 +VOLUME ["/data"] +STOPSIGNAL SIGTERM +ENTRYPOINT ["nyaterm-web"] diff --git a/deploy/web/README.md b/deploy/web/README.md new file mode 100644 index 00000000..8fa91ef3 --- /dev/null +++ b/deploy/web/README.md @@ -0,0 +1,54 @@ +# Web / Docker 部署入口 + +此目录提供 Web Beta 的镜像构建与 Compose 配置。完整的环境变量、秘密生成、HTTPS / 子路径代理、备份和能力范围见 [部署指南](../../docs/web-deployment.md)。 + +## 文件与执行目录 + +- `Dockerfile`:Node、Rust、Debian 三阶段构建,生成前端资源与 `nyaterm-web` 服务。 +- `docker-compose.yml`:服务运行配置与持久数据卷。 +- 仓库根目录 `.dockerignore`:排除依赖、构建产物、秘密与本地数据。 + +以下命令均在仓库根目录执行。Dockerfile 的位置与构建上下文不同:上下文必须是仓库根目录,才能复制前端和 Rust 源码。 + +## Compose 启动 + +安装 Docker Engine / Docker Desktop 和 Compose V2。提前在当前 shell 或仓库根目录的受限 `.env` 中设置: + +- `NYATERM_WEB_PASSWORD`:至少 32 字符的随机登录密码。 +- `NYATERM_WEB_ENCRYPTION_KEY`:标准 Base64 编码的 32 字节随机密钥,需持久保存。 +- `NYATERM_WEB_PUBLIC_URL`:浏览器实际访问地址,默认 `http://localhost:8080/`;非回环地址默认要求 HTTPS。 + +```sh +docker compose -f deploy/web/docker-compose.yml up --build -d +docker compose -f deploy/web/docker-compose.yml logs --tail 200 nyaterm +docker compose -f deploy/web/docker-compose.yml down +``` + +使用外部环境文件时,在 Compose 命令中添加 `--env-file /absolute/private/nyaterm.env`。生产环境推荐 `secrets` 挂载与 `_FILE` 变量,操作示例见部署指南。 + +默认项目名为 `nyaterm`,持久数据卷为 `nyaterm_nyaterm-data`。从旧版根目录 Compose 迁移时,如果原项目名不是 `nyaterm`,继续使用 `-p <原项目名>` 或 `COMPOSE_PROJECT_NAME`,并沿用原登录密码和加密密钥。普通 `down` 保留数据卷;`down -v` 会删除数据卷。 + +## 手工构建镜像 + +```sh +docker build -f deploy/web/Dockerfile -t nyaterm-web . +``` + +子路径部署示例: + +```sh +docker build -f deploy/web/Dockerfile --build-arg NYATERM_WEB_BASE_PATH=/nyaterm/ -t nyaterm-web . +``` + +运行时的 `NYATERM_WEB_PUBLIC_URL` 路径须与构建路径一致。单独运行容器、挂载秘密和配置反向代理见部署指南。 + +## 部署验收 + +在已安装项目依赖及 Playwright Chromium 的 Docker 环境执行: + +```sh +pnpm exec playwright install chromium +pnpm test:web:e2e +``` + +验收脚本分别构建根路径与 `/nyaterm/` 镜像,通过临时 OpenSSH 容器验证浏览器功能,失败产物写入 `artifacts/web-e2e/`。 diff --git a/deploy/web/docker-compose.yml b/deploy/web/docker-compose.yml new file mode 100644 index 00000000..0776b589 --- /dev/null +++ b/deploy/web/docker-compose.yml @@ -0,0 +1,29 @@ +name: nyaterm + +services: + nyaterm: + build: + context: ../.. + dockerfile: deploy/web/Dockerfile + args: + NYATERM_WEB_BASE_PATH: ${NYATERM_WEB_BASE_PATH:-/} + ports: + - "8080:8080" + environment: + NYATERM_WEB_ALLOW_INSECURE_HTTP: ${NYATERM_WEB_ALLOW_INSECURE_HTTP:-false} + NYATERM_WEB_PUBLIC_URL: ${NYATERM_WEB_PUBLIC_URL:-http://localhost:8080/} + NYATERM_WEB_PASSWORD: ${NYATERM_WEB_PASSWORD:?Set a random login password of at least 32 characters} + NYATERM_WEB_ENCRYPTION_KEY: ${NYATERM_WEB_ENCRYPTION_KEY:?Set a persistent base64-encoded 32-byte encryption key} + volumes: + - nyaterm-data:/data + restart: unless-stopped + init: true + read_only: true + tmpfs: + - /tmp + security_opt: + - no-new-privileges:true + cap_drop: + - ALL +volumes: + nyaterm-data: diff --git a/docs/gpu-plugin-validation.md b/docs/gpu-plugin-validation.md deleted file mode 100644 index 47ee326f..00000000 --- a/docs/gpu-plugin-validation.md +++ /dev/null @@ -1,95 +0,0 @@ -# GPU 插件验证记录 - -验证日期:2026-10-03。宿主:Windows x86_64;目标远端:Linux/NVIDIA。 -本记录验证代码、包和真实原生进程;没有连接用户的 SSH 会话,也没有真实 NVIDIA 主机结果。 - -## 交付 - -- 源码:`plugins/examples/gpu-monitor/`,插件 ID `nyaterm.gpu`,版本 `1.0.1`。 -- 包:`temp/plugins/gpu-monitor.nyap`,Windows x86_64,484,743 字节,8 个 ZIP 条目。 -- SHA-256:`e92a8888b3658e5fb72a0a576b09657db37f5dfac66cd4b133f637ff7d522bd3`。 -- 用法与构建:[GPU 插件 README](../plugins/examples/gpu-monitor/README.md)。 -- API:[插件开发说明](plugin-development.md)。 - -```sh -pnpm plugin:example:gpu -pnpm plugin:pack plugins/examples/gpu-monitor temp/plugins/gpu-monitor.nyap -node plugins/examples/gpu-monitor/verify-ui.mjs -``` - -构建输出包含相对路径 CSS、SDK、单个经典 IIFE 脚本和 release 后台。 -打包器调用实际安装器校验路径、引用、配额及校验和后发布 `.nyap`;另行核对全部 checksums。 -当前包只声明 Windows x86_64;构建脚本支持在 Linux/macOS、x86_64/aarch64 宿主重建相应产物。 -不能据此宣称跨平台桌面验证完成。 - -## 首版自动验证 - -| 检查 | 结果与范围 | -| ------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | -| Runtime `cargo test --features sdk-fixture` | 28 项通过;另有 1 项最终包测试需显式启用(本次已执行通过) | -| 最终包 Sidecar 测试 | 1 项通过;实际安装、授权、启动 release 可执行文件,两次 reverse Probe RPC 往返及 GPU 解析 | -| 共享 GPU crate | 5 项解析测试通过;涵盖多设备、缺失可选值、进程索引和带逗号名称 | -| Rust SDK | 2 项测试通过;JSONL/framed 的进程互操作另由 Runtime 覆盖 | -| Runtime/SDK/GPU Clippy | `--all-targets -- -D warnings` 通过;Runtime 启用 `sdk-fixture` | -| Tauri | `cargo check --all-targets`、`cargo clippy --all-targets` 通过;宿主 Clippy 有项目级警告,未宣称零警告 | -| 前端 TypeScript | 宿主 `tsc --noEmit` 与插件独立 TypeScript 检查通过 | -| 宿主/插件构建 | `pnpm build` 和 `pnpm plugin:example:gpu` 通过;宿主仍有既有大 chunk/Browserslist 提示 | -| 前端 lint | `pnpm lint` 通过;`CommandSuggestions.tsx` 有既有依赖数组警告 | -| 相关 Vitest | 初始 7 文件 16 项通过;补充 Bridge 和续期失败检查后,相关 5 文件 16 项通过 | -| 全量 Vitest 首轮 | 155 文件中 154 通过,971 项通过;AI 设置有 1 项 5 秒超时。该文件独立重跑 9 项全部通过 | -| 全量 Vitest 最终重跑 | `vitest run --maxWorkers=4`:155 文件、974 项全部通过 | -| 宿主 Rust 插件测试 | 编译成功;测试可执行文件启动失败,Windows 返回 `0xc0000139 / STATUS_ENTRYPOINT_NOT_FOUND`,未执行断言 | -| 构建产物 UI 冒烟 | jsdom 执行实际 SDK 和 IIFE:设备/驱动/CUDA/温度/功率/风扇/显存/进程、排序/搜索、刷新、四语言、主题、保留数据的错误和暂停状态通过 | - -Runtime 新增测试覆盖旧包兼容与新贡献校验、固定脚本全文审核、非法路径/超时/输出配额、更新和回滚移除授权、 -GPU Monitor 冲突、共享需求、无重叠/补发、最后需求释放、取消和迟到结果、停止/手动恢复、不可用停止及三次失败清空。 -前端测试覆盖初始快照竞态、revision 去重、订阅/iframe 归属、配额、取消、作用域续期、会话/激活切换、锁屏释放需求、 -插件错误不切源、停用恢复内置、当前会话资产回填及 NPU 入口保持原行为。 - -最终包测试执行真实原生程序,使用安装包内脚本和 `execute_declared`。 -执行器模拟远端输出,未建立网络 SSH 连接。私有 exec 通道代码经过 Tauri check/Clippy; -`SessionCommand::Write` 不在 Probe 执行路径中。尚未实际验证 russh 网络取消或终端无输入干扰。 -jsdom 只验证构建产物逻辑和 DOM,不等同于 WebView2 的样式、opaque-origin 自定义协议或原生桌面验收。 - -## 1.0.1 换行修复验证 - -用户桌面运行 `1.0.0` 报告 `GPU probe did not exit successfully`。 -检查原始 `.nyap` 发现 `gpu.sh` 的 34 个换行全部为 CRLF。 -在 WSL Ubuntu 的真实 `/bin/sh -s` 中,原字节第二行报 `export: LC_ALL\r: bad variable name`,退出码 2; -同样脚本仅转换为 LF 后成功,缺少 NVIDIA 工具时输出 `GPU_AVAILABLE\t0` 并退出 0。 -首版 Sidecar 测试模拟远端输出,未执行脚本,因而没有发现这一打包缺陷。 - -`1.0.1` 将脚本源码和构建输出固定为 LF,构建脚本显式规范换行; -后台错误区分非零退出码与没有退出状态,不记录原始 stdout/stderr。 -提高版本号使已安装的不可变 `1.0.0` 能通过正常更新流程安装修复。 - -本次通过的检查: - -- `pnpm plugin:example:gpu`:独立 TypeScript、经典脚本 UI、release 后台构建。 -- `pnpm plugin:pack ...`:实际安装器的包校验;新版包所有 checksums 和 LF 字节另行核对。 -- `python plugins/examples/gpu-monitor/verify-probe.py --wsl Ubuntu`:执行最终包内的真实脚本, - 验证 CRLF 失败、LF 无工具分支和隔离 `nvidia-smi` 样本的设备/CUDA/进程输出。 -- GPU Sidecar 测试 3 项通过:成功采集复用、退出码/缺失状态诊断、源码换行回归。 -- 最终 `1.0.1` 安装包 Sidecar 测试 1 项通过:实际安装/授权并启动 release 程序,两次固定 Probe 往返。 -- Runtime `clippy --all-targets --features sdk-fixture -- -D warnings` 通过。 -- 构建产物 GPU UI 冒烟通过。 - -此修复只需更新插件包并重新授权,不需要修改或重新编译宿主。 -Linux Shell 验证使用本机 WSL 和固定 NVIDIA 样本,仍未连接真实 NVIDIA SSH 服务器。 - -## 用户 SSH 会话上的桌面验收(未执行) - -使用包含本次接口实现的宿主,由用户自行建立 Linux/NVIDIA SSH 会话: - -1. 安装并审核固定脚本,授权 `native`/`remote.probe` 后启用;确认普通终端输入不被采集打断。 -2. 同时打开原 GPU 工作区和独立插件面板,设置 3 秒间隔,确认持续更新且共享采集。 -3. 顶部状态选择 GPU,关闭两处面板,确认顶部状态仍更新;关闭全部 GPU 需求后确认停止。 -4. 测试手动刷新、搜索/排序、四种语言和明暗主题;核对实时指标与服务器 `nvidia-smi`。 -5. 在任务空闲时点击“停止后台”,确认定时器不重启后台;刷新后恢复。 -6. 切换会话、断开、锁屏、关闭窗口和撤销授权,确认停止请求、旧数据不回填新会话。 -7. 超过 25 分钟确认主窗口续期不重载工作区数据;iframe 重建后取得当前快照。 -8. 模拟采集错误,确认保留旧数据并标记错误,三次后清空;不静默回退。 -9. 停用后确认恢复内置采集;更新/回滚要求重新审核;第二个 GPU Monitor 冲突被拒绝。 - -真实 NVIDIA 数据、持续网络采集、网络取消、原生安装 UI 和上述桌面行为均未验收。 -本次没有修改用户凭据、建立用户会话或安装到用户的真实插件目录。 diff --git a/docs/plugin-development-plan.md b/docs/plugin-development-plan.md deleted file mode 100644 index 34c2dde6..00000000 --- a/docs/plugin-development-plan.md +++ /dev/null @@ -1,30 +0,0 @@ -# Plugin development and diagnostics - -This stage completes four deliverables on top of the local plugin host: - -Implemented and verified. See [the development guide](plugin-development.md), -[native SDK reference](../plugins/sdk/rust/nyaterm-plugin-sdk/README.md) and -[validation report](plugin-system-validation.md#developer-tooling-and-diagnostics-validation). - -- `pnpm plugin:create --template ui|rust` creates a new, buildable - project without overwriting existing content. Each template includes a manifest, - working entrypoint, SDK integration, build/package instructions and README. -- A standalone Rust native SDK handles identity/API handshake, JSONL and framed - transport, concurrent requests, scoped reverse host calls, structured errors, - timeouts, cooperative cancellation and logging. The existing native counter - and generated Rust projects use the SDK. -- The Plugins panel displays idle/starting/running/stopped/error states for native - backends and distinguishes UI-only and disabled plugins. Users can stop a backend - and see its next invocation activate a fresh process. -- Users can inspect, refresh, copy and clear bounded in-memory logs. Startup, - protocol, crash and invocation errors are retained even during the handshake. - Old processes cannot overwrite the state of a newer activation. Logs are - treated as untrusted text, redact host-known secrets/scope tokens, and are not - persisted or added to backup/cloud sync. - -Verification must exercise generated projects through build/package validation, -the SDK through the real host runtime in both transports (including reverse RPC, -errors, cancellation and crash handling), status/log transitions and retention, -and the frontend diagnostic controls. Type checking, relevant lint and frontend -build remain final gates. Marketplace, connection provider APIs and browser data -stream subscriptions remain separate stages. diff --git a/docs/plugin-development.md b/docs/plugin-development.md deleted file mode 100644 index 063fab87..00000000 --- a/docs/plugin-development.md +++ /dev/null @@ -1,168 +0,0 @@ -# 插件开发与排错 - -插件安装、授权、运行状态和日志都在 **插件面板** 中操作。 - -## 创建项目 - -在 NyaTerm 仓库根目录执行: - -```sh -# 纯界面插件:查看选中终端的会话信息 -pnpm plugin:create example.session temp/plugins/my-session --template ui -pnpm plugin:pack temp/plugins/my-session temp/plugins/my-session.nyap - -# Rust 插件:界面调用后台,也提供原生命令 -pnpm plugin:create example.hello temp/plugins/my-hello --template rust -node temp/plugins/my-hello/build.mjs -pnpm plugin:pack temp/plugins/my-hello temp/plugins/my-hello.nyap -``` - -创建器拒绝覆盖已有目录。项目包含 manifest、可用入口、SDK 和 README; -Rust 模板还包含编译脚本,编译后自动填入当前平台的可执行文件路径。 -需要 Node 和 Rust 1.94 或更新版本。Rust SDK 使用本地路径依赖,移动项目时 -应一起提供 SDK 或调整 Cargo.toml 中的路径。 - -将生成的 `.nyap` 安装到插件面板,检查权限并启用。UI 模板需先选中已连接终端; -Rust 模板可以打开界面点击运行,也可以直接执行插件卡片上的原生命令。 - -## 页面字体 - -宿主会将应用的界面字体栈通过 `NyaTerm.context.theme` 中的 `--font-sans`、 -`--font-display` 同步到插件页面;修改设置时,已打开的页面也会收到更新,无需重载。 -`--font-mono` 提供宿主的默认等宽字体栈。SDK 默认使用界面字体,表单控件继承字体; -插件自定义 CSS 应使用 `font-family: var(--font-sans, system-ui, sans-serif)`, -代码区域可使用 `var(--font-mono, monospace)`,避免写死 `system-ui`。 - -iframe 可以使用系统安装的字体,但不会继承主界面的 `@font-face`。 -若使用未安装到系统的内置字体或其他 Web 字体,需要在插件包内提供字体文件并声明 -`@font-face`。已有插件若写死字体,需要修改 CSS 并重新打包安装。 - -## 编写 Rust 后台 - -入口实现 `Plugin`,由 `Server::from_env()` 启动服务。SDK 负责握手、传输、 -并发、作用域、超时和取消,无需手写 JSON-RPC: - -```rust -#[async_trait] -impl Plugin for MyPlugin { - async fn call(&self, context: Context, method: &str, input: Value) -> Result { - match method { - "ui/session" => { - let session = context.host().session().await?; - context.log("info", "Session inspected").await?; - Ok(session) - } - _ => Err(RpcError::method_not_found(method)), - } - } -} -``` - -以上会话读取需在 manifest 中声明并获授 `session.read`;原生执行需 `native`。 -UI 调用 `NyaTerm.backend("ui/session")`,contributions.commands 中的原生命令 -则使用 manifest 声明的方法。不要向 stdout 打印日志,它专用于协议;使用 -`Context::log` 或 stderr。不要记录口令、令牌、终端输入输出等敏感内容。 - -默认采用 JSONL。将 `backend.transport` 改为 `stdio-framed` 后,SDK 根据宿主 -环境自动切换协议,支持二进制帧;界面流式订阅尚未提供。完整 API 和取消约定 -见 [Rust SDK](../plugins/sdk/rust/nyaterm-plugin-sdk/README.md)。 - -## 固定远端 Probe 和共享 Monitor - -Manifest v1 可选声明 `contributions.probes` 和 `contributions.monitors`,旧包不必增加字段。 -以 GPU 为例: - -```json -{ - "contributions": { - "panels": [{ "id": "overview", "title": "GPU", "entry": "ui/index.html" }], - "probes": [ - { - "id": "gpu-overview", - "title": "GPU probe", - "entry": "assets/probes/gpu.sh", - "timeoutMs": 15000 - } - ], - "monitors": [ - { - "id": "gpu", - "title": "GPU", - "schema": "gpu.v1", - "method": "monitor/collect", - "panel": "overview" - } - ] - }, - "permissions": ["native", "remote.probe"] -} -``` - -每类最多 16 项,ID 不可与其他贡献重复。脚本必须位于包内 `assets/probes/*.sh`,为非空 UTF-8、无 NUL、最多 64 KiB; -超时范围 1–30 秒。Monitor 必须引用已声明面板,声明 `monitor/*` 方法、原生后台和 Probe;当前只支持 `gpu.v1`。 -自动授权绑定审核版本;启用对话框展示脚本全文,更新/切换版本移除 `remote.probe` 授权。 -宿主不保证第三方脚本只读,不允许两个有效插件同时提供同一种 Monitor schema。 - -后台每次 `monitor/collect` 调用 `context.host().remote_probe("gpu-overview").await?`,解析结果并返回 GPU JSON。 -对应 RPC `host/remote/probe` **只接受** `{ "probeId": "gpu-overview" }`,返回 `{ stdout, stderr, exitStatus }`。 -不能提供命令、参数、stdin 或其他会话 ID;宿主从当前包解析脚本,在所属 SSH 连接的新 exec 通道执行 `sh -s`。 -stdout/stderr 合计限 1 MiB,超时或取消关闭该通道,不向交互终端输入命令、不关闭 SSH 连接。 -执行前后检查版本、授权、窗口、会话及锁屏;网络设备和禁止远程探测的会话不可执行。 - -UI 只订阅结果,不直接启动后台采集循环: - -```js -await NyaTerm.ready; -const monitor = await NyaTerm.monitoring.subscribe("gpu", (snapshot) => { - // revision, sessionId, overview, error, refreshing, paused - render(snapshot); -}); -await monitor.refresh(); -await monitor.unsubscribe(); -``` - -宿主以插件/版本/窗口/会话/Monitor 为键共享任务,订阅返回初始快照,再推送递增 revision。 -Bridge 仅向对应 iframe 发送所属订阅;未开放通用原生事件透传或 UI 调用 `monitor/*`。 -宿主消费者使用 typed `pluginApi.subscribeMonitor/refreshMonitor/unsubscribeMonitor`; -`subscribePluginMonitor` 负责先监听再订阅、初始快照竞态、revision 去重及释放。 -每个 iframe 最多 16 个订阅;宿主最多 256 订阅和 128 共享任务。 - -SDK Context 提供 `language`、主题变量和 `monitorIntervalSeconds`。使用 `onContextChange` 同步语言/主题, -仅间隔变化时重新订阅。主窗口订阅 25 分钟续期;iframe 作用域按现有机制重建。 -闲置任务不持有请求租约;后台每次只处理一次采集,不保存已经结束请求的 Context。 - -`gpu.v1` 结构与 `RemoteGpuOverview` 相同,宿主拒绝未知字段并限制设备/进程数量和文本长度。 -资产回填只能来自当前会话的 GPU 数据,没有任意连接修改接口。 -完整示例与构建命令见 [GPU 插件](../plugins/examples/gpu-monitor/README.md)。 - -## 查看状态和日志 - -原生后台按需启动并复用进程。卡片显示未启动、启动中、运行中、已停止或运行异常; -禁用插件和仅界面插件有独立标识。RPC 方法返回错误时,只记录调用失败,后台仍可 -继续运行;进程退出、握手或协议失败则显示运行异常。 - -点击 **查看日志** 可刷新、复制和清空。日志包括时间、版本、级别、来源和消息。 -日志弹窗每秒刷新,卡片状态每 2.5 秒刷新;关闭插件管理面板后停止轮询。 -每个插件最多保留 100 条、64 KiB 消息与来源/版本文本,单条消息最多 2 KiB。 -stderr 每次启动最多采集约 64 KiB,超过后继续排空而不保留。 -日志只在内存中保留,退出应用即清除,不进入备份或云同步。 -宿主会遮蔽常见凭据和作用域令牌,但插件作者仍需自行避免写入敏感内容。 - -**停止后台** 保留启用状态和权限。普通调用可启动新进程,进程内计数等状态会重置; -监控任务会同时暂停,定时器不会自行重新启动后台,手动刷新或重新启用可恢复。 -插件有进行中的请求时会提示忙碌;先结束或关闭该操作,再停止后台。 -禁用、更新、切换版本、锁屏和退出应用也会停止后台。旧进程的延迟消息不会覆盖 -新启动进程的状态。更新插件必须提升版本号,同版本不同内容不能重新安装。 - -## 本地验证 - -```sh -node --test scripts/create-plugin.node-test.mjs -cargo test --manifest-path plugins/sdk/rust/nyaterm-plugin-sdk/Cargo.toml -cargo test --manifest-path src-tauri/crates/nyaterm-plugin-runtime/Cargo.toml --features sdk-fixture -pnpm exec vitest run src/components/plugins/PluginDiagnostics.test.tsx --maxWorkers=4 -``` - -`native-counter` 示例已使用 SDK,并升到 1.0.1:执行两次可看到计数递增和日志; -停止后台后再执行,计数从 1 开始。新版安装包为 `temp/plugins/native-counter.nyap`。 -模板编译和打包已在 Windows 验证;其他平台需构建对应二进制并填写 executables。 diff --git a/docs/plugin-system-plan.md b/docs/plugin-system-plan.md deleted file mode 100644 index 3afeb477..00000000 --- a/docs/plugin-system-plan.md +++ /dev/null @@ -1,22 +0,0 @@ -# NyaTerm plugin system implementation - -The requested plugin system builds on the preceding architecture review. The implementation must provide a usable extension host, local package installation, lifecycle management, permissions, isolated UI, native runtime, documentation and an end-to-end example. - -Implemented as the local tool/panel extension host v1. See the [plugin guide](plugins.md) -and [validation report](plugin-system-validation.md) for the supported APIs, evidence -and remaining desktop release QA limits. - -## Completion requirements - -- A versioned, validated manifest declares panels, commands, menu locations, permissions and optional platform-specific native backends. -- `.nyap` packages can be inspected before installation, safely extracted with strict size/path/checksum validation, installed as immutable versions, enabled/disabled, updated, rolled back and uninstalled. -- Permissions are granted explicitly, enforced in Rust, and checked together with live session scope and application lock state. Native execution is presented as trusted code with OS user permissions. -- Plugin panels integrate with the existing activity bar and panel layouts; plugin commands are discoverable and available in declared context menus. -- Third-party HTML/JS runs in a sandboxed iframe with a restricted CSP and a bridge bound to its own iframe, plugin identity and backend-issued scope token. It cannot invoke arbitrary Tauri commands. -- The host API supports scoped session metadata, recent terminal output, approved command execution, scoped file reads, namespaced settings and declared network origins. Revocation, session closure and window teardown invalidate access. -- Optional sidecars use lazy persistent processes, a versioned initialization handshake, bounded JSON-RPC/binary frames, timeouts, crash cleanup and stop-on-disable/update behavior. -- Updates and removal do not race active requests; plugin state and permissions survive restarts. Plugin layouts survive temporary unavailability. -- A JavaScript SDK, packager, working example plugin and developer documentation cover the installation and authoring workflow. -- Meaningful backend and frontend tests, type checking, relevant linting and a build verify the implementation. A runtime smoke check verifies installation and UI/bridge integration where the environment permits. - -New connection protocols and replacing built-in SSH/SFTP/RDP/VNC are subsequent provider API work described by the review, rather than requirements of this tool/panel extension host. A remote marketplace is also a separate service. Local packages and the extension runtime must be complete independently. diff --git a/docs/plugin-system-validation.md b/docs/plugin-system-validation.md deleted file mode 100644 index 4a499de7..00000000 --- a/docs/plugin-system-validation.md +++ /dev/null @@ -1,85 +0,0 @@ -# Plugin v1 validation - -Developer tooling and diagnostics were subsequently completed; see -[the development guide](plugin-development.md) and the updated verification below. - -Validated on Windows on 2026-10-03. Scope follows -[the implementation plan](plugin-system-plan.md). - -| Requirement | Implementation and evidence | -| ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| Versioned manifest and contributions | Independent runtime validates API/engine versions, paths, permissions, panels and command/menu declarations. Frontend contribution tests verify enabled-version discovery and namespaced IDs. | -| Local package lifecycle | Package/registry tests cover traversal, case collisions, complete checksums, modified content, inspection digest, immutable versions, persisted grants, permission revocation, update, rollback and publisher continuity. | -| Permissions and scope | Rust gateway allowlists capabilities and combines grants with plugin version, window ownership, connected session and lock checks. Approved command/file operations revalidate scope before execution. Permission review is bound to the active version to reject stale dialogs after an update. | -| Isolated UI | Bridge tests cover unrelated/spoofed frames, opaque origin, fixed scope token, arbitrary native command attempts, duplicate IDs, oversized messages and disposal. IPC tests cover top-frame initialization and Tauri binary/channel/fallback serialization. | -| Workspace integration | Unified Plugins management panel, activity panels, floating/multiple panel layouts and terminal/connection menu contributions integrate with existing workspace components. Existing workspace and context-menu tests pass. | -| Native runtime | Real child-process tests exercise handshake identity, persistent reuse, reverse RPC, timeout recovery and crash cleanup. Framed transport tests verify binary round trips and size limits. Activation cancellation and application exit/lock cleanup are implemented. | -| Developer workflow | UI and native examples both build/package. The extracted native package initializes and returns counts 1 then 2 from the same executable process. UI SDK round-trip tests and browser smoke verify the authoring path. | -| Documentation | [Plugin guide](plugins.md) specifies installation, trust, package lifecycle, storage, manifest, UI API, native protocol, limitations and maintenance. | - -Checks completed: - -- `pnpm exec vitest run --maxWorkers=4`: **152 files / 964 tests passed**. - Default high parallelism exceeded the existing AI settings test's five-second - timeout; limiting workers passed the entire suite. Temporary reference projects - under `temp/` are now excluded from test discovery. -- `pnpm build`: frontend type checking, MCP sidecar build and production Vite build - passed. Existing Browserslist age and large-bundle notices remain. -- `pnpm lint`: passed; one existing dependency warning in `CommandSuggestions.tsx` - remains. Plugin frontend files have no lint diagnostics. -- Runtime Cargo tests: **9 unit tests and 2 real-process tests passed**. -- Runtime `cargo clippy --all-targets -- -D warnings`: passed. -- Tauri `cargo check --lib` and `cargo clippy --lib`: passed. Existing project-wide - Clippy warnings remain; plugin-module diagnostics were checked separately. -- Tauri plugin tests: **2 passed**, for atomic lifecycle admission and rendered IPC - script templates. The Windows library-test binary failed in the loader before - running any tests; a temporary copy with a Common Controls v6 activation manifest - ran both successfully. Production application files were not altered for this - workaround. -- Real Chrome smoke using the actual bridge, SDK and Session Toolbox files: - panel loading, selected-session request, theme propagation, opaque message origin, - fixed-scope routing, parent DOM isolation, blocked direct network and absent - child-frame IPC transport all passed. Native host calls were stubbed in this - browser check. - -This validation does not include a manually operated packaged Tauri desktop build -or macOS/Linux WebView testing. The browser smoke uses HTTP asset serving with the -equivalent sandbox/CSP and mocked native commands; it does not exercise Tauri's -custom resource protocol or real terminal execution dialogs. Verify those in -desktop release QA. Native plugins remain trusted OS processes; the current -format has no cryptographic publisher signatures or marketplace. - -## Developer tooling and diagnostics validation - -Completed on Windows, 2026-10-03: - -- Project creation: UI and Rust starters generated successfully. Three Node tests - cover external paths (including spaces), valid SDK references, invalid arguments - and refusal to overwrite existing source. -- Generated UI/Rust packages pass the real package validator. The Rust starter - builds in release mode. Extracted native-counter **1.0.1** and the generated - Rust package both complete their SDK handshake, return counts **1, 2** from a - persistent process, and exit cleanly on stdin EOF. -- Rust SDK: two wire tests cover bounded frames, binary round trips, clean EOF and - truncated input. Runtime: **10 unit tests + 5 process integration tests** pass - with `--features sdk-fixture`. SDK process tests cover JSONL/framed transport, - concurrent and reverse RPC, binary data, structured logs, request cancellation, - cancellation of timed-out reverse host calls, method errors, panic recovery, - crashes, and stderr collected during a failing initialization handshake. -- Diagnostic storage tests cover retention limits, UTF-8 truncation, credential - redaction, stop/restart generations, clearing and removal. The panel distinguishes - UI-only/disabled plugins and backend states; three frontend tests cover stopping, - refresh/copy/clear, untrusted log text and stale responses after locking. -- Frontend full suite: **153 files / 967 tests pass**. `pnpm build` and `pnpm lint` - pass; the existing CommandSuggestions hook warning remains. -- SDK/runtime Clippy with `--all-targets -- -D warnings` passes. Tauri library - Clippy/check passes; there are no plugin-module diagnostics, while existing - project-wide warnings remain. -- `pnpm i18n:check` still reports existing ordering issues in the AI text sections - of all four locales. Plugin sections were formatted independently to preserve - unrelated content. All four locales contain the same diagnostic keys. - -The user reported successful desktop testing of the earlier native-counter -package. The new diagnostics controls have automated frontend coverage; the -updated 1.0.1 package is ready for desktop smoke testing. This does not claim -macOS/Linux validation or a full manual desktop lifecycle test. diff --git a/docs/plugins.md b/docs/plugins.md deleted file mode 100644 index 1389e43c..00000000 --- a/docs/plugins.md +++ /dev/null @@ -1,360 +0,0 @@ -# NyaTerm plugins v1 - -NyaTerm supports locally installed tool and panel extensions. A plugin can add -sandboxed HTML panels, terminal/connection context-menu commands, and an optional -persistent native backend. Built-in connection protocols remain managed by -NyaTerm. Connection/filesystem providers and an online marketplace are future APIs. - -## Install and use - -1. Open the **Plugins** activity panel, choose **Install local package**, and select a `.nyap` file. -2. Review the plugin identity, publisher label and declared permissions. Installation - validates the package and leaves it disabled. -3. Choose **Enable**, select the permissions to grant, and save. A native backend - requires explicit `native` approval. -4. Open its panel or command from the Plugins activity item. Panels also appear in - the activity bar. Declared menu commands appear in terminal/connection menus. - -The terminal selected when a panel opens defines its session scope. Selecting -another terminal recreates that scope. A terminal-menu command is scoped to that -terminal; a saved-connection-menu command has no live terminal scope. It cannot -silently access an unrelated active terminal. Opening a panel from a context menu -keeps that explicit session selection until another open intent replaces it. - -Command execution and file reading each require a separate approval dialog, even -after granting their manifest permissions. Closing the panel or command dialog, -closing its session/window, locking NyaTerm, or changing plugin activation revokes -the associated scope and cancels host requests. Cancellation cannot undo a command -that has already run on the terminal. - -Install a newer package with the same ID to update. Versions are immutable: the -same version cannot be replaced with different contents. Updates and switching -the version selector leave the selected version disabled for review. The old -versions remain available for rollback. Updates, grant changes and uninstall fail -while a plugin request is active; finish or close it, then retry. Disable stops the -native backend. Native processes are also stopped on lock and application exit. - -## Trust and storage - -UI panels run in an iframe with `sandbox="allow-scripts"`, an opaque origin and -a restrictive response CSP. Scripts, styles, images and fonts are local package -assets; direct network access, child frames, form submissions and popups are -blocked. The bridge accepts messages only from its own frame and binds requests -to a Rust-issued plugin/version/window/session scope. Plugins do not receive an -arbitrary Tauri command gateway. On Windows, where Wry injects initialization -scripts into subframes, the native IPC transport is explicitly initialized only -in the top-level document. - -**Native plugins are trusted executables running with your OS user permissions.** -Host permission checks do not sandbox their filesystem, network or subprocess -access. Only enable native packages from a source you trust. SHA-256 checksums -verify consistency and the reviewed package digest prevents a file swap between -inspection and installation; these are not publisher authentication. Publisher -names are self-declared. A changed publisher label cannot overwrite an existing -ID without uninstalling it first, but that continuity check is not a signature. -Local packages may remain unsigned. The official Plugin Store uses repository -Ed25519 signing: `signature.json` signs the exact bytes of `checksums.json`, which -covers every content file (including `manifest.json`) but excludes `checksums.json` -and `signature.json` themselves. Publisher verification and package signatures are -separate concepts; a signature authenticates the Store's reviewed artifact. - -The Marketplace downloads and verifies packages in Rust, checks the catalog's final -SHA-256 and size, and cross-checks ID, version, publisher, permissions and signing -key. Public keys are pinned in the client; catalog metadata cannot add trusted keys. -The review dialog shows the verified package before installation, and installation -leaves the plugin disabled until its runtime permissions are separately granted. -Installed versions persist provenance; switching between local and Store sources -requires uninstalling first. Old registry records default to local provenance. - -The Store lives in [`nyakang/nyaterm-plugins`](https://github.com/nyakang/nyaterm-plugins). -Its records and signing workflow are independent of author plugin source. First -rollout requires initializing the official key and configuring the protected -`plugin-signing` environment; an empty client trust store rejects every Store install. - -The standalone [`@nyaterm/plugin-cli`](../plugins/cli/README.md) package bundles the -authoring binary, SDK and templates, with a workflow to assemble six platforms: - -```sh -npm install -g @nyaterm/plugin-cli -nyaterm-plugin create example.hello ./hello -nyaterm-plugin pack ./hello ./hello-1.0.0.nyap -nyaterm-plugin inspect ./hello-1.0.0.nyap -``` - -Publication to npm is a separate release step. Distributed Rust templates use a -pinned public Git SDK dependency; source-checkout templates retain local paths. - -Plugin files live in the active runtime's `plugins/` directory (normally -`~/.nyaterm/plugins/`): - -```text -plugins/ - registry.json - vendor.plugin/versions/1.0.0/... - data/vendor.plugin/settings.json -``` - -`storage` holds plaintext, nonsecret JSON preferences, separately for each ID. -Do not store passwords, tokens, terminal commands or terminal output there. -Uninstall removes plugin code and registration; preferences are retained for a -later reinstall. Plugin packages, grants and preferences are excluded from the -existing portable snapshot/backup/cloud-sync payloads. - -## GPU monitor - -The [GPU monitor example](../plugins/examples/gpu-monitor/README.md) validates -fixed remote probes, shared collection and GPU status contributions. Build it with -`pnpm plugin:example:gpu`, then package it with -`pnpm plugin:pack plugins/examples/gpu-monitor temp/plugins/gpu-monitor.nyap`. -Enable it after reviewing its fixed scripts and approving `native` and `remote.probe`. -Automatic probe grants are removed on updates and version switches. -An authorized `gpu.v1` provider replaces built-in GPU collection and the existing -GPU workspace; disabling it restores the built-in source. Collection failures remain -visible on the plugin source. See the [validation record](gpu-plugin-validation.md). - -## Build the UI example - -For project generation, Rust SDK APIs, status and logs, see the -[development guide](plugin-development.md) and -[native SDK reference](../plugins/sdk/rust/nyaterm-plugin-sdk/README.md). - -From the NyaTerm repository (Node/pnpm and Rust required): - -```sh -pnpm plugin:pack plugins/examples/session-toolbox temp/plugins/session-toolbox.nyap -``` - -Install the generated file, enable it with the permissions you want to try, select -a live terminal and open **Session Toolbox**. Read output, request a command or -request a UTF-8 file read. The latter two actions show the native host approval UI. -Terminal execution uses the existing execution profile and shell capture engine; -sessions that disable execution retain that restriction. - -The packager includes `ui/nyaterm-sdk.js` automatically. It is reserved and cannot -be overwritten with different bytes. Only `manifest.json`, `ui/`, `assets/` and -`bin/` are collected, so source code/build artifacts outside these directories do -not enter the archive. UI entry assets must be at most 4 MiB each. - -## Manifest - -```json -{ - "contributions": { - "panels": [ - { "id": "overview", "title": "Overview", "entry": "ui/index.html" } - ], - "commands": [ - { - "id": "open-overview", - "title": "Open Overview", - "panel": "overview", - "menus": ["terminal", "connection"] - } - ] - }, - "description": "Tools for a selected terminal", - "engine": ">=1.2.12, <2.0.0", - "id": "example.tools", - "manifestVersion": 1, - "name": "Example Tools", - "permissions": ["session.read", "terminal.read", "storage"], - "publisher": "Example", - "version": "1.0.0" -} -``` - -Unknown fields are rejected. IDs use lowercase ASCII letters, digits, `.`, `_` -and `-`, start with a letter/digit and are at most 128 bytes; `..` is prohibited. -Plugin IDs also contain a dot to separate their namespace. -Versions use canonical SemVer; `engine` uses Rust semver requirements, including -comma-separated comparators. Panel entries are `ui/*.html`. A command references -exactly one panel or a declared `command/*` native method. Panel and command IDs -are unique across the contribution set. Optional menus are `terminal` and -`connection`. Panels use persisted `plugin::` activity IDs and -retain their layout when temporarily disabled or unavailable. - -Limits: 16 panels, 64 commands, 24 permissions including at most 8 exact HTTPS -origins. Package limits: 128 MiB compressed, 256 MiB expanded, 64 MiB per file and -1024 archive entries. Paths must be portable relative paths; traversal, symlinks, -special files, Windows aliases and case collisions are rejected. `checksums.json` -maps every other archive filename, including the manifest, to its SHA-256 hash. -Use the packager to produce this file rather than maintaining hashes by hand. - -## UI SDK and host API - -Load classic scripts from local files. Inline styles are allowed; use a local -bundled JS file rather than a CDN or dynamic imports requiring cross-origin access. - -```html - - -``` - -```js -await NyaTerm.ready; -const session = await NyaTerm.session(); -const { output } = await NyaTerm.terminal.read(100); -document.getElementById("output").textContent = output; -``` - -Use `textContent` when rendering terminal/file/network data. Typings are in -`plugins/sdk/nyaterm.d.ts`. SDK operations return promises and reject on denied -permission, invalid scope, cancellation or runtime errors. - -| SDK method | Host method / permission | Behavior | -| ---------------------------------------------- | ------------------------------------------------- | ----------------------------------------------------------------------------------- | -| `session()` | `host/session` / `session.read` | Selected `{id,name,type,connected}` or `null`; no credentials | -| `terminal.read(lines = 100)` | `host/terminal/read` / `terminal.read` | `{output}`; 1–500 recent lines | -| `terminal.execute(command, timeoutMs = 30000)` | `host/terminal/execute` / `terminal.execute` | Individual approval, audit and existing execution engine; 1–120 s execution timeout | -| `filesystem.read(path)` | `host/filesystem/read` / `filesystem.read` | Individual approval; at most 256 KiB UTF-8 text | -| `storage.get(key)` | `host/storage/get` / `storage` | JSON value or `null` | -| `storage.set(key, value)` | `host/storage/set` / `storage` | `null` deletes; otherwise saves nonsecret JSON | -| `network.request(url, options)` | `host/network/request` / `network:https://origin` | GET/POST, declared exact origin, no redirects, 30 s timeout | -| `backend(method, input)` | `ui/*` or declared `command/*` / `native` | Request to persistent backend | - -Execute results contain `output`, `exitCode`, `durationMs`, `timedOut` and -`sourceTruncated`. File reads expose `content` (SSH may return extra file metadata). -Local file paths are confined to the selected session's canonical working -directory. Remote reads require SSH/SFTP; serial and Telnet do not offer file reads. -Storage allows 128 portable-ID keys and 1 MiB serialized JSON per plugin. Network -options are `{method, body, contentType}`; request bodies are capped at 256 KiB and -responses at 1 MiB. Results are `{status, contentType, body}` with text bodies. -URLs cannot contain credentials. HTTPS origins include their nondefault port and -must match exactly; no wildcards, paths or implicit subdomain grants. - -`NyaTerm.context` contains plugin identity/version and CSS theme variables; -`NyaTerm.onContextChange(listener)` returns an unsubscribe function. Theme variables -are applied to the iframe document automatically, including `--font-sans` and -`--font-display` (the configured UI font stack) and `--font-mono`. Font changes -are pushed to open panels without reloading them. The SDK defaults to the UI font -and makes form controls inherit it; plugin styles should use -`font-family: var(--font-sans, system-ui, sans-serif)` instead of a fixed family. -Use `var(--font-mono, monospace)` for code. System-installed fonts are available -inside the iframe, but the host's `@font-face` declarations are not inherited; -package any required web fonts with the plugin and declare them in its CSS. -Existing packages with a fixed font family need to update their CSS and be rebuilt. -This context contains no scope -token. Tokens are managed by the parent bridge. Scopes expire after 30 minutes; -open panels renew at 25 minutes by recreating their document. Preserve needed -nonsecret preferences in plugin storage. - -UI requests are limited to 32 outstanding calls and 1 MiB request parameters. -Host calls have a 180 s envelope including approval; SDK calls time out at 185 s. -An approval dialog times out at 120 s. A native backend request times out at 170 s, -including any reverse host calls, so long commands and approvals share that budget. - -## Native backend protocol - -Build and package a working persistent-process example: - -```sh -pnpm plugin:example:native -pnpm plugin:pack temp/plugins/native-counter temp/plugins/native-counter.nyap -``` - -Install and explicitly grant `native`. Invoke **Increment native counter** from -the Plugins panel twice: its count increases in the same process. Disable/re-enable -and invoke again: activation starts a fresh process. The build script generates -a manifest for the current OS/architecture. Production plugins should bundle the -executables needed for each supported platform: - -```json -{ - "executables": { - "windows-x86_64": "bin/backend.exe", - "linux-x86_64": "bin/backend" - }, - "transport": "stdio-jsonl" -} -``` - -Put that object under `backend`, declare the `native` permission, and declare a -command with `"method": "command/count"`. Accepted platforms are Windows, Linux -and macOS on `x86_64` or `aarch64`. Activation is lazy, per plugin; calls reuse the -process and can run concurrently. stdout is exclusively protocol traffic; write -diagnostics to stderr. Environment inheritance is restricted to PATH, OS/temp and -locale values, plus `NYATERM_PLUGIN_ID`, `NYATERM_PLUGIN_VERSION`, `NYATERM_APP_VERSION`. -The working directory is the immutable installed version directory. - -The host first sends a JSON-RPC 2.0 request: - -```json -{ - "id": 1, - "jsonrpc": "2.0", - "method": "plugin/initialize", - "params": { - "pluginId": "example.tools", - "pluginVersion": "1.0.0", - "appVersion": "1.2.12", - "apiVersion": 1, - "protocolVersion": 1, - "permissions": ["native"] - } -} -``` - -Reply within 10 seconds with matching identity/version/protocol: - -```json -{ - "id": 1, - "jsonrpc": "2.0", - "result": { - "pluginId": "example.tools", - "pluginVersion": "1.0.0", - "protocolVersion": 1 - } -} -``` - -Normal host requests have numeric IDs and params `{scopeToken, input}`. Respond -with the same numeric ID and `result` or `error:{code,message}`. Backends may make -reverse host requests using a **string ID**, one of the host methods in the table, -and params `{scopeToken, input}`. They must propagate the token associated with the -specific call, not keep a global "current session". Host enforcement checks the -token's plugin, live session/window, version, grants and lock state. Other plugins' -tokens are rejected. Backends should handle `$/cancelRequest` notifications for -timed-out numeric host requests. Closing a scope stops waiting for its response; -native code must arrange its own cooperative work cancellation. - -`stdio-jsonl` is one UTF-8 JSON object per newline. `stdio-framed` uses: - -```text -kind: u8 | payload length: u32 little-endian | payload -kind 1: UTF-8 JSON-RPC -kind 2: channel length: u16 little-endian | UTF-8 channel | raw bytes -``` - -Frames are capped at 8 MiB; JSON at 2 MiB; channel names at 128 bytes. The runtime -supports notification/binary subscriptions for future provider integration, but -v1 browser APIs expose request/response calls only. There is no public UI binary -stream or backend event subscription. Do not use those frames to implement a UI -API yet. The runtime bounds queues and pending requests and terminates the child -on protocol failures, failed handshake, disable/update/uninstall, lock or exit. -A crashed backend is activated again on the next call. - -## Maintaining the host - -The independent runtime lives in `src-tauri/crates/nyaterm-plugin-runtime`; Tauri -adapters and permission checks live in `src-tauri/src/core/plugins`. Plugin UI -bridge code and the SDK are separately tested. - -`ipc_transport.js` and `process_ipc_message.js` preserve Tauri 2.11.5 transport -semantics, with a top-frame guard before initializing the invoke key. When -upgrading Tauri, compare these files with its upstream scripts and rerun the IPC -isolation/serialization tests. Do not remove the guard or add `allow-same-origin` -to plugin iframes to work around compatibility issues. - -On Windows, the full Tauri library test executable may need a Common Controls v6 -activation manifest to load its native UI dependencies. An entrypoint-not-found -error before any tests run is a loader failure; check the executable manifest -rather than interpreting it as a failed plugin assertion. - -```sh -pnpm exec vitest run -pnpm build -pnpm lint -cargo test --manifest-path src-tauri/crates/nyaterm-plugin-runtime/Cargo.toml -cargo test --manifest-path src-tauri/Cargo.toml --lib core::plugins -``` diff --git a/docs/web-deployment.md b/docs/web-deployment.md new file mode 100644 index 00000000..c0fd5614 --- /dev/null +++ b/docs/web-deployment.md @@ -0,0 +1,248 @@ +# Web / Docker 部署 + +Web 模式复用现有 React 界面,由 `nyaterm-web` 同时提供静态资源和 HTTP / WebSocket / SSE API。此 Beta 面向一个可信管理员:不同登录的终端、认证提示和运行中的 AI 请求隔离,但保存的连接、凭据、设置、known_hosts 和 AI 历史在同一实例内共享。 + +## 本地运行 + +需要 Node 24、pnpm 11.10 和 Rust 1.97.1(与 Dockerfile 一致)。 + +```powershell +pnpm install --frozen-lockfile +pnpm build:web +pnpm web:build:server +``` + +生成两个独立随机秘密并写入工作区外的受限目录。以下命令不打印秘密;登录密码可通过密码管理器读取保存。目录和文件的访问权限应限制为服务运行用户。 + +```powershell +$secretDir = Join-Path $env:LOCALAPPDATA 'NyaTermWebSecrets' +New-Item -ItemType Directory -Force -Path $secretDir | Out-Null +$loginBytes = [System.Security.Cryptography.RandomNumberGenerator]::GetBytes(32) +$keyBytes = [System.Security.Cryptography.RandomNumberGenerator]::GetBytes(32) +[System.IO.File]::WriteAllText((Join-Path $secretDir 'login'), [Convert]::ToBase64String($loginBytes)) +[System.IO.File]::WriteAllText((Join-Path $secretDir 'encryption'), [Convert]::ToBase64String($keyBytes)) +$env:NYATERM_WEB_PASSWORD_FILE = Join-Path $secretDir 'login' +$env:NYATERM_WEB_ENCRYPTION_KEY_FILE = Join-Path $secretDir 'encryption' +$env:NYATERM_WEB_PUBLIC_URL = 'http://localhost:8080/' +$env:NYATERM_WEB_BIND = '127.0.0.1:8080' +$env:NYATERM_WEB_DATA_DIR = Join-Path $env:LOCALAPPDATA 'NyaTermWebData' +pnpm web:serve +``` + +访问 `http://localhost:8080/`,使用生成的登录密码。PowerShell 示例要求 PowerShell 7 / .NET 的 `GetBytes(int)` API。Linux 可用 `umask 077` 后分别执行 `openssl rand -base64 32 > login` 和 `openssl rand -base64 32 > encryption`,然后配置相应 `_FILE` 变量。 + +## 环境变量 + +| 变量 | 含义 | +| ---------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- | +| `NYATERM_WEB_PASSWORD` / `NYATERM_WEB_PASSWORD_FILE` | 登录密码,至少 32 字符;推荐随机生成。指定 `_FILE` 时优先读取文件。 | +| `NYATERM_WEB_ENCRYPTION_KEY` / `NYATERM_WEB_ENCRYPTION_KEY_FILE` | 标准 Base64 编码的 32 字节随机密钥,必须持久保存,禁止重新生成后直接复用旧数据。 | +| `NYATERM_WEB_PUBLIC_URL` | 浏览器实际访问地址,含端口和 base path;默认 `http://localhost:8080/`。非回环地址默认必须为 HTTPS。 | +| `NYATERM_WEB_BIND` | 服务监听地址,默认 `127.0.0.1:8080`,容器为 `0.0.0.0:8080`。 | +| `NYATERM_WEB_DATA_DIR` | redb 数据目录,默认 `./nyaterm-web-data`,容器为 `/data`。 | +| `NYATERM_WEB_DIST` | 已构建 React 资源目录,默认 `dist`,容器为 `/app/dist`。 | +| `NYATERM_WEB_BASE_PATH` | **前端构建时**使用的路径,默认 `/`,必须与 PUBLIC_URL 的路径一致并以 `/` 结尾。 | + +`NYATERM_WEB_ALLOW_INSECURE_HTTP=true` 可显式允许局域网 HTTP 地址;同时将 `NYATERM_WEB_PUBLIC_URL` 设置为浏览器实际使用的 IP/域名和端口,并将监听地址设为 `0.0.0.0:8080`。前端请求 ID 使用 `crypto.getRandomValues` 兼容 HTTP,但剪贴板等浏览器能力仍可能要求 HTTPS。Host/Origin 校验继续生效,访问地址必须与 PUBLIC_URL 一致。 + +服务启动会验证数据密钥;错误密钥会阻止启动。文件方式会去掉末尾换行。登录密码可独立替换,服务器重启使所有旧登录失效;加密密钥目前没有在线轮换 API。 + +## Docker + +部署文件集中在 [deploy/web/](../deploy/web/README.md)。以下 Docker 和 Compose 命令均在仓库根目录执行;构建上下文仍是仓库根目录,使用根目录的 `.dockerignore`。 + +```sh +docker build -f deploy/web/Dockerfile -t nyaterm-web . +docker volume create nyaterm-data +docker run -d --name nyaterm-web --init \ + -p 127.0.0.1:8080:8080 \ + -e NYATERM_WEB_PUBLIC_URL=http://localhost:8080/ \ + -e NYATERM_WEB_PASSWORD_FILE=/run/secrets/login \ + -e NYATERM_WEB_ENCRYPTION_KEY_FILE=/run/secrets/encryption \ + --mount type=bind,src=/absolute/private/secrets,dst=/run/secrets,readonly \ + --mount type=volume,src=nyaterm-data,dst=/data \ + --read-only --tmpfs /tmp --cap-drop ALL \ + --security-opt no-new-privileges:true \ + nyaterm-web +``` + +容器使用 UID / GID `10001:10001`。绑定目录时,确保该用户可以读取秘密文件和写入数据目录;新 named volume 会继承镜像中 `/data` 的所有权。默认端口映射仅暴露本机。如需远程访问,使用下面的 HTTPS 反向代理配置。 + +仓库还提供 `deploy/web/docker-compose.yml`,它通过外部环境变量注入密码和密钥: + +```sh +docker compose -f deploy/web/docker-compose.yml up --build -d +``` + +提前在当前 shell 或仓库根目录的受限 `.env` 中设置两个必填秘密。使用外部环境文件时,在 Compose 命令中显式添加 `--env-file /absolute/private/nyaterm.env`。`.env*` 已排除在 Git 和 Docker build context 外。生产环境优先将 Compose 改为 `secrets` 挂载和 `_FILE` 变量,避免把秘密写入镜像。Dockerfile 使用 Node、Rust、Debian 三阶段构建,最终镜像只含 dist、服务程序和运行依赖。 + +Compose 默认项目名显式设为 `nyaterm`,与原先在名为 `nyaterm` 的仓库根目录运行的默认项目名一致,数据卷仍为 `nyaterm_nyaterm-data`。如果旧部署使用其他仓库目录名、`-p` 或 `COMPOSE_PROJECT_NAME`,迁移后须通过 `-p <原项目名>` 或 `COMPOSE_PROJECT_NAME` 沿用原值,确保继续使用原数据卷和镜像;原登录密码和加密密钥也须保持一致。 + +仓库 Compose 的 `8080:8080` 会发布到所有主机接口。仅本机访问或使用本机反向代理时,将它改为 `127.0.0.1:8080:8080`。 + +使用文件秘密时,可把下面的独立配置保存为工作区外的 `/absolute/private/compose.secrets.yml`。先按上面的命令构建 `nyaterm-web` 镜像,创建两个独立秘密文件,并确保 UID 10001 可以读取它们。此配置替代仓库 Compose,不与它叠加,避免原配置中必填的明文环境变量插值。 + +```yaml +name: nyaterm +services: + nyaterm: + image: nyaterm-web + ports: + - "127.0.0.1:8080:8080" + environment: + NYATERM_WEB_PUBLIC_URL: https://terminal.example.com/ + NYATERM_WEB_PASSWORD_FILE: /run/secrets/login + NYATERM_WEB_ENCRYPTION_KEY_FILE: /run/secrets/encryption + secrets: + - login + - encryption + volumes: + - nyaterm-data:/data + restart: unless-stopped + init: true + read_only: true + tmpfs: + - /tmp + security_opt: + - no-new-privileges:true + cap_drop: + - ALL +secrets: + login: + file: /absolute/private/login + encryption: + file: /absolute/private/encryption +volumes: + nyaterm-data: +``` + +```sh +docker compose -f /absolute/private/compose.secrets.yml up -d +``` + +将 PUBLIC_URL 换为实际 HTTPS 地址,并配置下面的反向代理。Compose 文件秘密以只读挂载提供;宿主机文件权限仍决定容器能否读取,不能依赖 Compose 的 `uid` / `gid` 为绑定文件改属主。 + +## HTTPS 和子路径 + +例如部署到 `https://terminal.example.com/nyaterm/`: + +```sh +docker build -f deploy/web/Dockerfile --build-arg NYATERM_WEB_BASE_PATH=/nyaterm/ -t nyaterm-web . +``` + +运行时设置 `NYATERM_WEB_PUBLIC_URL=https://terminal.example.com/nyaterm/`。前端和后端必须使用相同的 origin 和路径;后端会校验原始 Host / Origin,不使用转发头放宽校验。代理应保留完整路径,不能剥掉 `/nyaterm`。 + +使用仓库 Compose 时,构建前同时设置 `NYATERM_WEB_BASE_PATH=/nyaterm/` 和上述 PUBLIC_URL,再执行 `up --build -d`。HTTPS 地址使登录 Cookie 带上 Secure;TLS 可在代理终止,后端仍使用内部 HTTP。 + +Nginx 示例(TLS 证书配置按已有站点设置): + +```nginx +# http 块 +map $http_upgrade $nyaterm_connection_upgrade { + default upgrade; + '' close; +} + +# 已配置 TLS 的 server 块 +location = /nyaterm { return 308 /nyaterm/; } +location /nyaterm/ { + proxy_pass http://127.0.0.1:8080; + proxy_http_version 1.1; + proxy_set_header Host $http_host; + proxy_set_header Origin $http_origin; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $nyaterm_connection_upgrade; + proxy_buffering off; + proxy_request_buffering off; + proxy_read_timeout 650s; + client_max_body_size 0; +} +``` + +关闭响应缓冲以支持 SSE / 下载,关闭请求缓冲以支持流式上传。可根据部署需求调整上传大小和代理超时。Web 不提供跨源 API 或 Vite 开发代理;验证运行模式应使用构建后的 dist。 + +## 数据与能力范围 + +停服后备份整个数据目录,并单独备份加密密钥与登录密码。密钥丢失将无法解密凭据。不要让多个进程同时写同一个 redb volume,也不要把桌面用户数据目录直接作为服务器 volume。保存的私人密钥、密码和 AI provider 密钥使用原有 AES-GCM 存储。AI 历史和连接元数据属于实例共享数据,未作为整个数据库加密。 + +支持保存/临时 SSH、Telnet 和 VNC,密码/私钥与手工交互认证、主机指纹确认、终端输入/输出/resize、SFTP 浏览与单文件流式上传/下载、文本编辑、基础 AI Ask、设置持久化以及资源/GPU/NPU/进程监控。监控使用独立 SSH exec 通道,Docker 管理仍不支持。Telnet 支持原有字符编码、自动登录、本地回显、行编辑、NAWS 和 raw TCP;VNC 支持现有认证与服务器密钥确认、缩放、共享连接、只读、文本剪贴板和重连。三种协议均支持 SOCKS5、HTTP CONNECT 及代理认证、SSH 跳板(最多 8 层,使用跳板自身网络配置)。代理失败不回退到直连。插件部分提供兼容性及权限检查框架,当前没有浏览器插件安装/执行器。 + +Web 当前不支持本地 Shell、Serial、RDP、ProxyCommand、SSH agent/X11/证书登录、SSH 启动命令、非标准 SSH profile、非 UTF-8 SSH 终端/文件名、SFTP compatibility mode、CWD 自动跟踪、录制、SCP/Zmodem、本地文件 watcher、传输暂停/重试、同步备份和 AI Agent/MCP/本地附件。系统托盘、原生窗口、OS credential manager、系统全局快捷键、桌面通知、自动更新也不提供。浏览器文件、剪贴板、链接和 iframe 页面代替相应原生入口,仍受浏览器权限约束。 + +刷新页面时,按工作区 pane ID 重新附着当前登录的有效会话;旧连接租期结束后,保存的连接按原有工作区恢复流程重新创建。SFTP 仅对 SSH 会话提供。浏览器剪贴板需要 HTTPS 和浏览器权限,未授权时显示提示,画面和键鼠仍可使用。网络面板可管理代理及分组,Web 子窗口使用同源 iframe。单镜像部署,无需 noVNC、Guacamole 或额外服务。 + +更新后请重新构建镜像并重建容器。普通 `docker compose down` 保留数据卷;`down -v` 会删除数据卷。恢复停服备份时,使用原加密密钥、恢复整个数据目录及 UID/GID 10001 的读写权限,再启动单个实例。浏览器中的密码备份用于配置迁移,不能代替整个数据目录的备份。 + +## Web Beta 能力矩阵与验收 + +| 能力 | Web Beta | 桌面兼容 | +| ------------------------------- | ------------------------------------------------------- | ---------------------------------- | +| SSH/Telnet | 登录 owner 隔离、输入输出、刷新附着、有限重试恢复 | 原生协议与生命周期保持 | +| VNC | 现有 Web 认证、输入、剪贴板及恢复 | 保持 | +| 单文件上传/下载 | 流式原始上传;浏览器 Blob 下载并提示失败 | 保持原生传输 | +| 同名上传 | ask/skip/rename/overwrite;POSIX 原子覆盖,拒绝目录覆盖 | 共用 duplicate_strategy 设置 | +| 文件编辑 | 默认内部编辑器,工作区或 iframe;二进制/不支持编码下载 | 保留外部编辑器、watcher 和本地路径 | +| 传输设置 | 仅冲突策略与内部编辑显示方式 | 隐藏字段完整保留,无迁移 | +| 目录传输、调优、暂停/重试 | 未实现,隐藏入口;含目录选择禁用下载 | 保持 | +| 资源/GPU/NPU/进程监控 | 独立 SSH exec 与白名单进程信号 | 保持 | +| RDP、录制、云同步、AI Agent/MCP | 当前不支持 | 保持 | + +上传继续使用 `POST api/sessions/{id}/upload?path=...` 和原始文件正文;返回 `{ "bytes": 123, "status": "completed", "path": "/final/path" }`。跳过返回 bytes=0、status=skipped;改名返回 UUID 后缀的最终 path。覆盖依赖远端 `posix-rename@openssh.com`;不支持时明确失败并保留原文件,不回退到先删后写。 + +SSH/Telnet 初次附着及断线恢复的每轮预算为 25 秒,单次握手最多 5 秒;退避依次 1、2、4 秒,之后不超过 5 秒。WebSocket 断开、发送失败或超时只释放当前附着;服务端保留远端会话约 30 秒,期间可重新附着同一个 session ID。输出队列有界,断开期间队列填满会对远端施加背压;不保证重放已经交给旧 WebSocket 的字节。握手失败会查询当前会话;401/404 停止恢复,其余临时失败继续到预算结束。显式关闭、注销、登录过期、远端结束或租约到期会结束会话。注销从 File 菜单进入。 + +完整合并检查由 [Web merge checks](../.github/workflows/web-merge-checks.yml) 执行,包含前端 lint、国际化、Vitest、Web 构建、Core / Web Rust 测试、三个平台的桌面 cargo check,以及 Docker / Chromium 验收。Docker/OpenSSH 验收需要 Docker CLI: + +```sh +pnpm exec playwright install chromium +pnpm test:web:e2e +``` + +脚本为根路径和 `/nyaterm/` 构建独立镜像,启动临时 OpenSSH 容器,检查登录、持续输出中的刷新与原会话重附着、文件编辑/传输和注销。失败产物保存在 `artifacts/web-e2e/`。部署后还需通过实际 HTTPS 代理确认登录、终端、SSE 与上传下载。 + +## 密码备份与文件导入 + +File 菜单的导出在浏览器中设置并确认本次备份密码,下载密码保护的 `.nya` 文件;导入选择文件、输入该文件的原备份密码,并确认覆盖实例配置。取消选择不会报错,执行期间不能重复提交。备份密码与 Web 登录密码、部署加密密钥相互独立,仅在本次请求中使用。 + +`.nya` 延续桌面已有格式和旧版解码能力。Web 导出将凭据包装到独立的备份密钥中;Web 导入解开源密钥,把凭据重新加密到目标实例。可以在使用不同部署密钥的 Web 实例之间迁移,不需要复制或替换目标 `NYATERM_WEB_ENCRYPTION_KEY`。桌面导出的备份密码是导出时的桌面主密码;当前桌面导入入口也使用桌面主密码解密文件,因此导入 Web 备份前需将桌面主密码设为该备份密码。 + +导入先校验密码、完整性、数据关系和凭据,再用单个 redb 事务恢复;失败保留原配置。备份中的 Serial、本地 Shell 等桌面配置会保留,Web 能力检查控制使用。导入成功后刷新连接、设置、快捷命令、历史和笔记。运行中的会话仍使用已建立的连接。 + +桌面备份中的旧连接内联明文密码会在导入时转换为目标实例的加密凭据,空字符串按未保存密码处理。符合 AES-GCM 密文格式的字段仍必须通过解密校验,不会在解密失败后当作明文保存;私钥、托管账号、OTP 等其他凭据继续严格校验。 + +连接导入支持 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 -f deploy/web/docker-compose.yml logs --tail 200 nyaterm` 查看。同时写入数据目录的 `logs/` 子目录。日志按 UTC 日期或 10 MiB 轮转,总量不超过 100 MiB;诊断设置中的级别和保留天数保存后立即生效,保留天数为 1–30 天。文件写入失败时继续 stdout 日志并保持业务运行,正常停机刷新日志。 + +仓库 Compose 未单独配置 Docker 日志轮转,沿用 Docker daemon 的日志驱动及限制。可在服务中添加 `logging: { driver: json-file, options: { max-size: "10m", max-file: "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/`。 + +该本机脚本只覆盖不依赖 OpenSSH 容器的浏览器功能;完整 SSH 刷新恢复和容器部署验收使用 `pnpm test:web:e2e`。当前提交的验证结果以对应的 CI run 为准。 diff --git a/e2e/deployment.spec.mjs b/e2e/deployment.spec.mjs new file mode 100644 index 00000000..5fb66f9e --- /dev/null +++ b/e2e/deployment.spec.mjs @@ -0,0 +1,303 @@ +import { test, expect } from "@playwright/test"; +import { readFileSync } from "node:fs"; +import { gotoLogin } from "./loginNavigation.mjs"; + +const en = JSON.parse( + readFileSync(new URL("../src/i18n/locales/en.json", import.meta.url), "utf8"), +); +const t = (key) => key.split(".").reduce((value, part) => value[part], en); + +test("single administrator: login, SSH, refresh, editor, conflicts, download, child settings, logout", async ({ + page, +}, testInfo) => { + if ( + !process.env.NYATERM_E2E_PASSWORD || + !process.env.NYATERM_E2E_SSH_PASSWORD + ) + throw new Error( + "Run pnpm test:web:e2e to provision disposable Docker credentials.", + ); + const base = process.env.NYATERM_E2E_URL; + const errors = [], + output = []; + page.on("websocket", (socket) => { + if (socket.url().endsWith("/terminal")) + socket.on("framereceived", ({ payload }) => { + if (Buffer.isBuffer(payload)) output.push(payload); + }); + }); + const terminalOutput = () => Buffer.concat(output).toString("utf8"); + try { + await gotoLogin(page, base); + page.on("pageerror", (error) => errors.push(String(error))); + await page + .getByLabel(t("web.password")) + .fill(process.env.NYATERM_E2E_PASSWORD); + await page + .getByRole("button", { name: t("web.signIn"), exact: true }) + .click(); + await expect(page.getByLabel(t("web.password"))).toHaveCount(0); + const auth = await ( + await page.request.get(new URL("api/auth/session", base).href) + ).json(); + const headers = { + "X-Nyaterm-Csrf": auth.csrf, + "X-Nyaterm-Request": "1", + Origin: new URL(base).origin, + }; + const command = async (name, args = {}) => { + const response = await page.request.post( + new URL(`api/commands/${name}`, base).href, + { headers, data: args }, + ); + expect(response.ok(), `${name}: ${await response.text()}`).toBe(true); + return response.json(); + }; + const settings = await command("get_app_settings"); + settings.general.startup_restore = true; + settings.ui.language = "en"; + settings.ui.file_explorer_view_mode = "list"; + settings.ui.active_left_panel = "fileExplorer"; + settings.ui.active_right_panel = "savedConnections"; + settings.ui.left_open_panels = ["fileExplorer"]; + settings.ui.right_open_panels = ["savedConnections"]; + settings.transfer.editor_type = "external"; // Web must override this without persisting a change. + settings.transfer.internal_editor_display = "workspace"; + settings.transfer.duplicate_strategy = "ask"; + await command("save_app_settings", { settings }); + await command("save_connection", { + connection: { + id: "", + name: "E2E OpenSSH", + type: "ssh", + host: "ssh-fixture", + port: 22, + username: "test", + auth: { + mode: "password", + password: process.env.NYATERM_E2E_SSH_PASSWORD, + }, + }, + }); + await page.reload(); + await page.getByText("E2E OpenSSH", { exact: true }).first().dblclick(); + await expect( + page.getByText(t("settings.hostKeyVerifyTitle"), { exact: true }), + ).toBeVisible(); + await page + .getByRole("button", { + name: t("settings.hostKeyVerifyAccept"), + exact: true, + }) + .click(); + await expect + .poll( + async () => + (await command("get_sessions")).filter((session) => session.connected) + .length, + ) + .toBe(1); + const sessionId = (await command("get_sessions"))[0].id; + expect(sessionId).toBeTruthy(); + async function typeCommand(marker) { + const terminal = page.locator(".xterm-helper-textarea").first(); + await terminal.focus(); + await terminal.pressSequentially(`printf 'WEB_%s\\n' '${marker}'`, { + delay: 5, + }); + await terminal.press("Enter"); + await expect.poll(terminalOutput).toContain(`WEB_${marker}`); + } + await typeCommand("READY"); + await expect + .poll(async () => (await command("get_app_settings")).ui.open_tabs.length) + .toBeGreaterThan(0); + // Leave a remote producer running across reload; quiet terminals do not + // exercise the server's WebSocket send/disconnect race. + const terminal = page.locator(".xterm-helper-textarea").first(); + await terminal.focus(); + await terminal.pressSequentially( + "while :; do printf 'WEB_%s\\n' 'STREAMING'; sleep 0.01; done & NYATERM_E2E_OUTPUT_PID=$!", + ); + await terminal.press("Enter"); + await expect.poll(terminalOutput).toContain("WEB_STREAMING"); + await page.reload(); + await expect(page.locator(".xterm-helper-textarea").first()).toBeAttached(); + output.length = 0; + await expect.poll(terminalOutput).toContain("WEB_STREAMING"); + expect( + (await command("get_sessions")).map((session) => session.id), + ).toEqual([sessionId]); + await terminal.focus(); + await terminal.pressSequentially( + 'kill "$NYATERM_E2E_OUTPUT_PID"; wait "$NYATERM_E2E_OUTPUT_PID" 2>/dev/null', + ); + await terminal.press("Enter"); + await typeCommand("REFRESHED"); + expect((await command("get_sessions"))[0].id).toBe(sessionId); + + const readme = page.getByText("readme.txt", { exact: true }).first(); + await readme.dblclick(); + const editor = page.locator(".cm-content").first(); + await expect(editor).toContainText("fixture text"); + await editor.fill("edited through Chromium\n"); + await page + .getByRole("button", { name: t("fileEditor.save"), exact: true }) + .click(); + await expect + .poll( + async () => + ( + await command("open_remote_file_text", { + sessionId, + path: "/home/test/readme.txt", + }) + ).file.content, + ) + .toBe("edited through Chromium\n"); + expect((await command("get_app_settings")).transfer.editor_type).toBe( + "external", + ); + + const binaryDownload = page.waitForEvent("download"); + await page.getByText("binary.bin", { exact: true }).first().dblclick(); + const binary = await binaryDownload; + expect(readFileSync(await binary.path())).toEqual( + Buffer.from([0, 1, 2, 3]), + ); + await expect( + page.getByText(t("fileExplorer.webUnsupportedDownload")), + ).toBeVisible(); + + async function upload(content) { + await page + .getByRole("button", { name: t("fileExplorer.upload"), exact: true }) + .click(); + const chooser = page.waitForEvent("filechooser"); + await page + .getByRole("menuitem", { name: t("fileExplorer.upload"), exact: true }) + .click(); + await ( + await chooser + ).setFiles({ + name: "readme.txt", + mimeType: "text/plain", + buffer: Buffer.from(content), + }); + } + for (const action of ["duplicateSkip", "duplicateOverwrite"]) { + const response = page.waitForResponse((response) => + response.url().includes(`/sessions/${sessionId}/upload?`), + ); + await upload("browser upload\n"); + await page + .getByRole("button", { name: t(`fileTransfer.${action}`), exact: true }) + .click(); + const result = await (await response).json(); + expect(result.status).toBe( + action === "duplicateSkip" ? "skipped" : "completed", + ); + expect(result.path).toBe("/home/test/readme.txt"); + const saved = await command("open_remote_file_text", { + sessionId, + path: result.path, + }); + expect(saved.file.content).toBe( + action === "duplicateSkip" + ? "edited through Chromium\n" + : "browser upload\n", + ); + } + const current = await command("get_app_settings"); + current.transfer.duplicate_strategy = "rename"; + await command("save_app_settings", { settings: current }); + const renamedResponse = page.waitForResponse((response) => + response.url().includes(`/sessions/${sessionId}/upload?`), + ); + await upload("renamed browser upload\n"); + const renamed = await (await renamedResponse).json(); + expect(renamed.path).toMatch(/^\/home\/test\/readme\.txt\.[0-9a-f-]{36}$/); + expect( + ( + await command("open_remote_file_text", { + sessionId, + path: renamed.path, + }) + ).file.content, + ).toBe("renamed browser upload\n"); + const downloaded = page.waitForEvent("download"); + const readmeRow = page.getByRole("listitem").filter({ + has: page.getByText("readme.txt", { exact: true }), + }); + await expect(readmeRow).toHaveCount(1); + // Click the visible filename, not the center of an overflowing grid row. + await readmeRow.getByText("readme.txt", { exact: true }).click(); + await page + .getByRole("button", { + name: t("fileExplorer.downloadSelected"), + exact: true, + }) + .click(); + const textDownload = await downloaded; + expect(textDownload.suggestedFilename()).toBe("readme.txt"); + expect(readFileSync(await textDownload.path()).toString()).toBe( + "browser upload\n", + ); + + 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.transfer"), exact: true }) + .click(); + await expect(child.getByRole("combobox")).toHaveCount(2); + await expect( + child.getByText(t("settings.downloadPath"), { exact: true }), + ).toHaveCount(0); + await child.getByRole("combobox").nth(1).click(); + await child + .getByRole("option", { + name: t("settings.internalEditorDisplayWindow"), + 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 + .getByText(renamed.path.split("/").pop(), { exact: true }) + .first() + .dblclick(); + const fileChild = page.frameLocator('iframe[src*="window=file-editor"]'); + await expect(fileChild.locator(".cm-content")).toContainText( + "renamed browser upload", + ); + await fileChild + .getByRole("button", { name: t("common.close"), exact: true }) + .last() + .click(); + await expect(page.locator('iframe[src*="window=file-editor"]')).toHaveCount( + 0, + ); + + await page.getByText(t("menu.file"), { exact: true }).first().click(); + await page.getByText(t("web.signOut"), { exact: true }).click(); + const quit = page.getByRole("button", { + name: t("dialog.confirmCloseAction"), + exact: true, + }); + if (await quit.isVisible()) await quit.click(); + await expect(page.getByLabel(t("web.password"))).toBeVisible(); + expect( + (await page.request.get(new URL("api/auth/session", base).href)).status(), + ).toBe(401); + expect(errors).toEqual([]); + } finally { + await testInfo.attach("browser-errors", { + body: errors.join("\n"), + contentType: "text/plain", + }); + } +}); diff --git a/e2e/loginNavigation.mjs b/e2e/loginNavigation.mjs new file mode 100644 index 00000000..52eb2b71 --- /dev/null +++ b/e2e/loginNavigation.mjs @@ -0,0 +1,40 @@ +// Docker network changes can invalidate Chromium's first module requests. +// Retry only that transport failure, before any login or application actions. +export async function gotoLogin(page, url) { + for (let attempt = 0; attempt < 3; attempt++) { + let networkChanged = false; + const pageErrors = []; + const onRequestFailed = (request) => { + if (request.failure()?.errorText === "net::ERR_NETWORK_CHANGED") + networkChanged = true; + }; + const onPageError = (error) => pageErrors.push(error); + page.on("requestfailed", onRequestFailed); + page.on("pageerror", onPageError); + try { + await page.goto(url); + await page.locator("#web-password").waitFor({ + state: "visible", + timeout: 10_000, + }); + if (pageErrors.length) + throw new AggregateError(pageErrors, "Errors during login navigation"); + return; + } catch (error) { + networkChanged ||= String(error).includes("net::ERR_NETWORK_CHANGED"); + const unexpectedError = pageErrors.some( + (entry) => + !String(entry).includes( + "Failed to fetch dynamically imported module", + ), + ); + if (!networkChanged || unexpectedError || attempt === 2) throw error; + console.warn( + `Retrying login navigation after ERR_NETWORK_CHANGED (${attempt + 1}/2)`, + ); + } finally { + page.off("requestfailed", onRequestFailed); + page.off("pageerror", onPageError); + } + } +} diff --git a/e2e/playwright.config.mjs b/e2e/playwright.config.mjs new file mode 100644 index 00000000..a99b8847 --- /dev/null +++ b/e2e/playwright.config.mjs @@ -0,0 +1,22 @@ +import { defineConfig } from "@playwright/test"; + +export default defineConfig({ + testDir: ".", + 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, + retries: 0, + outputDir: + process.env.NYATERM_E2E_ARTIFACTS ?? "../artifacts/web-e2e/results", + reporter: [["list"]], + use: { + baseURL: process.env.NYATERM_E2E_URL ?? "http://localhost:18080/", + browserName: "chromium", + viewport: { width: 1440, height: 1000 }, + trace: "retain-on-failure", + screenshot: "only-on-failure", + }, +}); diff --git a/e2e/ssh/Dockerfile b/e2e/ssh/Dockerfile new file mode 100644 index 00000000..0c8495f9 --- /dev/null +++ b/e2e/ssh/Dockerfile @@ -0,0 +1,10 @@ +FROM debian:bookworm-slim +RUN apt-get update && apt-get install -y --no-install-recommends openssh-server \ + && rm -rf /var/lib/apt/lists/* && useradd --create-home --shell /bin/bash test \ + && mkdir -p /run/sshd && rm -f /etc/ssh/ssh_host_* \ + && printf 'PasswordAuthentication yes\nPermitRootLogin no\nUsePAM no\nSubsystem sftp internal-sftp\n' > /etc/ssh/sshd_config +COPY start.sh /start.sh +RUN sed -i 's/\r$//' /start.sh && chmod +x /start.sh +VOLUME ["/home/test"] +EXPOSE 22 +ENTRYPOINT ["/start.sh"] diff --git a/e2e/ssh/start.sh b/e2e/ssh/start.sh new file mode 100644 index 00000000..48faa400 --- /dev/null +++ b/e2e/ssh/start.sh @@ -0,0 +1,9 @@ +#!/bin/sh +set -eu +printf 'test:%s\n' "$(cat /run/secrets/ssh-password)" | chpasswd +ssh-keygen -A +printf 'fixture text\n' > /home/test/readme.txt +printf '\000\001\002\003' > /home/test/binary.bin +mkdir -p /home/test/folder +chown -R test:test /home/test +exec /usr/sbin/sshd -D -e diff --git a/e2e/web-features.spec.mjs b/e2e/web-features.spec.mjs new file mode 100644 index 00000000..d6348494 --- /dev/null +++ b/e2e/web-features.spec.mjs @@ -0,0 +1,348 @@ +import { test, expect } from "@playwright/test"; +import { readFileSync } from "node:fs"; +import { randomUUID } from "node:crypto"; +import { gotoLogin } from "./loginNavigation.mjs"; + +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 gotoLogin(page, 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 = []; + await gotoLogin(page, base); + page.on("pageerror", (error) => errors.push(String(error))); + 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/index.html b/index.html index 1e997127..71a73d68 100644 --- a/index.html +++ b/index.html @@ -3,7 +3,7 @@ - + NyaTerm diff --git a/package.json b/package.json index b0b504e6..541cc3d2 100644 --- a/package.json +++ b/package.json @@ -41,7 +41,13 @@ "fmt:rust:check": "cargo fmt --manifest-path src-tauri/Cargo.toml -- --check", "i18n:check": "prettier --check src/i18n/locales/*.json", "i18n:fix": "prettier --write src/i18n/locales/*.json", - "postinstall": "node scripts/patch-xterm-webgl-clear-model.cjs && node scripts/patch-xterm-wide-char-selection.cjs && node scripts/patch-xterm-foreground-intense.cjs" + "postinstall": "node scripts/patch-xterm-webgl-clear-model.cjs && node scripts/patch-xterm-wide-char-selection.cjs && node scripts/patch-xterm-foreground-intense.cjs", + "build:web": "tsc && vite build", + "web:build:server": "cargo build --manifest-path src-tauri/crates/nyaterm-web/Cargo.toml --release --locked", + "web:serve": "cargo run --manifest-path src-tauri/crates/nyaterm-web/Cargo.toml --locked", + "test:web": "cargo test --manifest-path src-tauri/crates/nyaterm-web/Cargo.toml --locked", + "i18n:keys": "node scripts/check-i18n-keys.mjs", + "test:web:e2e": "node scripts/web-e2e.mjs" }, "dependencies": { "@codemirror/autocomplete": "^6.20.3", @@ -91,6 +97,7 @@ "cmdk": "^1.1.1", "i18next": "^25.8.11", "input-otp": "^1.4.2", + "jsqr": "^1.4.0", "lucide-react": "^0.575.0", "motion": "^12.34.3", "next-themes": "^0.4.6", @@ -111,6 +118,7 @@ }, "devDependencies": { "@biomejs/biome": "^2.4.2", + "@playwright/test": "^1.63.0", "@tailwindcss/vite": "^4.1.18", "@tauri-apps/cli": "^2.11.4", "@testing-library/react": "^16.3.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index da4b7bcd..cd6e9b53 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -149,6 +149,9 @@ importers: input-otp: specifier: ^1.4.2 version: 1.4.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + jsqr: + specifier: ^1.4.0 + version: 1.4.0 lucide-react: specifier: ^0.575.0 version: 0.575.0(react@19.2.4) @@ -204,6 +207,9 @@ importers: '@biomejs/biome': specifier: ^2.4.2 version: 2.4.2 + '@playwright/test': + specifier: ^1.63.0 + version: 1.63.0 '@tailwindcss/vite': specifier: ^4.1.18 version: 4.1.18(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)) @@ -3125,6 +3131,11 @@ packages: resolution: {integrity: sha512-C2Xj8FZ0uHWeCXXqX5B4/gVFQmtSkiuOolzAgutjTfseNOHT3pUjljDZsTSxXFGgio54bCzVFqmEOUrIVk8RDA==} engines: {node: '>=20.0.0'} + '@playwright/test@1.63.0': + resolution: {integrity: sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==} + engines: {node: '>=20'} + hasBin: true + '@pnpm/config.env-replace@1.1.0': resolution: {integrity: sha512-htyl8TWnKL7K/ESFa1oW2UB5lVDxuF5DpM7tBi6Hu2LNL3mWkIzNLG6N4zoCUP1lCKNxWy/3iu8mS8MvToGd6w==} engines: {node: '>=12.22.0'} @@ -6590,6 +6601,9 @@ packages: jsonfile@6.2.0: resolution: {integrity: sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==} + jsqr@1.4.0: + resolution: {integrity: sha512-dxLob7q65Xg2DvstYkRpkYtmKm2sPJ9oFhrhmudT1dZvNFFTlroai3AWSpLey/w5vMcLBXRgOJsbXpdN9HzU/A==} + keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} @@ -7560,6 +7574,16 @@ packages: resolution: {integrity: sha512-emEcLuomt2j03vxD54giVB4SxTjnsqkU692xZOZXHDVoYyypEm+b3jpiTcc+Cf+myooc+/Ly0z01jqeNHVgJGw==} engines: {node: '>=16.0.0'} + playwright-core@1.63.0: + resolution: {integrity: sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==} + engines: {node: '>=20'} + hasBin: true + + playwright@1.63.0: + resolution: {integrity: sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==} + engines: {node: '>=20'} + hasBin: true + postcss-attribute-case-insensitive@7.0.1: resolution: {integrity: sha512-Uai+SupNSqzlschRyNx3kbCTWgY/2hcwtHEI/ej2LJWc9JJ77qKgGptd8DHwY1mXtZ7Aoh4z4yxfwMBue9eNgw==} engines: {node: '>=18'} @@ -13409,6 +13433,10 @@ snapshots: tslib: 2.8.1 tsyringe: 4.10.0 + '@playwright/test@1.63.0': + dependencies: + playwright: 1.63.0 + '@pnpm/config.env-replace@1.1.0': {} '@pnpm/network.ca-file@1.0.2': @@ -17057,6 +17085,8 @@ snapshots: optionalDependencies: graceful-fs: 4.2.11 + jsqr@1.4.0: {} + keyv@4.5.4: dependencies: json-buffer: 3.0.1 @@ -18218,6 +18248,12 @@ snapshots: pvutils: 1.1.5 tslib: 2.8.1 + playwright-core@1.63.0: {} + + playwright@1.63.0: + dependencies: + playwright-core: 1.63.0 + postcss-attribute-case-insensitive@7.0.1(postcss@8.5.6): dependencies: postcss: 8.5.6 diff --git a/scripts/check-i18n-keys.mjs b/scripts/check-i18n-keys.mjs new file mode 100644 index 00000000..65caf524 --- /dev/null +++ b/scripts/check-i18n-keys.mjs @@ -0,0 +1,31 @@ +import { readFileSync } from "node:fs"; +function keys(value, prefix = "") { + return Object.entries(value) + .flatMap(([key, child]) => { + const path = prefix ? `${prefix}.${key}` : key; + return typeof child === "object" && child !== null + ? keys(child, path) + : [path]; + }) + .map((key) => key.replace(/_(zero|one|two|few|many|other|plural)$/, "")) + .filter((key, index, all) => all.indexOf(key) === index) + .sort(); +} +const locales = ["en", "zh-CN", "zh-TW", "ko"]; +const reference = keys( + JSON.parse(readFileSync(`src/i18n/locales/en.json`, "utf8")), +); +for (const locale of locales.slice(1)) { + const current = keys( + JSON.parse(readFileSync(`src/i18n/locales/${locale}.json`, "utf8")), + ); + const missing = reference.filter((key) => !current.includes(key)); + const extra = current.filter((key) => !reference.includes(key)); + if (missing.length || extra.length) + throw new Error( + `${locale}: missing ${missing.join(", ")}; extra ${extra.join(", ")}`, + ); +} +console.log( + `All four locales have the same ${reference.length} translation keys.`, +); diff --git a/scripts/web-e2e.mjs b/scripts/web-e2e.mjs new file mode 100644 index 00000000..017d9e7a --- /dev/null +++ b/scripts/web-e2e.mjs @@ -0,0 +1,194 @@ +import { spawnSync } from "node:child_process"; +import { randomBytes, randomUUID } from "node:crypto"; +import { + chmodSync, + mkdirSync, + mkdtempSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { createServer } from "node:net"; +import { tmpdir } from "node:os"; +import { resolve, join, relative, basename } from "node:path"; +import { setTimeout as delay } from "node:timers/promises"; + +const id = `nyaterm-e2e-${randomUUID().slice(0, 8)}`; +const secrets = mkdtempSync(join(tmpdir(), `${id}-`)); +const artifacts = resolve("artifacts/web-e2e"); +mkdirSync(artifacts, { recursive: true }); +const password = randomBytes(32).toString("base64"); +const sshPassword = randomBytes(32).toString("base64"); +for (const [name, value] of Object.entries({ + login: password, + encryption: randomBytes(32).toString("base64"), + "ssh-password": sshPassword, +})) { + writeFileSync(join(secrets, name), value, { mode: 0o644 }); +} +chmodSync(secrets, 0o755); // Container UID 10001 must read only these disposable credentials. +const docker = (args, capture = false, optional = false) => { + const result = spawnSync("docker", args, { + encoding: "utf8", + stdio: capture ? "pipe" : "inherit", + }); + if (!optional && (result.error || result.status !== 0)) + throw new Error(`Docker ${args[0]} failed`); + return ( + (result.stdout ?? "") + (args[0] === "logs" ? (result.stderr ?? "") : "") + ); +}; +const mount = (name, target) => [ + "--mount", + `type=bind,src=${join(secrets, name)},dst=/run/secrets/${target},readonly`, +]; +const failures = []; +const containers = [], + volumes = [], + images = []; +let networkCreated = false; +async function freePort() { + const server = createServer(); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolve); + }); + const port = server.address().port; + await new Promise((resolve) => server.close(resolve)); + return port; +} +try { + docker(["version"], true); + docker(["network", "create", id]); + networkCreated = true; + docker(["build", "-t", `${id}-ssh`, "e2e/ssh"]); + images.push(`${id}-ssh`); + for (const [name, basePath] of [ + ["root", "/"], + ["subpath", "/nyaterm/"], + ]) { + const output = join(artifacts, name); + mkdirSync(output, { recursive: true }); + const image = `${id}-${name}`; + docker([ + "build", + "-f", + "deploy/web/Dockerfile", + "--build-arg", + `NYATERM_WEB_BASE_PATH=${basePath}`, + "-t", + image, + ".", + ]); + images.push(image); + const ssh = `${id}-${name}-ssh`, + web = `${id}-${name}-web`; + const sshVolume = `${ssh}-data`, + webVolume = `${web}-data`; + for (const volume of [sshVolume, webVolume]) { + docker(["volume", "create", volume]); + volumes.push(volume); + } + containers.push(ssh, web); + docker([ + "run", + "-d", + "--name", + ssh, + "--network", + id, + "--network-alias", + "ssh-fixture", + ...mount("ssh-password", "ssh-password"), + "--mount", + `type=volume,src=${sshVolume},dst=/home/test`, + `${id}-ssh`, + ]); + const port = await freePort(); + const url = `http://localhost:${port}${basePath}`; + docker([ + "run", + "-d", + "--init", + "--name", + web, + "--network", + id, + "-p", + `127.0.0.1:${port}:8080`, + "-e", + `NYATERM_WEB_PUBLIC_URL=${url}`, + "-e", + "NYATERM_WEB_PASSWORD_FILE=/run/secrets/login", + "-e", + "NYATERM_WEB_ENCRYPTION_KEY_FILE=/run/secrets/encryption", + ...mount("login", "login"), + ...mount("encryption", "encryption"), + "--mount", + `type=volume,src=${webVolume},dst=/data`, + "--read-only", + "--tmpfs", + "/tmp", + "--cap-drop", + "ALL", + "--security-opt", + "no-new-privileges:true", + image, + ]); + try { + const deadline = Date.now() + 60_000; + for (;;) { + try { + if ((await fetch(url, { signal: AbortSignal.timeout(2000) })).ok) + break; + } catch {} + if (Date.now() >= deadline) throw new Error("Web readiness timed out"); + await delay(500); + } + const result = spawnSync( + process.execPath, + [ + "node_modules/@playwright/test/cli.js", + "test", + "--config", + "e2e/playwright.config.mjs", + ], + { + stdio: "inherit", + env: { + ...process.env, + NYATERM_E2E_URL: url, + NYATERM_E2E_PASSWORD: password, + NYATERM_E2E_SSH_PASSWORD: sshPassword, + NYATERM_E2E_ARTIFACTS: join(output, "results"), + }, + }, + ); + if (result.error || result.status !== 0) + throw new Error(`Deployment acceptance failed for ${basePath}`); + } catch (error) { + failures.push(error); + } finally { + for (const container of [ssh, web]) + writeFileSync( + join(output, `${container.endsWith("ssh") ? "ssh" : "web"}.log`), + docker(["logs", container], true, true), + ); + docker(["rm", "-f", web, ssh], true, true); + } + } + if (failures.length) + throw new AggregateError(failures, "Deployment acceptance failed"); +} finally { + for (const container of containers) + docker(["rm", "-f", container], true, true); + for (const volume of volumes) docker(["volume", "rm", volume], true, true); + if (networkCreated) docker(["network", "rm", id], true, true); + for (const image of images) docker(["image", "rm", image], true, true); + const secretRelative = relative(resolve(tmpdir()), resolve(secrets)); + if ( + secretRelative.startsWith("..") || + !basename(secrets).startsWith(`${id}-`) + ) + throw new Error("Refusing to remove an unexpected secrets directory"); + rmSync(secrets, { recursive: true, force: true }); +} 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 c0f96596..7367c920 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -4944,6 +4944,7 @@ dependencies = [ "md-5 0.10.6", "notify", "nucleo-matcher", + "nyaterm-core", "nyaterm-gpu", "nyaterm-mcp-protocol", "nyaterm-otp", @@ -5002,6 +5003,56 @@ dependencies = [ "zmodem2", ] +[[package]] +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", + "futures-util", + "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", + "time", + "tokio", + "tokio-socks", + "tokio-util", + "tracing", + "urlencoding", + "uuid", + "vnc-rs", + "zeroize", + "zip 8.6.0", +] + [[package]] name = "nyaterm-gpu" version = "0.1.0" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 08c4d249..32ba959d 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -82,15 +82,17 @@ crate-type = ["staticlib", "cdylib", "rlib"] tauri-build = { version = "2", features = [] } [dev-dependencies] +nyaterm-core = { path = "crates/nyaterm-core", features = ["test-support"] } tokio = { version = "1", features = ["test-util"] } [dependencies] +nyaterm-core = { path = "crates/nyaterm-core", features = ["desktop-importer"] } tempfile = "3" nyaterm-gpu = { path = "crates/nyaterm-gpu" } nyaterm-otp = { path = "crates/otp" } nyaterm-mcp-protocol = { path = "crates/nyaterm-mcp-protocol" } nyaterm-plugin-runtime = { path = "crates/nyaterm-plugin-runtime" } -tauri = { version = "2", features = ["tray-icon", "protocol-asset"] } +tauri = { version = "2", features = ["tray-icon", "protocol-asset", "macos-private-api"] } tauri-plugin-opener = "2" serde = { version = "1", features = ["derive"] } serde_json = { version = "1", features = ["raw_value"] } diff --git a/src-tauri/crates/nyaterm-core/Cargo.lock b/src-tauri/crates/nyaterm-core/Cargo.lock new file mode 100644 index 00000000..d11bf03b --- /dev/null +++ b/src-tauri/crates/nyaterm-core/Cargo.lock @@ -0,0 +1,5715 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common 0.1.7", + "generic-array 0.14.7", +] + +[[package]] +name = "aead" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1973cfbc1a2daf9cf550e74e1f088c28e7f7d8c1e1418fb6c9dc5184b7e84c99" +dependencies = [ + "crypto-common 0.2.2", + "inout 0.2.2", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher 0.4.4", + "cpufeatures 0.2.17", +] + +[[package]] +name = "aes" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35f0f96ce78e38c3dc6d8948aa8163d06385be74000f3c7a95bf1eef35d3ea32" +dependencies = [ + "cipher 0.5.2", + "cpubits", + "cpufeatures 0.3.1", + "zeroize", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead 0.5.2", + "aes 0.8.4", + "cipher 0.4.4", + "ctr 0.9.2", + "ghash 0.5.1", + "subtle", +] + +[[package]] +name = "aes-gcm" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f2b8006a0c83f52b62ba44a97b58bf76fe2f70a329e588f67f89691d93d498f" +dependencies = [ + "aead 0.6.1", + "aes 0.9.3", + "cipher 0.5.2", + "ctr 0.10.1", + "ctutils", + "ghash 0.6.0", + "zeroize", +] + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + +[[package]] +name = "argon2" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "134c52ddac6d63c576bef8168db10c83c49c26444ecbc68060fef078925a901c" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures 0.3.1", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee19bd99b43e3691acbad4e840420a4881cea6c0b66a208125a824f8fd53f5a1" +dependencies = [ + "compression-codecs", + "compression-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "async-http-proxy" +version = "1.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29faa5d4d308266048bd7505ba55484315a890102f9345b9ff4b87de64201592" +dependencies = [ + "base64 0.13.1", + "httparse", + "thiserror 1.0.69", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d7b9decdf35d8908a7e3ef02f64c5e9b1695e230154c0e8de3969142d9b94c" +dependencies = [ + "futures", + "rustc_version", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + +[[package]] +name = "base16ct" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd307490d624467aa6f74b0eabb77633d1f758a7b25f12bceb0b22e08d9726f6" + +[[package]] +name = "base64" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bcrypt-pbkdf" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "144e573728da132683b9488acd528274c790e07fc06ff81ee29f9d8f8b1041e0" +dependencies = [ + "blowfish", + "pbkdf2 0.13.0", + "sha2 0.11.0", +] + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" +dependencies = [ + "serde_core", +] + +[[package]] +name = "blake2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b5d4d889834ee8ecfc0f8426ad30faf7cdcb10f741a8e6d7224d95325479f6f" +dependencies = [ + "digest 0.11.3", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array 0.14.7", +] + +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "710f1dd022ef4e93f8a438b4ba958de7f64308434fa6a87104481645cc30068b" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62ce3946557b35e71d1bbe07ec385073ce9eda05043f95de134eb578fcf1a298" +dependencies = [ + "byteorder", + "cipher 0.5.2", +] + +[[package]] +name = "bs58" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "bzip2" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3a53fac24f34a81bc9954b5d6cfce0c21e18ec6959f44f56e8e90e4bb7c346c" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce2dc9ee5f88d11e0beb842c88b33c8a5cf0d1329c4b19494af42b07dbfe8896" +dependencies = [ + "cipher 0.5.2", +] + +[[package]] +name = "cc" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f74872d07caf508b30a21f6836e7d7016a2eaf7d9ff4f48deaa58cd8a0407630" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cipher 0.5.2", + "cpufeatures 0.3.1", + "rand_core 0.10.1", + "zeroize", +] + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common 0.1.7", + "inout 0.1.4", + "zeroize", +] + +[[package]] +name = "cipher" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c" +dependencies = [ + "block-buffer 0.12.1", + "crypto-common 0.2.2", + "inout 0.2.2", + "zeroize", +] + +[[package]] +name = "cmac" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8543454e3c3f5126effff9cd44d562af4e31fb8ce1cc0d3dcd8f084515dbc1aa" +dependencies = [ + "cipher 0.4.4", + "dbl", + "digest 0.10.7", +] + +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + +[[package]] +name = "compression-codecs" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98fc98460ba0ad5317075d3632b8dfc45d0be8c4a49347c2a38272019717614a" +dependencies = [ + "compression-core", + "flate2", + "memchr", +] + +[[package]] +name = "compression-core" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + +[[package]] +name = "constant_time_eq" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" + +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core-graphics" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081" +dependencies = [ + "bitflags 1.3.2", + "core-foundation 0.9.4", + "core-graphics-types", + "foreign-types 0.5.0", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf" +dependencies = [ + "bitflags 1.3.2", + "core-foundation 0.9.4", + "libc", +] + +[[package]] +name = "core-text" +version = "20.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9d2790b5c08465d49f8dc05c8bcae9fea467855947db39b0f8145c091aaced5" +dependencies = [ + "core-foundation 0.9.4", + "core-graphics", + "foreign-types 0.5.0", + "libc", +] + +[[package]] +name = "core_detect" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48" + +[[package]] +name = "cpubits" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" + +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array 0.14.7", + "rand_core 0.6.4", + "subtle", + "zeroize", +] + +[[package]] +name = "crypto-bigint" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a52aa3fcda4e6302a9f48734f234d35d4721b96f8fe07d073f07ce9df4f0271" +dependencies = [ + "cpubits", + "ctutils", + "getrandom 0.4.3", + "hybrid-array", + "num-traits", + "rand_core 0.10.1", + "serdect", + "subtle", + "zeroize", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array 0.14.7", + "rand_core 0.6.4", + "typenum", +] + +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "getrandom 0.4.3", + "hybrid-array", + "rand_core 0.10.1", +] + +[[package]] +name = "crypto-primes" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3633a51a39c69ebbaa4feaa694bd83d241e4093901c84a0963b19d9bb3f0cf8f" +dependencies = [ + "crypto-bigint 0.7.5", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher 0.4.4", +] + +[[package]] +name = "ctr" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baaca1c4b237092596f64d571e9db6ce4109c4ef9742e27590f1709594461f21" +dependencies = [ + "cipher 0.5.2", +] + +[[package]] +name = "ctutils" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov", + "subtle", +] + +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "curve25519-dalek-derive", + "digest 0.10.7", + "fiat-crypto 0.2.9", + "rustc_version", + "subtle", +] + +[[package]] +name = "curve25519-dalek" +version = "5.0.0-rc.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c906a87e53a36ff795d72e06e8162a83c5436e3ea89e942a9cb9fc083f0a384f" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "curve25519-dalek-derive", + "digest 0.11.3", + "fiat-crypto 0.3.0", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "darling" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed17f5901b6630b993ca003def43f2f8ef4014fc13b047b57aad617ff32bc2ec" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6837e2cf7485aaae18f86181d2f0e9a7ed297a025e220aeabf63fdebd3a2ddff" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 3.0.6", +] + +[[package]] +name = "darling_macro" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785" +dependencies = [ + "darling_core", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "dashmap" +version = "6.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c" +dependencies = [ + "cfg-if", + "crossbeam-utils", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core", +] + +[[package]] +name = "data-encoding" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" + +[[package]] +name = "dbl" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd2735a791158376708f9347fe8faba9667589d82427ef3aed6794a8981de3d9" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac6b926516df9c60bfa16e107b21086399f8285a44ca9711344b9e553c5146e2" + +[[package]] +name = "defmt" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1" +dependencies = [ + "bitflags 1.3.2", + "defmt-macros", +] + +[[package]] +name = "defmt-macros" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8" +dependencies = [ + "defmt-parser", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "defmt-parser" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +dependencies = [ + "thiserror 2.0.21", +] + +[[package]] +name = "delegate" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "780eb241654bf097afb00fc5f054a09b687dad862e485fdcf8399bb056565370" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid 0.9.6", + "pem-rfc7468 0.7.0", + "zeroize", +] + +[[package]] +name = "der" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a878c850e9e421b20262e9b41f9c860e4785fa07541c266b62ff9d1ef998a80a" +dependencies = [ + "const-oid 0.10.2", + "pem-rfc7468 1.0.0", + "zeroize", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "serde_core", +] + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.119", + "unicode-xid", +] + +[[package]] +name = "des" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "916a94e407b54f9034d71dd748234cd1e516ced6284009906ae246f177eafe5a" +dependencies = [ + "cipher 0.5.2", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "const-oid 0.9.6", + "crypto-common 0.1.7", + "subtle", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "const-oid 0.10.2", + "crypto-common 0.2.2", + "ctutils", + "zeroize", +] + +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.61.2", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "dlib" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a" +dependencies = [ + "libloading", +] + +[[package]] +name = "dsa" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c8573b60d07b5b5cecadc1f978c9b71d5504151e703cbb0439ee4ad301338f3" +dependencies = [ + "crypto-bigint 0.7.5", + "crypto-common 0.2.2", + "crypto-primes", + "der 0.8.2", + "digest 0.11.3", + "rfc6979 0.6.0", + "sha2 0.11.0", + "signature 3.0.0", + "zeroize", +] + +[[package]] +name = "dwrote" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1b35532432acc8b19ceed096e35dfa088d3ea037fe4f3c085f1f97f33b4d02" +dependencies = [ + "lazy_static", + "libc", + "winapi", + "wio", +] + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "eax" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9954fabd903b82b9d7a68f65f97dc96dd9ad368e40ccc907a7c19d53e6bfac28" +dependencies = [ + "aead 0.5.2", + "cipher 0.4.4", + "cmac", + "ctr 0.9.2", + "subtle", +] + +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der 0.7.10", + "digest 0.10.7", + "elliptic-curve 0.13.8", + "rfc6979 0.4.0", + "signature 2.2.0", + "spki 0.7.3", +] + +[[package]] +name = "ecdsa" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0681a4fc24c767085329728d8dfba959af91228aa4610cca4f8ce317ba46ae0" +dependencies = [ + "der 0.8.2", + "digest 0.11.3", + "elliptic-curve 0.14.1", + "rfc6979 0.6.0", + "signature 3.0.0", + "spki 0.8.0", + "zeroize", +] + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "signature 2.2.0", +] + +[[package]] +name = "ed25519" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29fcf32e6c73d1079f83ab4d782de2d81620346a5f38c6237a86a22f8368980a" +dependencies = [ + "pkcs8 0.11.0", + "signature 3.0.0", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek 4.1.3", + "ed25519 2.2.3", + "sha2 0.10.9", + "subtle", +] + +[[package]] +name = "ed25519-dalek" +version = "3.0.0-rc.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1685663e23882cd8517dcbcb1c23a6ebff4433c22dfb681d760219b62cd1b849" +dependencies = [ + "curve25519-dalek 5.0.0-rc.1", + "ed25519 3.0.0", + "rand_core 0.10.1", + "serde", + "sha2 0.11.0", + "signature 3.0.0", + "subtle", + "zeroize", +] + +[[package]] +name = "either" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" + +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct 0.2.0", + "crypto-bigint 0.5.5", + "digest 0.10.7", + "ff 0.13.1", + "generic-array 0.14.7", + "group 0.13.0", + "pkcs8 0.10.2", + "rand_core 0.6.4", + "sec1 0.7.3", + "subtle", + "zeroize", +] + +[[package]] +name = "elliptic-curve" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d65aa39b3a5c1c9c1b745c9a019234bb7a21b77abcb4f4d266d706e2d577d65" +dependencies = [ + "base16ct 1.0.0", + "crypto-bigint 0.7.5", + "crypto-common 0.2.2", + "digest 0.11.3", + "ff 0.14.0", + "group 0.14.0", + "hkdf 0.13.0", + "hybrid-array", + "pem-rfc7468 1.0.0", + "pkcs8 0.11.0", + "rand_core 0.10.1", + "sec1 0.8.1", + "subtle", + "zeroize", +] + +[[package]] +name = "encoding_rs" +version = "0.8.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e985e0451871ad22fb8d2b6b076e2028a502a0d3950998c2c5c0a4f9b5d9679" +dependencies = [ + "cfg-if", + "core_detect", + "multiversion_no_op", + "rustversion", + "scopeguard", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa18ce2bc66555b3218614519ac839ddb759a7d6720732f979ef8d13be147ecd" +dependencies = [ + "once_cell", + "proc-macro2", + "quote", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74fef4569247a5f429d9156b9d0a2599914385dd189c539334c625d8099d90ab" +dependencies = [ + "futures-core", + "nom", + "pin-project-lite", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "ff" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1f686ab92a9fb0eaf188f6c6c87b89490baa6fdb0db4544ba4dc47f7942489f" +dependencies = [ + "rand_core 0.10.1", + "subtle", +] + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + +[[package]] +name = "fiat-crypto" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64cd1e32ddd350061ae6edb1b082d7c54915b5c672c389143b9a63403a109f24" + +[[package]] +name = "find-msvc-tools" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" + +[[package]] +name = "flate2" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +dependencies = [ + "crc32fast", + "miniz_oxide", + "zlib-rs", +] + +[[package]] +name = "float-ord" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ce81f49ae8a0482e4c55ea62ebbd7e5a686af544c00b9d090bba3ff9be97b3d" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "font-kit" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c7e611d49285d4c4b2e1727b72cf05353558885cc5252f93707b845dfcaf3d3" +dependencies = [ + "bitflags 2.13.2", + "byteorder", + "core-foundation 0.9.4", + "core-graphics", + "core-text", + "dirs", + "dwrote", + "float-ord", + "freetype-sys", + "lazy_static", + "libc", + "log", + "pathfinder_geometry", + "pathfinder_simd", + "walkdir", + "winapi", + "yeslogic-fontconfig-sys", +] + +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared 0.1.1", +] + +[[package]] +name = "foreign-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +dependencies = [ + "foreign-types-macros", + "foreign-types-shared 0.3.1", +] + +[[package]] +name = "foreign-types-macros" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea5190182e6915eb873ddbc16e23b711b6eb1f9c00a0d0a3a91b5f6228475225" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "freetype-sys" +version = "0.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e7edc5b9669349acfda99533e9e0bcf26a51862ab43b08ee7745c55d28eb134" +dependencies = [ + "cc", + "libc", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-executor" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "genai" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d12aba7e9dc2c4d54654566dc3dc8383b5cb52e0cfc5754989afe0480d933e3" +dependencies = [ + "base64 0.22.1", + "bytes", + "derive_more", + "eventsource-stream", + "futures", + "mime_guess", + "paste", + "regex", + "reqwest", + "serde", + "serde_json", + "serde_with", + "strum", + "tokio", + "tokio-stream", + "tracing", + "uuid", + "value-ext", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", + "zeroize", +] + +[[package]] +name = "generic-array" +version = "1.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "337d46834ee672ab3e48caca2cb0c78cc174fb12b3a68d0d88f99a0519a5e36e" +dependencies = [ + "generic-array 0.14.7", + "rustversion", + "typenum", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi", + "rand_core 0.10.1", + "wasm-bindgen", +] + +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval 0.6.2", +] + +[[package]] +name = "ghash" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2eecf2d5dc9b66b732b97707a0210906b1d30523eb773193ab777c0c84b3e8d5" +dependencies = [ + "polyval 0.7.3", + "zeroize", +] + +[[package]] +name = "gloo-timers" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "482ce8a491a501da4cd806bd190275363d674f2845005c6ddbd5d3e1dd54495d" +dependencies = [ + "futures-channel", + "futures-core", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff 0.13.1", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "group" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fd1a1c7a5206c5b7a3f5a0d7ccd3ff85d0c8f5133d62a02680255b0004af5f4" +dependencies = [ + "ff 0.14.0", + "rand_core 0.10.1", + "subtle", +] + +[[package]] +name = "h2" +version = "0.4.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef8e5e5a340588f4452631496976cf8636d4a7ecf600239fdc27615d2530bc16" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap 2.14.2", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hex-literal" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4aaa26c720c68b866f2c96ef5c1264b3e6f473fe5d4ce61cd44bbe913e553018" +dependencies = [ + "hmac 0.13.0", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest 0.10.7", +] + +[[package]] +name = "hmac" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" +dependencies = [ + "digest 0.11.3", +] + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hybrid-array" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17" +dependencies = [ + "ctutils", + "subtle", + "typenum", + "zeroize", +] + +[[package]] +name = "hyper" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b501faa50e7a26c3d3560ca625132f4078a17771f4810baf70475ae48cbe43" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfa8e654703247911e29c23fbeaa261834bd9bb74efba2f9acddc37bfb127f53" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-tls" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" +dependencies = [ + "bytes", + "http-body-util", + "hyper", + "hyper-util", + "native-tls", + "tokio", + "tokio-native-tls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff" +dependencies = [ + "base64 0.23.1", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "httparse", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "system-configuration", + "tokio", + "tower-service", + "tracing", + "windows-registry", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "moxcms", + "num-traits", + "zune-core", + "zune-jpeg", +] + +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "inout" +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", +] + +[[package]] +name = "inout" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7" +dependencies = [ + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae8e22120c32fb4d19ec55fba35015f57095cd95a2e3b732e44457f5915b2ee8" +dependencies = [ + "num-integer", + "num-traits", + "rand 0.10.3", + "rand_core 0.10.1", +] + +[[package]] +name = "ipnet" +version = "2.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jiff" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ab1baf72f08796de0260609515130699b890ac25f30e610ad894bc5856cafdb" +dependencies = [ + "defmt", + "jiff-core", + "jiff-static", + "jiff-tzdb-platform", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", + "windows-link", +] + +[[package]] +name = "jiff-core" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e52fe76043ccecc9005d2305ebaadf7d7fc0cc89ca6baa10a94d6bc68c7128c" +dependencies = [ + "defmt", + "log", +] + +[[package]] +name = "jiff-static" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "378268a1116ad67ae6228701118ac9f491d78fda38a40a1f1a9e1348de6f7212" +dependencies = [ + "jiff-core", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jiff-tzdb" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "142bd39932ad231f10513df9ab62661fead8719872150b7ad02a2df79f4e141e" + +[[package]] +name = "jiff-tzdb-platform" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8" +dependencies = [ + "jiff-tzdb", +] + +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.3", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e" +dependencies = [ + "cfg-if", + "futures-util", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8f198d1db720e4940b5a493201d199d9f24f568f8f746bd13706243a2f71598" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", +] + +[[package]] +name = "kem" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01737161ba802849cfd486b5bd209d38ba4943494c249a8126005170c7621edd" +dependencies = [ + "crypto-common 0.2.2", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb" +dependencies = [ + "spin", +] + +[[package]] +name = "libbz2-rs-sys" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34b357333733e8260735ba5894eb928c02ecc69c78715f01a8019e7fa7f2db4c" + +[[package]] +name = "libc" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libredox" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61ff90caf6077a803a240f62fdbe88645a890bbca49ef8174c3cb0404362171d" +dependencies = [ + "libc", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "lzma-rust2" +version = "0.16.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca93e534d1142d1d0dcca6d25fe302508a5dfb40b302802904577725ea0b695b" +dependencies = [ + "sha2 0.11.0", +] + +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest 0.10.7", +] + +[[package]] +name = "md5" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ebb8d8732c6a6df3d8f032a82911cfc747e00efb95cc46e8d0acd5b5b88570c" + +[[package]] +name = "memchr" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "ml-kem" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e15f3e5b957493873e396a66914e83e616b6afe335cdef7efe5c6e1216aba66" +dependencies = [ + "hybrid-array", + "kem", + "module-lattice", + "pkcs8 0.11.0", + "rand_core 0.10.1", + "sha3 0.11.0", +] + +[[package]] +name = "module-lattice" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c61b87c9683ab7cb1c6871d261ad5479b6b10ceb52c4352aaca3b5d35a8febe" +dependencies = [ + "ctutils", + "hybrid-array", + "num-traits", +] + +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + +[[package]] +name = "multiversion_no_op" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d" + +[[package]] +name = "native-tls" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" +dependencies = [ + "bitflags 2.13.2", + "cfg-if", + "cfg_aliases", + "libc", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "nucleo-matcher" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf33f538733d1a5a3494b836ba913207f14d9d4a1d3cd67030c5061bdd2cac85" +dependencies = [ + "memchr", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-bigint-dig" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +dependencies = [ + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand 0.8.8", + "smallvec", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" +dependencies = [ + "num-integer", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +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", + "futures-util", + "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", + "thiserror 2.0.21", + "time", + "tokio", + "tokio-socks", + "tokio-util", + "tracing", + "urlencoding", + "uuid", + "vnc-rs", + "zeroize", + "zip", +] + +[[package]] +name = "nyaterm-gpu" +version = "0.1.0" +dependencies = [ + "serde", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + +[[package]] +name = "openssl" +version = "0.10.81" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45" +dependencies = [ + "bitflags 2.13.2", + "cfg-if", + "foreign-types 0.3.2", + "libc", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "openssl-sys" +version = "0.9.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "option-ext" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "ecdsa 0.16.9", + "elliptic-curve 0.13.8", + "primeorder 0.13.6", + "sha2 0.10.9", +] + +[[package]] +name = "p256" +version = "0.14.0-rc.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6bb40a5099e2c38a09dd29321a7a7f045f165a54317679c7cdfb0cbaf8f6b1e" +dependencies = [ + "ecdsa 0.17.0", + "elliptic-curve 0.14.1", + "primefield", + "primeorder 0.14.0", + "sha2 0.11.0", +] + +[[package]] +name = "p384" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6" +dependencies = [ + "ecdsa 0.16.9", + "elliptic-curve 0.13.8", + "primeorder 0.13.6", + "sha2 0.10.9", +] + +[[package]] +name = "p384" +version = "0.14.0-rc.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "492f329d7eb11d22dadc988626b9ea1f503b4ab043a8b1e4e2cc4ae45dabdd70" +dependencies = [ + "ecdsa 0.17.0", + "elliptic-curve 0.14.1", + "fiat-crypto 0.3.0", + "primefield", + "primeorder 0.14.0", + "sha2 0.11.0", +] + +[[package]] +name = "p521" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fc9e2161f1f215afdfce23677034ae137bbd45016a880c2eb3ba8eb95f085b2" +dependencies = [ + "base16ct 0.2.0", + "ecdsa 0.16.9", + "elliptic-curve 0.13.8", + "primeorder 0.13.6", + "rand_core 0.6.4", + "sha2 0.10.9", +] + +[[package]] +name = "p521" +version = "0.14.0-rc.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff42e4ace5424e3b6d7cb82514be89866b85af87015f80341e37dcc21a66ce6e" +dependencies = [ + "base16ct 1.0.0", + "ecdsa 0.17.0", + "elliptic-curve 0.14.1", + "primefield", + "primeorder 0.14.0", + "sha2 0.11.0", +] + +[[package]] +name = "pageant" +version = "0.2.1" +dependencies = [ + "base16ct 1.0.0", + "byteorder", + "bytes", + "delegate", + "futures", + "log", + "rand 0.10.3", + "sha2 0.11.0", + "thiserror 2.0.21", + "tokio", + "windows", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "password-hash" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aab41826031698d6ffcd9cff78ef56ef998e39dc7e5067cdfebe373842d4723b" +dependencies = [ + "phc", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pathfinder_geometry" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b7e7b4ea703700ce73ebf128e1450eb69c3a8329199ffbfb9b2a0418e5ad3" +dependencies = [ + "log", + "pathfinder_simd", +] + +[[package]] +name = "pathfinder_simd" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4500030c302e4af1d423f36f3b958d1aecb6c04184356ed5a833bf6b60435777" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112d82ceb8c5bf524d9af484d4e4970c9fd5a0cc15ba14ad93dccd28873b0629" +dependencies = [ + "digest 0.11.3", + "hmac 0.13.0", +] + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + +[[package]] +name = "pem-rfc7468" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6305423e0e7738146434843d1694d621cce767262b2a86910beab705e4493d9" +dependencies = [ + "base64ct", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "phc" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44dc769b75f93afdddd8c7fa12d685292ddeff1e66f7f0f3a234cf1818afe892" +dependencies = [ + "base64ct", + "ctutils", +] + +[[package]] +name = "pin-project-lite" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der 0.7.10", + "pkcs8 0.10.2", + "spki 0.7.3", +] + +[[package]] +name = "pkcs1" +version = "0.8.0-rc.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "986d2e952779af96ea048f160fd9194e1751b4faea78bcf3ceb456efe008088e" +dependencies = [ + "der 0.8.2", + "spki 0.8.0", +] + +[[package]] +name = "pkcs5" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63d440a804ec8d6fafbb6b84471e013286658d373248927692ab3366686220ca" +dependencies = [ + "aes 0.9.3", + "aes-gcm 0.11.1", + "cbc 0.2.1", + "der 0.8.2", + "pbkdf2 0.13.0", + "rand_core 0.10.1", + "scrypt", + "sha2 0.11.0", + "spki 0.8.0", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der 0.7.10", + "spki 0.7.3", +] + +[[package]] +name = "pkcs8" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "451913da69c775a56034ea8d9003d27ee8948e12443eae7c038ba100a4f21cb7" +dependencies = [ + "der 0.8.2", + "pkcs5", + "rand_core 0.10.1", + "spki 0.8.0", +] + +[[package]] +name = "pkg-config" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2d0073b297041425c7c3df6eb4792d598a15323fe63346852b092eca02904c" +dependencies = [ + "cpufeatures 0.3.1", + "universal-hash 0.6.1", + "zeroize", +] + +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "opaque-debug", + "universal-hash 0.5.1", +] + +[[package]] +name = "polyval" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0fa31d631f2b2cb2a544d0aa321ce847a94764d701ca2becc411138b93d49cd" +dependencies = [ + "cpubits", + "cpufeatures 0.3.1", + "universal-hash 0.6.1", + "zeroize", +] + +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + +[[package]] +name = "portable-atomic-util" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10ab3eb7f3becc3a1cbc4f2c6f20267996cfc1a6467a873763411b136a122715" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppmd-rust" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "196a7c80b9a7652aba7cc070827516c2abe4ccdf53d128e1944003cf5726cff1" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "primefield" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c555a6e4eb7d4e158fcb028c835c3b8642206ddc279b5c6b202ef9a8bdb592f4" +dependencies = [ + "crypto-bigint 0.7.5", + "crypto-common 0.2.2", + "ff 0.14.0", + "rand_core 0.10.1", + "subtle", + "zeroize", +] + +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve 0.13.8", +] + +[[package]] +name = "primeorder" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c9f42978c78a00e3d68f69fc03e57a234debae69da4020a4fb588fcdcd07b06" +dependencies = [ + "elliptic-curve 0.14.1", + "once_cell", + "primefield", + "serdect", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "pxfm" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" +dependencies = [ + "libc", + "rand_chacha", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "redb" +version = "4.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb338a6c67830a61bed824b78c2bb034ab1ff401a20616bd7957524f4fdc2f22" +dependencies = [ + "libc", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.13.2", +] + +[[package]] +name = "redox_users" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60dc65c0ff1a7ae1294b0c67b9f14baf70b644404010370171787bfac1038fc0" +dependencies = [ + "libredox", + "thiserror 2.0.21", +] + +[[package]] +name = "ref-cast" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e440fb4e4b4147295338efb76001ab9e4efc0e5839df2c47fc5ac2381d365c3" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "reqwest" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16a1cfa75cc186dd73d5818e510e042e40927bccc9c236b061cea97e1eb08029" +dependencies = [ + "base64 0.23.1", + "bytes", + "encoding_rs", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-tls", + "hyper-util", + "js-sys", + "log", + "mime", + "native-tls", + "percent-encoding", + "pin-project-lite", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-native-tls", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", +] + +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac 0.12.1", + "subtle", +] + +[[package]] +name = "rfc6979" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4a459cddafb3fe76b31fd8f1108007566c40301feb64dc7b54656eb7388172b" +dependencies = [ + "crypto-bigint 0.7.5", + "hmac 0.13.0", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rsa" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +dependencies = [ + "const-oid 0.9.6", + "digest 0.10.7", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1 0.7.5", + "pkcs8 0.10.2", + "rand_core 0.6.4", + "sha2 0.10.9", + "signature 2.2.0", + "spki 0.7.3", + "subtle", + "zeroize", +] + +[[package]] +name = "rsa" +version = "0.10.0-rc.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30b2aa4ba0d89f73d1e332df05be0eeab8840351c36ca5654341dfdb57bb3caf" +dependencies = [ + "const-oid 0.10.2", + "crypto-bigint 0.7.5", + "crypto-primes", + "digest 0.11.3", + "pkcs1 0.8.0-rc.4", + "pkcs8 0.11.0", + "rand_core 0.10.1", + "sha2 0.11.0", + "signature 3.0.0", + "spki 0.8.0", + "zeroize", +] + +[[package]] +name = "russh" +version = "0.62.1" +dependencies = [ + "aes 0.9.3", + "bitflags 2.13.2", + "block-padding 0.4.2", + "byteorder", + "bytes", + "cbc 0.2.1", + "cipher 0.5.2", + "crypto-bigint 0.7.5", + "ctr 0.10.1", + "curve25519-dalek 5.0.0-rc.1", + "data-encoding", + "delegate", + "der 0.8.2", + "des", + "digest 0.11.3", + "ecdsa 0.17.0", + "ed25519-dalek 3.0.0-rc.1", + "elliptic-curve 0.14.1", + "enum_dispatch", + "flate2", + "futures", + "generic-array 1.4.5", + "getrandom 0.4.3", + "ghash 0.6.0", + "hex-literal", + "hmac 0.13.0", + "inout 0.2.2", + "internal-russh-num-bigint", + "keccak 0.2.2", + "log", + "md5", + "ml-kem", + "module-lattice", + "num-bigint", + "p256 0.14.0-rc.15", + "p384 0.14.0-rc.15", + "p521 0.14.0-rc.15", + "pageant", + "pbkdf2 0.13.0", + "pkcs1 0.8.0-rc.4", + "pkcs5", + "pkcs8 0.11.0", + "polyval 0.7.3", + "rand 0.10.3", + "rand_core 0.10.1", + "ring", + "rsa 0.10.0-rc.18", + "russh-cryptovec", + "russh-util", + "salsa20 0.11.0", + "scrypt", + "sec1 0.8.1", + "sha1 0.11.0", + "sha2 0.11.0", + "sha3 0.11.0", + "signature 3.0.0", + "spki 0.8.0", + "ssh-encoding 0.3.0", + "ssh-key 0.7.0-rc.11", + "subtle", + "thiserror 2.0.21", + "tokio", + "typenum", + "universal-hash 0.6.1", + "zeroize", +] + +[[package]] +name = "russh-cryptovec" +version = "0.62.0" +dependencies = [ + "log", + "nix 0.31.3", + "ssh-encoding 0.3.0", + "windows-sys 0.61.2", +] + +[[package]] +name = "russh-sftp" +version = "2.3.0" +dependencies = [ + "bitflags 2.13.2", + "bytes", + "chrono", + "dashmap", + "encoding_rs", + "gloo-timers", + "log", + "serde", + "serde_bytes", + "thiserror 2.0.21", + "tokio", + "tokio-util", + "wasm-bindgen-futures", +] + +[[package]] +name = "russh-util" +version = "0.52.0" +dependencies = [ + "chrono", + "tokio", + "wasm-bindgen", + "wasm-bindgen-futures", +] + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" +dependencies = [ + "bitflags 2.13.2", + "errno 0.3.14", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" +dependencies = [ + "once_cell", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f874456e72520ff1375a06c588eaf074b0f01f9e9e1aada45bd9b7954a6e42c" +dependencies = [ + "cfg-if", + "cipher 0.5.2", +] + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "scrypt" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d87af57419b594aa23fa95f09f0e06d80d84ba01c26148c43844cad6ff4485f0" +dependencies = [ + "cfg-if", + "pbkdf2 0.13.0", + "salsa20 0.11.0", + "sha2 0.11.0", +] + +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct 0.2.0", + "der 0.7.10", + "generic-array 0.14.7", + "pkcs8 0.10.2", + "subtle", + "zeroize", +] + +[[package]] +name = "sec1" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56d437c2f19203ce5f7122e507831de96f3d2d4d3be5af44a0b0a09d8a80e4d" +dependencies = [ + "base16ct 1.0.0", + "ctutils", + "der 0.8.2", + "hybrid-array", + "subtle", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.13.2", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_bytes" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serde_with" +version = "3.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9adc193c780ef8f159aee8b61e2d5801aaa555e6eb0947fe45530ec506296f" +dependencies = [ + "base64 0.23.1", + "bs58", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.14.2", + "jiff", + "schemars 0.9.0", + "schemars 1.2.2", + "serde_core", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e17bbc68e28663bbbb90df47e058aa7eda4fb445b89fe70457bb94fbccf6e49" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "serdect" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66cf8fedced2fcf12406bcb34223dffb92eaf34908ede12fed414c82b7f00b3e" +dependencies = [ + "base16ct 1.0.0", + "serde", +] + +[[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha1" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "digest 0.11.3", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be176f1a57ce4e3d31c1a166222d9768de5954f811601fb7ca06fc8203905ce1" +dependencies = [ + "digest 0.11.3", + "keccak 0.2.2", +] + +[[package]] +name = "shlex" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest 0.10.7", + "rand_core 0.6.4", +] + +[[package]] +name = "signature" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d567dcbaf0049cb8ac2608a76cd95ff9e4412e1899d389ee400918ca7537f5" +dependencies = [ + "digest 0.11.3", + "rand_core 0.10.1", +] + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "spin" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der 0.7.10", +] + +[[package]] +name = "spki" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d9efca8738c78ee9484207732f728b1ef517bbb1833d6fc0879ca898a522f6f" +dependencies = [ + "base64ct", + "der 0.8.2", +] + +[[package]] +name = "ssh-cipher" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caac132742f0d33c3af65bfcde7f6aa8f62f0e991d80db99149eb9d44708784f" +dependencies = [ + "cipher 0.4.4", + "ssh-encoding 0.2.0", +] + +[[package]] +name = "ssh-cipher" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d801accda99469cde6d73da741422610fdf6508a72d9a69d1b55cb241c720597" +dependencies = [ + "aead 0.6.1", + "aes 0.9.3", + "aes-gcm 0.11.1", + "chacha20", + "cipher 0.5.2", + "ctutils", + "des", + "poly1305 0.9.1", + "ssh-encoding 0.3.0", + "zeroize", +] + +[[package]] +name = "ssh-encoding" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb9242b9ef4108a78e8cd1a2c98e193ef372437f8c22be363075233321dd4a15" +dependencies = [ + "base64ct", + "pem-rfc7468 0.7.0", + "sha2 0.10.9", +] + +[[package]] +name = "ssh-encoding" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b54d0ed0498daf3f78d82e00e28c8eec9d75a067c4cfbcc7a0f7d0f4077749e" +dependencies = [ + "base64ct", + "bytes", + "crypto-bigint 0.7.5", + "ctutils", + "digest 0.11.3", + "pem-rfc7468 1.0.0", + "zeroize", +] + +[[package]] +name = "ssh-key" +version = "0.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b86f5297f0f04d08cabaa0f6bff7cb6aec4d9c3b49d87990d63da9d9156a8c3" +dependencies = [ + "ed25519-dalek 2.2.0", + "p256 0.13.2", + "p384 0.13.1", + "p521 0.13.3", + "rand_core 0.6.4", + "rsa 0.9.10", + "sec1 0.7.3", + "sha2 0.10.9", + "signature 2.2.0", + "ssh-cipher 0.2.0", + "ssh-encoding 0.2.0", + "subtle", + "zeroize", +] + +[[package]] +name = "ssh-key" +version = "0.7.0-rc.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9a32fae177b74a22aa9c5b01bf7e68b33545be32d9e381e248058d2adc15ce3" +dependencies = [ + "argon2", + "bcrypt-pbkdf", + "ctutils", + "dsa", + "ed25519-dalek 3.0.0-rc.1", + "hex", + "hmac 0.13.0", + "p256 0.14.0-rc.15", + "p384 0.14.0-rc.15", + "p521 0.14.0-rc.15", + "rand_core 0.10.1", + "rsa 0.10.0-rc.18", + "sec1 0.8.1", + "sha1 0.11.0", + "sha2 0.11.0", + "signature 3.0.0", + "ssh-cipher 0.3.0", + "ssh-encoding 0.3.0", + "zeroize", +] + +[[package]] +name = "stable_deref_trait" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a8f8038e7e7969abb3f1b7c2a811225e9296da208539e0f79c5251d6cac0025" +dependencies = [ + "vte", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags 2.13.2", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl 2.0.21", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "js-sys", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" + +[[package]] +name = "tokio" +version = "1.53.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e95f91fcc7a621e8b030f6aa23c71fe9838ae2fb4d8118b75602a328f5144044" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9cc2678c2cdd569ef8215e2afd7954ada2ae20b4fdd2c5fe6139a3b02d105db" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-socks" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7e2948f60dbe26b35f2c7fb74ac2854c1fddded0fe9d7548fcc674a246f7615" +dependencies = [ + "either", + "futures-util", + "thiserror 1.0.69", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" +dependencies = [ + "bytes", + "futures-core", + "futures-io", + "futures-sink", + "futures-util", + "libc", + "pin-project-lite", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "async-compression", + "bitflags 2.13.2", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "pin-project-lite", + "tokio", + "tokio-util", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typed-path" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e" + +[[package]] +name = "typenum" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + +[[package]] +name = "unicode-ident" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common 0.1.7", + "subtle", +] + +[[package]] +name = "universal-hash" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4987bdc12753382e0bec4a65c50738ffaabc998b9cdd1f952fb5f39b0048a96" +dependencies = [ + "crypto-common 0.2.2", + "ctutils", +] + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97277d36b9c3ace13e58fa6e753f8b0bbbf302a18dd193240d70f9e29681059a" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "value-ext" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca945a9c7463ad3085da59b5dc4f8faf4dff6f3e7d835fa7ef08a3b36926512d" +dependencies = [ + "derive_more", + "serde", + "serde_json", +] + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "vnc-rs" +version = "0.5.3" +dependencies = [ + "aes 0.8.4", + "async_io_stream", + "eax", + "flate2", + "futures", + "image", + "rand 0.8.8", + "rsa 0.9.10", + "sha2 0.10.9", + "thiserror 1.0.69", + "tokio", + "tokio-stream", + "tokio-util", + "tracing", + "wasm-bindgen-futures", +] + +[[package]] +name = "vte" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "231fdcd7ef3037e8330d8e17e61011a2c244126acc0a982f4040ac3f9f0bc077" +dependencies = [ + "memchr", +] + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.78" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ef4c5d3d2cdf5c54f4231181768f5510842e350db025faf1f7163b1030ed928" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.6", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-streams" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "web-sys" +version = "0.3.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fbddc4a036f00ec4f18c83445bd3115cb306a91da554919a099d9222fe4a7f8" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections", + "windows-core", + "windows-future", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core", + "windows-link", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core", + "windows-link", +] + +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "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]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "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]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows_aarch64_gnullvm" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d129932f4644ac2396cb456385cbf9e63b5b30c6e8dc4820bdca4eb082037a5" +dependencies = [ + "winapi", +] + +[[package]] +name = "wnaf" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "795ca18b3fdb5e62bf982199278341ddcf7ebf7d32e25e212ad05d496e95f6fa" +dependencies = [ + "ff 0.14.0", + "group 0.14.0", + "hybrid-array", + "primefield", +] + +[[package]] +name = "writeable" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d8b8abf912b9a29ff112e1671c97c33636903d13a69712037190e6805af4f76" +dependencies = [ + "dlib", + "once_cell", + "pkg-config", +] + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6df92bf3d9227be3d53173901ddbffac2babc27ae50f397776ffd6dc33f800cb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.59" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac4f328cf2f05d084e496c3e9c3f33ed0a183656a16e1fcec4d464d8373aec82" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", + "synstructure", +] + +[[package]] +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" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "zip" +version = "8.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d04a6b5381502aa6087c94c669499eb1602eb9c5e8198e534de571f7154809b" +dependencies = [ + "aes 0.9.3", + "bzip2", + "constant_time_eq", + "crc32fast", + "deflate64", + "flate2", + "getrandom 0.4.3", + "hmac 0.13.0", + "indexmap 2.14.2", + "lzma-rust2", + "memchr", + "pbkdf2 0.13.0", + "ppmd-rust", + "sha1 0.11.0", + "time", + "typed-path", + "zeroize", + "zopfli", + "zstd", +] + +[[package]] +name = "zlib-rs" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zopfli" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] + +[[package]] +name = "zstd" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "7.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64d80649ab6db9d9f6f9c80a40becd948eda4714a0a5ac8c4d157a32231c7882" +dependencies = [ + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.1.0+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ef0a8027ec3ee71300ab3bcbcd0393f434aa72b91ca6d635a39941deae8eea0" +dependencies = [ + "cc", + "pkg-config", +] + +[[package]] +name = "zune-core" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56377fd46368984a170bc5aac5567e52ca5da874caa60bea39fcbca78fb658b" + +[[package]] +name = "zune-jpeg" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +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 new file mode 100644 index 00000000..a8269731 --- /dev/null +++ b/src-tauri/crates/nyaterm-core/Cargo.toml @@ -0,0 +1,62 @@ +[package] +name = "nyaterm-core" +version = "0.1.0" +edition = "2024" +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 = { version = "1", features = ["raw_value"] } +thiserror = "2" +redb = "4.1.0" +aes-gcm = "0.10" +base64 = "0.22" +sha2 = "0.10" +sha1 = "0.10" +hmac = "0.12" +hex = "0.4" +md-5 = "0.10" +urlencoding = "2" +rand = "0.8" +nyaterm-gpu = { path = "../nyaterm-gpu" } +zip = "8" +dirs = "6" +uuid = { version = "1", features = ["v4"] } +font-kit = "0.14.3" +nucleo-matcher = "0.3" +regex = "1" +tracing = "0.1" +ssh-key = { version = "0.6", features = ["ed25519", "std"] } +russh = { path = "../../vendor/russh/russh", default-features = false, features = ["des", "dsa", "flate2", "ring", "rsa"] } +russh-sftp = { path = "../../vendor/russh-sftp" } +tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "sync", "io-util", "time", "fs"] } +tokio-util = { version = "0.7", features = ["rt", "io"] } +futures-util = "0.3" +zeroize = "1" +time = { version = "0.3", features = ["formatting", "macros"] } +genai = { version = "0.6.5", default-features = false, features = ["native-tls"] } +reqwest = { version = "0.13", default-features = false, features = ["socks", "json", "form", "query", "native-tls"] } + +encoding_rs = "0.8" +strip-ansi-escapes = "0.2" +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/src/config/cloud_sync.rs b/src-tauri/crates/nyaterm-core/src/config/cloud_sync.rs similarity index 98% rename from src-tauri/src/config/cloud_sync.rs rename to src-tauri/crates/nyaterm-core/src/config/cloud_sync.rs index 0dcaebc5..a8b2c76f 100644 --- a/src-tauri/src/config/cloud_sync.rs +++ b/src-tauri/crates/nyaterm-core/src/config/cloud_sync.rs @@ -3,7 +3,6 @@ use crate::error::AppResult; use crate::storage::{self, SettingsDocKey}; use crate::utils::crypto; use serde::{Deserialize, Serialize}; -use tauri::AppHandle; pub const MASKED_SECRET_VALUE: &str = "__SET__"; @@ -345,12 +344,12 @@ fn default_conflict_kind() -> String { "content_conflict".to_string() } -pub fn load_cloud_sync_settings(app: &AppHandle) -> AppResult { +pub fn load_cloud_sync_settings(app: &impl Sized) -> AppResult { let _ = app; storage::load_settings_doc(SettingsDocKey::CloudSyncSettings) } -pub fn load_cloud_sync_state(app: &AppHandle) -> AppResult { +pub fn load_cloud_sync_state(app: &impl Sized) -> AppResult { let _ = app; let mut state: CloudSyncState = storage::load_settings_doc(SettingsDocKey::CloudSyncState)?; if state.device_id.is_empty() { @@ -359,7 +358,7 @@ pub fn load_cloud_sync_state(app: &AppHandle) -> AppResult { Ok(state) } -pub fn save_cloud_sync_state(app: &AppHandle, state: &CloudSyncState) -> AppResult<()> { +pub fn save_cloud_sync_state(app: &impl Sized, state: &CloudSyncState) -> AppResult<()> { let _ = app; storage::save_settings_doc(SettingsDocKey::CloudSyncState, state) } diff --git a/src-tauri/src/config/connection.rs b/src-tauri/crates/nyaterm-core/src/config/connection.rs similarity index 99% rename from src-tauri/src/config/connection.rs rename to src-tauri/crates/nyaterm-core/src/config/connection.rs index 84eb64b6..d1a348f6 100644 --- a/src-tauri/src/config/connection.rs +++ b/src-tauri/crates/nyaterm-core/src/config/connection.rs @@ -8,7 +8,6 @@ use crate::storage; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use std::collections::HashSet; -use tauri::AppHandle; // ── Connection type discriminator ─────────────────────────────────────────── @@ -1341,7 +1340,7 @@ pub fn connection_custom_icon_from_data_url( // ── Loading / saving ──────────────────────────────────────────────────────── -pub fn load_sessions(app: &AppHandle) -> AppResult { +pub fn load_sessions(app: &impl Sized) -> AppResult { let mut config = storage::load_sessions()?; let mut migrated = false; for connection in &mut config.connections { @@ -1355,7 +1354,7 @@ pub fn load_sessions(app: &AppHandle) -> AppResult { } /// Saves sessions config to disk. -pub fn save_sessions(app: &AppHandle, config: &SessionsConfig) -> AppResult<()> { +pub fn save_sessions(app: &impl Sized, config: &SessionsConfig) -> AppResult<()> { let _ = app; let mut sanitized = config.clone(); for conn in &mut sanitized.connections { @@ -1390,7 +1389,7 @@ pub fn save_sessions(app: &AppHandle, config: &SessionsConfig) -> AppResult<()> /// Loads the main app config (sessions + groups). /// Also runs one-time migrations. -pub fn load_config(app: &AppHandle) -> AppResult { +pub fn load_config(app: &impl Sized) -> AppResult { let mut cfg = load_sessions(app)?; migrate_global_proxy_to_connections(app, &mut cfg)?; @@ -1401,7 +1400,10 @@ pub fn load_config(app: &AppHandle) -> AppResult { // ── Config migrations ─────────────────────────────────────────────────────── -fn migrate_global_proxy_to_connections(app: &AppHandle, cfg: &mut SessionsConfig) -> AppResult<()> { +fn migrate_global_proxy_to_connections( + app: &impl Sized, + cfg: &mut SessionsConfig, +) -> AppResult<()> { let mut settings = load_app_settings(app)?; if !settings.proxy.enabled || cfg.connections.is_empty() { return Ok(()); @@ -1446,7 +1448,7 @@ fn migrate_global_proxy_to_connections(app: &AppHandle, cfg: &mut SessionsConfig } fn migrate_connection_proxies_to_standalone( - _app: &AppHandle, + _app: &impl Sized, _cfg: &mut SessionsConfig, ) -> AppResult<()> { // Legacy `network.proxy` inline objects are no longer present in the new format. @@ -1456,7 +1458,7 @@ fn migrate_connection_proxies_to_standalone( /// Returns the effective encoding for a connection: per-connection override if set, /// otherwise the global default from `interaction.default_encoding`. -pub fn resolve_connection_encoding(app: &AppHandle, conn: &SavedConnection) -> String { +pub fn resolve_connection_encoding(app: &impl Sized, conn: &SavedConnection) -> String { let per_conn = match &conn.config { ConnectionType::Ssh { encoding, .. } | ConnectionType::LocalTerminal { encoding, .. } @@ -1475,7 +1477,7 @@ pub fn resolve_connection_encoding(app: &AppHandle, conn: &SavedConnection) -> S /// Loads a single connection by ID. /// /// Returns `AppError::SessionNotFound` if no connection with that ID exists. -pub fn load_connection_by_id(app: &AppHandle, id: &str) -> AppResult { +pub fn load_connection_by_id(app: &impl Sized, id: &str) -> AppResult { let mut conn = storage::get_connection(id)? .ok_or_else(|| AppError::SessionNotFound(format!("Connection '{}' not found", id)))?; let mut migrated = migrate_legacy_ssh_agent_settings(&mut conn); @@ -1491,7 +1493,7 @@ pub fn load_connection_by_id(app: &AppHandle, id: &str) -> AppResult AppResult<()> { +pub fn save_config(app: &impl Sized, config: &AppConfig) -> AppResult<()> { save_sessions(app, config) } diff --git a/src-tauri/src/config/credential.rs b/src-tauri/crates/nyaterm-core/src/config/credential.rs similarity index 97% rename from src-tauri/src/config/credential.rs rename to src-tauri/crates/nyaterm-core/src/config/credential.rs index 6d02bb7d..7c346aef 100644 --- a/src-tauri/src/config/credential.rs +++ b/src-tauri/crates/nyaterm-core/src/config/credential.rs @@ -3,7 +3,6 @@ use crate::error::{AppError, AppResult}; use crate::storage; use crate::utils::crypto; use serde::{Deserialize, Serialize}; -use tauri::AppHandle; fn default_enabled() -> bool { true @@ -38,7 +37,7 @@ pub struct CredentialsConfig { pub credentials: Vec, } -pub fn load_credentials(app: &AppHandle) -> AppResult { +pub fn load_credentials(app: &impl Sized) -> AppResult { let _ = app; let mut config = CredentialsConfig { credentials: storage::list_credentials()?, @@ -50,12 +49,12 @@ pub fn load_credentials(app: &AppHandle) -> AppResult { Ok(config) } -pub fn save_credentials(app: &AppHandle, config: &CredentialsConfig) -> AppResult<()> { +pub fn save_credentials(app: &impl Sized, config: &CredentialsConfig) -> AppResult<()> { let _ = app; storage::replace_credentials(config) } -pub fn load_credential_by_id(app: &AppHandle, id: &str) -> AppResult { +pub fn load_credential_by_id(app: &impl Sized, id: &str) -> AppResult { let cfg = load_credentials(app)?; let mut entry = cfg .credentials diff --git a/src-tauri/src/config/key.rs b/src-tauri/crates/nyaterm-core/src/config/key.rs similarity index 93% rename from src-tauri/src/config/key.rs rename to src-tauri/crates/nyaterm-core/src/config/key.rs index e2acadaf..957d10c9 100644 --- a/src-tauri/src/config/key.rs +++ b/src-tauri/crates/nyaterm-core/src/config/key.rs @@ -5,7 +5,6 @@ use crate::utils::crypto; use serde::{Deserialize, Serialize}; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{RwLock, RwLockReadGuard}; -use tauri::AppHandle; static SSH_KEY_CHANGE_EPOCH: AtomicU64 = AtomicU64::new(0); static SSH_KEY_ACCESS: RwLock<()> = RwLock::new(()); @@ -57,7 +56,7 @@ fn apply_key_status_flags(key: &mut SshKey) { key.has_cert_data = key.cert.is_some(); } -pub fn load_keys(app: &AppHandle) -> AppResult { +pub fn load_keys(app: &impl Sized) -> AppResult { let _ = app; let mut config = KeysConfig { keys: storage::list_ssh_keys()?, @@ -68,7 +67,7 @@ pub fn load_keys(app: &AppHandle) -> AppResult { Ok(config) } -pub fn save_keys(app: &AppHandle, config: &KeysConfig) -> AppResult<()> { +pub fn save_keys(app: &impl Sized, config: &KeysConfig) -> AppResult<()> { let _ = app; let _write_guard = SSH_KEY_ACCESS .write() @@ -83,7 +82,7 @@ pub fn save_keys(app: &AppHandle, config: &KeysConfig) -> AppResult<()> { /// Forwarding brokers may cache parsed signing identities for one epoch. Signing must still /// acquire [`ssh_key_read_guard`] and verify the epoch before using cached private /// material, so a successful save or deletion invalidates every stale signer. -pub(crate) fn ssh_key_change_epoch() -> u64 { +pub fn ssh_key_change_epoch() -> u64 { SSH_KEY_CHANGE_EPOCH.load(Ordering::SeqCst) } @@ -92,13 +91,13 @@ pub(crate) fn ssh_key_change_epoch() -> u64 { /// Keep this guard only while reading the persistent snapshot or producing a /// signature. PEM decryption and parsing deliberately happen after snapshot reads /// release the guard, preventing slow key formats from blocking saves. -pub(crate) fn ssh_key_read_guard() -> RwLockReadGuard<'static, ()> { +pub fn ssh_key_read_guard() -> RwLockReadGuard<'static, ()> { SSH_KEY_ACCESS .read() .unwrap_or_else(std::sync::PoisonError::into_inner) } -pub fn load_key_by_id(app: &AppHandle, id: &str) -> AppResult { +pub fn load_key_by_id(app: &impl Sized, id: &str) -> AppResult { let cfg = load_keys(app)?; let mut key = cfg .keys diff --git a/src-tauri/crates/nyaterm-core/src/config/mod.rs b/src-tauri/crates/nyaterm-core/src/config/mod.rs new file mode 100644 index 00000000..c26be4b0 --- /dev/null +++ b/src-tauri/crates/nyaterm-core/src/config/mod.rs @@ -0,0 +1,116 @@ +//! Config persistence for sessions, UI, and quick commands. +//! +//! Stores typed entities and small singleton documents in `~/.nyaterm/nyaterm.redb`. +//! Credentials are AES-256-GCM encrypted in-place. + +mod cloud_sync; +mod connection; +mod credential; +mod key; +mod note; +mod otp; +mod password; +mod proxy; +mod quick_command; +mod settings; +mod tunnel; +mod ui; + +#[allow(unused_imports)] +pub use cloud_sync::{ + CloudConflictPreview, CloudSyncHistoryEntry, CloudSyncSettings, CloudSyncState, + CloudSyncStatus, GiteeSnippetSyncSettings, MASKED_SECRET_VALUE, S3SyncSettings, + WebdavSyncSettings, decrypt_cloud_sync_settings, encrypt_cloud_sync_settings, + load_cloud_sync_settings, load_cloud_sync_state, mask_cloud_sync_settings, + merge_masked_cloud_sync_settings, save_cloud_sync_state, +}; +#[allow(unused_imports)] +pub use connection::{ + AiExecutionProfile, AppConfig, AssetAccelerator, AssetAcceleratorType, AssetDeviceType, + AssetDisk, AssetDiskKind, AssetDiskPurpose, AssetMetadata, ConnectionAuth, + ConnectionCustomIcon, ConnectionNetwork, ConnectionRecordingSettings, ConnectionType, Group, + MAX_SFTP_SHELL_DETECTION_TIMEOUT_MS, MAX_SSH_AGENT_FORWARDING_ENDPOINTS, + MAX_SSH_AGENT_FORWARDING_IDENTITIES, MIN_SFTP_SHELL_DETECTION_TIMEOUT_MS, RdpClipboardSettings, + RdpDisplaySettings, RdpReconnectSettings, RdpSecuritySettings, SavedConnection, + SerialFlowControl, SerialModemUploadProtocol, SessionsConfig, SftpCwdFollowMode, SftpSettings, + SshAgentEndpoint, SshAgentForwardingConfig, SshAgentForwardingPolicy, + SshAgentForwardingSources, SshAlgorithmMode, SshAlgorithmPreferences, SshProfile, + SshRuntimeMode, SshTerminalType, TelnetAutoLoginConfig, VncClipboardSettings, + VncDisplaySettings, VncReconnectSettings, VncSecuritySettings, + connection_custom_icon_from_data_url, connection_custom_icon_id_for_data_url, + effective_cwd_follow_mode, effective_cwd_follow_mode_for_profile, + effective_cwd_follow_mode_for_runtime, is_connection_custom_icon_data_url, load_config, + load_connection_by_id, load_sessions, migrate_legacy_asset_tags, + migrate_legacy_ssh_agent_settings, normalize_connection_tags, resolve_connection_encoding, + resolve_ssh_terminal_type, save_config, save_sessions, ssh_agent_endpoint_key, + validate_ssh_agent_endpoint, validate_ssh_agent_endpoint_shape, + validate_ssh_agent_forwarding_config, validate_ssh_agent_forwarding_shape, + validate_ssh_agent_settings, +}; +#[allow(unused_imports)] +pub use credential::{ + CredentialsConfig, SavedCredential, load_credential_by_id, load_credentials, + reorder_credentials, save_credentials, upsert_credential, +}; +#[allow(unused_imports)] +pub use key::{ + KeysConfig, SshKey, decrypt_key_cert, decrypt_key_pem, load_key_by_id, load_keys, save_keys, +}; +pub use key::{ssh_key_change_epoch, ssh_key_read_guard}; +#[allow(unused_imports)] +pub use note::{ + DeleteNoteNodeResult, NoteDocument, NoteFolder, NoteNodeChange, NoteSummary, NoteTreePayload, + NoteUpdateResult, NotesChangedEvent, NotesSnapshot, +}; +#[allow(unused_imports)] +pub use otp::{OtpConfig, OtpEntry, load_otp_entries, load_otp_entry_by_id, save_otp_entries}; +#[allow(unused_imports)] +pub use password::{ + PasswordsConfig, SavedPassword, decrypt_account_password, effective_account_id, + load_password_by_id, load_passwords, load_saved_account, resolve_account_username, + save_passwords, +}; +#[allow(unused_imports)] +pub use proxy::{ + ProxyConfig, ProxyGroup, ProxyGroupsConfig, load_proxies, load_proxy_by_id, load_proxy_groups, + save_proxies, save_proxy_groups, +}; +#[allow(unused_imports)] +pub use quick_command::{ + QuickCommand, QuickCommandCategory, QuickCommandsConfig, load_quick_commands, + save_quick_commands, +}; +#[allow(unused_imports)] +pub use settings::{ + AI_REQUEST_USER_AGENT_DEFAULT, ActionLinksMatcherSettings, AgentCommandExecutionMode, + AiAgentKind, AiApiFormat, AiBackendKind, AiCustomActionConfig, AiMode, AiModelConfigItem, + AiModelSource, AiPermissionMode, AiProviderApiProtocol, AiProviderCredential, AiProviderKind, + AiProviderProfile, AiProxyMode, AiProxyProtocol, AiProxySettings, AiReasoningEffort, + AiSettings, AppSettings, AppearanceSettings, ClaudeCodeIntegrationSettings, + CodexIntegrationSettings, CodexThreadMode, DiagnosticsLogLevel, DiagnosticsSettings, + ExternalMcpSessionScope, ExternalMcpSettings, GeneralSettings, InteractionSettings, + KeywordHighlightRule, ProxySettings, RecordingSettings, RiskLevel, SearchEngine, + SearchSettings, SecuritySettings, TerminalColorsConfig, TerminalSettings, ThemeColorsConfig, + ThemeConfig, TransferSettings, TranslationSettings, ai_model_id_for_credential, + ai_model_id_for_provider, decrypt_ai_settings, encrypt_ai_settings, load_app_settings, + mask_ai_settings, merge_masked_ai_settings, normalize_ai_settings, save_app_settings, +}; +#[allow(unused_imports)] +pub use tunnel::{ + TunnelConfig, TunnelGroup, TunnelGroupsConfig, TunnelsConfig, load_tunnel_groups, load_tunnels, + save_tunnel_groups, save_tunnels, +}; +#[allow(unused_imports)] +pub use ui::{ActivityBarLayout, RestorablePaneNode, RestorableTab, UiConfig}; + +pub fn uuid_v4() -> String { + uuid::Uuid::new_v4().to_string() +} + +pub fn default_true() -> bool { + true +} + +pub fn default_false() -> bool { + false +} diff --git a/src-tauri/src/config/note.rs b/src-tauri/crates/nyaterm-core/src/config/note.rs similarity index 100% rename from src-tauri/src/config/note.rs rename to src-tauri/crates/nyaterm-core/src/config/note.rs diff --git a/src-tauri/src/config/otp.rs b/src-tauri/crates/nyaterm-core/src/config/otp.rs similarity index 88% rename from src-tauri/src/config/otp.rs rename to src-tauri/crates/nyaterm-core/src/config/otp.rs index 7b8720a1..799cb472 100644 --- a/src-tauri/src/config/otp.rs +++ b/src-tauri/crates/nyaterm-core/src/config/otp.rs @@ -3,7 +3,6 @@ use crate::error::{AppError, AppResult}; use crate::storage; use crate::utils::crypto; use serde::{Deserialize, Serialize}; -use tauri::AppHandle; fn default_digits() -> u8 { 6 @@ -50,7 +49,7 @@ pub struct OtpConfig { pub entries: Vec, } -pub fn load_otp_entries(app: &AppHandle) -> AppResult { +pub fn load_otp_entries(app: &impl Sized) -> AppResult { let _ = app; let mut config = OtpConfig { entries: storage::list_otp_accounts()?, @@ -61,12 +60,12 @@ pub fn load_otp_entries(app: &AppHandle) -> AppResult { Ok(config) } -pub fn save_otp_entries(app: &AppHandle, config: &OtpConfig) -> AppResult<()> { +pub fn save_otp_entries(app: &impl Sized, config: &OtpConfig) -> AppResult<()> { let _ = app; storage::replace_otp_accounts(config) } -pub fn load_otp_entry_by_id(app: &AppHandle, id: &str) -> AppResult { +pub fn load_otp_entry_by_id(app: &impl Sized, id: &str) -> AppResult { let cfg = load_otp_entries(app)?; let mut entry = cfg .entries diff --git a/src-tauri/src/config/password.rs b/src-tauri/crates/nyaterm-core/src/config/password.rs similarity index 93% rename from src-tauri/src/config/password.rs rename to src-tauri/crates/nyaterm-core/src/config/password.rs index 194850e1..81c4501a 100644 --- a/src-tauri/src/config/password.rs +++ b/src-tauri/crates/nyaterm-core/src/config/password.rs @@ -3,7 +3,6 @@ use crate::error::{AppError, AppResult}; use crate::storage; use crate::utils::crypto; use serde::{Deserialize, Serialize}; -use tauri::AppHandle; /// Managed account entry. The password field is AES-256-GCM encrypted on disk. /// @@ -38,7 +37,7 @@ pub fn effective_account_id<'a>( /// Loads account metadata without decrypting its password. Missing references /// are treated as unavailable so callers can apply protocol-specific fallback. pub fn load_saved_account( - app: &AppHandle, + app: &impl Sized, account_id: Option<&str>, legacy_password_id: Option<&str>, ) -> AppResult> { @@ -72,7 +71,7 @@ pub struct PasswordsConfig { pub passwords: Vec, } -pub fn load_passwords(app: &AppHandle) -> AppResult { +pub fn load_passwords(app: &impl Sized) -> AppResult { let _ = app; let mut config = PasswordsConfig { passwords: storage::list_passwords()?, @@ -83,12 +82,12 @@ pub fn load_passwords(app: &AppHandle) -> AppResult { Ok(config) } -pub fn save_passwords(app: &AppHandle, config: &PasswordsConfig) -> AppResult<()> { +pub fn save_passwords(app: &impl Sized, config: &PasswordsConfig) -> AppResult<()> { let _ = app; storage::replace_passwords(config) } -pub fn load_password_by_id(app: &AppHandle, id: &str) -> AppResult { +pub fn load_password_by_id(app: &impl Sized, id: &str) -> AppResult { let cfg = load_passwords(app)?; let mut entry = cfg .passwords diff --git a/src-tauri/src/config/proxy.rs b/src-tauri/crates/nyaterm-core/src/config/proxy.rs similarity index 80% rename from src-tauri/src/config/proxy.rs rename to src-tauri/crates/nyaterm-core/src/config/proxy.rs index 4042daf9..215eeea8 100644 --- a/src-tauri/src/config/proxy.rs +++ b/src-tauri/crates/nyaterm-core/src/config/proxy.rs @@ -2,7 +2,6 @@ use super::uuid_v4; use crate::error::AppResult; use crate::storage::{self, SettingsDocKey}; use serde::{Deserialize, Serialize}; -use tauri::AppHandle; fn default_protocol() -> String { "socks5".to_string() @@ -52,27 +51,27 @@ pub struct ProxyGroupsConfig { pub groups: Vec, } -pub fn load_proxies(app: &AppHandle) -> AppResult> { +pub fn load_proxies(app: &impl Sized) -> AppResult> { let _ = app; storage::list_proxies() } -pub fn save_proxies(app: &AppHandle, proxies: &[ProxyConfig]) -> AppResult<()> { +pub fn save_proxies(app: &impl Sized, proxies: &[ProxyConfig]) -> AppResult<()> { let _ = app; storage::replace_proxies(proxies) } -pub fn load_proxy_by_id(app: &AppHandle, id: &str) -> AppResult> { +pub fn load_proxy_by_id(app: &impl Sized, id: &str) -> AppResult> { let proxies = load_proxies(app)?; Ok(proxies.into_iter().find(|p| p.id == id)) } -pub fn load_proxy_groups(app: &AppHandle) -> AppResult> { +pub fn load_proxy_groups(app: &impl Sized) -> AppResult> { let _ = app; Ok(storage::load_settings_doc::(SettingsDocKey::ProxyGroups)?.groups) } -pub fn save_proxy_groups(app: &AppHandle, groups: &[ProxyGroup]) -> AppResult<()> { +pub fn save_proxy_groups(app: &impl Sized, groups: &[ProxyGroup]) -> AppResult<()> { let _ = app; storage::save_settings_doc( SettingsDocKey::ProxyGroups, diff --git a/src-tauri/src/config/quick_command.rs b/src-tauri/crates/nyaterm-core/src/config/quick_command.rs similarity index 90% rename from src-tauri/src/config/quick_command.rs rename to src-tauri/crates/nyaterm-core/src/config/quick_command.rs index 5decbf78..d939f470 100644 --- a/src-tauri/src/config/quick_command.rs +++ b/src-tauri/crates/nyaterm-core/src/config/quick_command.rs @@ -1,7 +1,6 @@ use crate::error::AppResult; use crate::storage::{self, SettingsDocKey}; use serde::{Deserialize, Serialize}; -use tauri::AppHandle; fn default_execute() -> String { "execute".to_string() @@ -58,13 +57,13 @@ pub struct QuickCommandsConfig { } /// Loads quick commands from local app storage. -pub fn load_quick_commands(app: &AppHandle) -> AppResult { +pub fn load_quick_commands(app: &impl Sized) -> AppResult { let _ = app; storage::load_settings_doc(SettingsDocKey::QuickCommands) } /// Saves quick commands to local app storage. -pub fn save_quick_commands(app: &AppHandle, config: &QuickCommandsConfig) -> AppResult<()> { +pub fn save_quick_commands(app: &impl Sized, config: &QuickCommandsConfig) -> AppResult<()> { let _ = app; storage::save_settings_doc(SettingsDocKey::QuickCommands, config) } diff --git a/src-tauri/src/config/settings/ai.rs b/src-tauri/crates/nyaterm-core/src/config/settings/ai.rs similarity index 100% rename from src-tauri/src/config/settings/ai.rs rename to src-tauri/crates/nyaterm-core/src/config/settings/ai.rs diff --git a/src-tauri/src/config/settings/appearance.rs b/src-tauri/crates/nyaterm-core/src/config/settings/appearance.rs similarity index 100% rename from src-tauri/src/config/settings/appearance.rs rename to src-tauri/crates/nyaterm-core/src/config/settings/appearance.rs diff --git a/src-tauri/src/config/settings/diagnostics.rs b/src-tauri/crates/nyaterm-core/src/config/settings/diagnostics.rs similarity index 100% rename from src-tauri/src/config/settings/diagnostics.rs rename to src-tauri/crates/nyaterm-core/src/config/settings/diagnostics.rs diff --git a/src-tauri/src/config/settings/general.rs b/src-tauri/crates/nyaterm-core/src/config/settings/general.rs similarity index 100% rename from src-tauri/src/config/settings/general.rs rename to src-tauri/crates/nyaterm-core/src/config/settings/general.rs diff --git a/src-tauri/src/config/settings/interaction.rs b/src-tauri/crates/nyaterm-core/src/config/settings/interaction.rs similarity index 100% rename from src-tauri/src/config/settings/interaction.rs rename to src-tauri/crates/nyaterm-core/src/config/settings/interaction.rs diff --git a/src-tauri/src/config/settings/mod.rs b/src-tauri/crates/nyaterm-core/src/config/settings/mod.rs similarity index 98% rename from src-tauri/src/config/settings/mod.rs rename to src-tauri/crates/nyaterm-core/src/config/settings/mod.rs index f1e7ec37..b7ed8d9d 100644 --- a/src-tauri/src/config/settings/mod.rs +++ b/src-tauri/crates/nyaterm-core/src/config/settings/mod.rs @@ -42,7 +42,6 @@ use crate::error::{AppError, AppResult}; use crate::storage::{self, SettingsDocKey}; use serde::{Deserialize, Serialize}; use std::collections::HashMap; -use tauri::AppHandle; #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct AppSettings { @@ -79,7 +78,7 @@ pub struct AppSettings { pub keybindings: HashMap, } -pub fn load_app_settings(app: &AppHandle) -> AppResult { +pub fn load_app_settings(app: &impl Sized) -> AppResult { let raw_settings = storage::load_settings_doc::(SettingsDocKey::AppSettings)?; let mut settings: AppSettings = if raw_settings.is_null() { @@ -507,7 +506,7 @@ fn migrate_legacy_recording_settings( true } -fn persist_migrated_app_settings(app: &AppHandle, settings: &AppSettings) { +fn persist_migrated_app_settings(app: &impl Sized, settings: &AppSettings) { let mut persisted = settings.clone(); let Ok(cloud_sync) = encrypt_cloud_sync_settings(persisted.cloud_sync.clone()) else { return; @@ -521,7 +520,7 @@ fn persist_migrated_app_settings(app: &AppHandle, settings: &AppSettings) { let _ = save_app_settings(app, &persisted); } -pub fn save_app_settings(app: &AppHandle, config: &AppSettings) -> AppResult<()> { +pub fn save_app_settings(app: &impl Sized, config: &AppSettings) -> AppResult<()> { let _ = app; storage::save_settings_doc(SettingsDocKey::AppSettings, config) } diff --git a/src-tauri/src/config/settings/proxy.rs b/src-tauri/crates/nyaterm-core/src/config/settings/proxy.rs similarity index 100% rename from src-tauri/src/config/settings/proxy.rs rename to src-tauri/crates/nyaterm-core/src/config/settings/proxy.rs diff --git a/src-tauri/src/config/settings/recording.rs b/src-tauri/crates/nyaterm-core/src/config/settings/recording.rs similarity index 100% rename from src-tauri/src/config/settings/recording.rs rename to src-tauri/crates/nyaterm-core/src/config/settings/recording.rs diff --git a/src-tauri/src/config/settings/search.rs b/src-tauri/crates/nyaterm-core/src/config/settings/search.rs similarity index 100% rename from src-tauri/src/config/settings/search.rs rename to src-tauri/crates/nyaterm-core/src/config/settings/search.rs diff --git a/src-tauri/src/config/settings/security.rs b/src-tauri/crates/nyaterm-core/src/config/settings/security.rs similarity index 100% rename from src-tauri/src/config/settings/security.rs rename to src-tauri/crates/nyaterm-core/src/config/settings/security.rs diff --git a/src-tauri/src/config/settings/terminal.rs b/src-tauri/crates/nyaterm-core/src/config/settings/terminal.rs similarity index 100% rename from src-tauri/src/config/settings/terminal.rs rename to src-tauri/crates/nyaterm-core/src/config/settings/terminal.rs diff --git a/src-tauri/src/config/settings/transfer.rs b/src-tauri/crates/nyaterm-core/src/config/settings/transfer.rs similarity index 100% rename from src-tauri/src/config/settings/transfer.rs rename to src-tauri/crates/nyaterm-core/src/config/settings/transfer.rs diff --git a/src-tauri/src/config/settings/translation.rs b/src-tauri/crates/nyaterm-core/src/config/settings/translation.rs similarity index 100% rename from src-tauri/src/config/settings/translation.rs rename to src-tauri/crates/nyaterm-core/src/config/settings/translation.rs diff --git a/src-tauri/src/config/tunnel.rs b/src-tauri/crates/nyaterm-core/src/config/tunnel.rs similarity index 87% rename from src-tauri/src/config/tunnel.rs rename to src-tauri/crates/nyaterm-core/src/config/tunnel.rs index c822a64b..be7a0981 100644 --- a/src-tauri/src/config/tunnel.rs +++ b/src-tauri/crates/nyaterm-core/src/config/tunnel.rs @@ -2,7 +2,6 @@ use super::{default_false, default_true, uuid_v4}; use crate::error::AppResult; use crate::storage::{self, SettingsDocKey}; use serde::{Deserialize, Serialize}; -use tauri::AppHandle; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct TunnelConfig { @@ -73,22 +72,22 @@ pub struct TunnelGroupsConfig { pub groups: Vec, } -pub fn load_tunnels(app: &AppHandle) -> AppResult> { +pub fn load_tunnels(app: &impl Sized) -> AppResult> { let _ = app; storage::list_tunnels() } -pub fn save_tunnels(app: &AppHandle, tunnels: &[TunnelConfig]) -> AppResult<()> { +pub fn save_tunnels(app: &impl Sized, tunnels: &[TunnelConfig]) -> AppResult<()> { let _ = app; storage::replace_tunnels(tunnels) } -pub fn load_tunnel_groups(app: &AppHandle) -> AppResult> { +pub fn load_tunnel_groups(app: &impl Sized) -> AppResult> { let _ = app; Ok(storage::load_settings_doc::(SettingsDocKey::TunnelGroups)?.groups) } -pub fn save_tunnel_groups(app: &AppHandle, groups: &[TunnelGroup]) -> AppResult<()> { +pub fn save_tunnel_groups(app: &impl Sized, groups: &[TunnelGroup]) -> AppResult<()> { let _ = app; storage::save_settings_doc( SettingsDocKey::TunnelGroups, diff --git a/src-tauri/src/config/ui.rs b/src-tauri/crates/nyaterm-core/src/config/ui.rs similarity index 100% rename from src-tauri/src/config/ui.rs rename to src-tauri/crates/nyaterm-core/src/config/ui.rs diff --git a/src-tauri/crates/nyaterm-core/src/core/ai/catalog.rs b/src-tauri/crates/nyaterm-core/src/core/ai/catalog.rs new file mode 100644 index 00000000..37739284 --- /dev/null +++ b/src-tauri/crates/nyaterm-core/src/core/ai/catalog.rs @@ -0,0 +1,67 @@ +use crate::config; + +pub fn merge_model_discoveries( + settings: &config::AiSettings, + discoveries: Vec, +) -> Vec { + let old_by_id = settings + .models + .iter() + .map(|model| (model.id.as_str(), model)) + .collect::>(); + let now = time::OffsetDateTime::now_utc() + .format(&time::format_description::well_known::Rfc3339) + .unwrap_or_else(|_| String::new()); + let mut seen = std::collections::HashSet::new(); + let mut merged = Vec::new(); + + for item in discoveries { + if !seen.insert(item.id.clone()) { + continue; + } + if let Some(old) = old_by_id.get(item.id.as_str()) { + let mut model = (*old).clone(); + model.last_seen_at = Some(now.clone()); + merged.push(model); + } else { + merged.push(config::AiModelConfigItem { + id: item.id, + name: item.name, + backend: item.backend, + provider_kind: item.provider_kind, + credential_id: item.credential_id, + enabled: false, + source: item.source, + last_seen_at: Some(now.clone()), + supported_reasoning_efforts: None, + }); + } + } + + for old in &settings.models { + if !seen.contains(&old.id) { + merged.push(old.clone()); + } + } + + merged.sort_by(|left, right| left.name.cmp(&right.name)); + merged +} + +pub fn update_default_model_id( + settings: &config::AiSettings, + models: &[config::AiModelConfigItem], +) -> Option { + if let Some(default_model_id) = settings.default_model_id.as_deref() { + if models + .iter() + .any(|model| model.enabled && model.id == default_model_id) + { + return Some(default_model_id.to_string()); + } + } + models + .iter() + .find(|model| model.enabled) + .map(|model| model.id.clone()) +} diff --git a/src-tauri/crates/nyaterm-core/src/core/ai/history.rs b/src-tauri/crates/nyaterm-core/src/core/ai/history.rs new file mode 100644 index 00000000..2f66360b --- /dev/null +++ b/src-tauri/crates/nyaterm-core/src/core/ai/history.rs @@ -0,0 +1,477 @@ +use std::collections::HashSet; + +use serde::{Deserialize, Serialize}; + +use crate::config::{AiAgentKind, AiBackendKind}; +use crate::error::{AppError, AppResult}; +use crate::storage::{self, SettingsDocKey}; + +use super::types::{ + AiAuditLog, AiChatRequest, AiMessage, AiMessageRole, AiSession, AiSessionBackendMetadata, + AiSessionScope, AiSessionScopeType, AppendAiAuditRequest, now_rfc3339, uuid, +}; + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct AiHistoryFile { + #[serde(default)] + pub sessions: Vec, + #[serde(default)] + pub messages: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +struct AiAuditFile { + #[serde(default)] + logs: Vec, +} + +const AI_HISTORY_MAX_SESSIONS: usize = 200; +const AI_HISTORY_MAX_MESSAGES: usize = 2_000; +const AI_AUDIT_MAX_LOGS: usize = 2_000; + +fn normalize_session_scope(session: &mut AiSession) { + if session.agent_kind == AiAgentKind::Nyaterm + && let Some(metadata) = session.backend_metadata.as_ref() + && metadata.backend == AiBackendKind::Codex + { + session.agent_kind = AiAgentKind::Codex; + if session.external_session_id.is_none() { + session.external_session_id = metadata.external_thread_id.clone(); + } + } + + if session.scope.r#type == AiSessionScopeType::Unbound && session.scope.target_id.is_none() { + if let Some(connection_id) = session.connection_id.as_ref() { + if !connection_id.trim().is_empty() + && !session + .scope + .connection_ids + .iter() + .any(|value| value == connection_id) + { + session.scope.connection_ids.push(connection_id.clone()); + } + } + } +} + +fn normalize_history(history: &mut AiHistoryFile) { + for session in &mut history.sessions { + normalize_session_scope(session); + } +} + +pub fn load_history(_app: &impl Sized) -> AppResult { + let mut history: AiHistoryFile = storage::load_settings_doc(SettingsDocKey::AiHistory)?; + normalize_history(&mut history); + Ok(history) +} + +pub fn trim_history(history: &mut AiHistoryFile) { + history + .sessions + .sort_by(|left, right| right.updated_at.cmp(&left.updated_at)); + if history.sessions.len() > AI_HISTORY_MAX_SESSIONS { + history.sessions.truncate(AI_HISTORY_MAX_SESSIONS); + } + + let retained_sessions: HashSet<&str> = history + .sessions + .iter() + .map(|session| session.id.as_str()) + .collect(); + history + .messages + .retain(|message| retained_sessions.contains(message.session_id.as_str())); + + if history.messages.len() > AI_HISTORY_MAX_MESSAGES { + history + .messages + .sort_by(|left, right| left.created_at.cmp(&right.created_at)); + let remove_count = history.messages.len() - AI_HISTORY_MAX_MESSAGES; + history.messages.drain(0..remove_count); + } + + let sessions_with_messages: HashSet<&str> = history + .messages + .iter() + .map(|message| message.session_id.as_str()) + .collect(); + history + .sessions + .retain(|session| sessions_with_messages.contains(session.id.as_str())); +} + +pub fn save_user_message( + app: &impl Sized, + session_id: &str, + request: &AiChatRequest, +) -> AppResult<()> { + tracing::debug!( + session_id = %session_id, + connection_id = ?request.connection_id, + action = ?request.action, + "Persisting AI user message" + ); + + let now = now_rfc3339(); + let title = request + .user_input + .chars() + .take(42) + .collect::() + .trim() + .to_string(); + let connection_id = request.connection_id.clone(); + let scope = request.owner_scope.clone(); + let user_input = request.user_input.clone(); + let session_id = session_id.to_string(); + + let _ = app; + storage::update_settings_doc::(SettingsDocKey::AiHistory, |history| { + if let Some(session) = history + .sessions + .iter_mut() + .find(|item| item.id == session_id) + { + session.updated_at = now.clone(); + } else { + history.sessions.push(AiSession { + id: session_id.clone(), + agent_kind: request.agent_kind.clone(), + scope, + connection_id, + title: if title.is_empty() { + "AI Session".to_string() + } else { + title + }, + created_at: now.clone(), + updated_at: now.clone(), + external_session_id: request.existing_external_session_id.clone(), + backend_metadata: None, + }); + } + history.messages.push(AiMessage { + id: format!("msg-{}", uuid()), + session_id, + role: AiMessageRole::User, + content: user_input, + created_at: now, + reasoning_content: None, + command_cards: vec![], + }); + trim_history(history); + Ok(()) + }) +} + +pub fn validate_session_scope( + app: &impl Sized, + session_id: &str, + request: &AiChatRequest, +) -> AppResult<()> { + let history = load_history(app)?; + let Some(existing) = history.sessions.iter().find(|item| item.id == session_id) else { + return Ok(()); + }; + + if existing.agent_kind != request.agent_kind { + return Err(AppError::Config( + "AI session belongs to another agent type".to_string(), + )); + } + + if existing.scope.r#type == AiSessionScopeType::Unbound { + return Ok(()); + } + + if existing.scope.r#type != request.owner_scope.r#type + || existing.scope.target_id != request.owner_scope.target_id + { + return Err(AppError::Config( + "AI session belongs to another terminal scope".to_string(), + )); + } + + Ok(()) +} + +pub fn append_message(app: &impl Sized, message: AiMessage) -> AppResult<()> { + tracing::debug!( + session_id = %message.session_id, + role = ?message.role, + content_len = message.content.len(), + command_card_count = message.command_cards.len(), + has_reasoning = message.reasoning_content.is_some(), + "Persisting AI message" + ); + + let _ = app; + storage::update_settings_doc::(SettingsDocKey::AiHistory, |history| { + if let Some(session) = history + .sessions + .iter_mut() + .find(|item| item.id == message.session_id) + { + session.updated_at = message.created_at.clone(); + } + history.messages.push(message); + trim_history(history); + Ok(()) + }) +} + +pub fn get_session_backend_metadata( + app: &impl Sized, + session_id: &str, +) -> AppResult> { + Ok(load_history(app)? + .sessions + .into_iter() + .find(|session| session.id == session_id) + .and_then(|session| session.backend_metadata)) +} + +pub fn set_session_external_session_id( + app: &impl Sized, + session_id: &str, + agent_kind: AiAgentKind, + external_session_id: String, +) -> AppResult<()> { + let _ = app; + let session_id = session_id.to_string(); + storage::update_settings_doc::(SettingsDocKey::AiHistory, |history| { + normalize_history(history); + if let Some(session) = history + .sessions + .iter_mut() + .find(|item| item.id == session_id) + { + session.agent_kind = agent_kind; + session.external_session_id = Some(external_session_id); + } + Ok(()) + }) +} + +pub fn set_session_backend_metadata( + app: &impl Sized, + session_id: &str, + metadata: AiSessionBackendMetadata, +) -> AppResult<()> { + let _ = app; + let session_id = session_id.to_string(); + storage::update_settings_doc::(SettingsDocKey::AiHistory, |history| { + if let Some(session) = history + .sessions + .iter_mut() + .find(|item| item.id == session_id) + { + session.backend_metadata = Some(metadata); + } + Ok(()) + }) +} + +pub fn get_ai_sessions(app: &impl Sized) -> AppResult> { + let mut sessions = load_history(app)?.sessions; + sessions.sort_by(|left, right| right.updated_at.cmp(&left.updated_at)); + Ok(sessions) +} + +pub fn get_ai_messages(app: &impl Sized, session_id: String) -> AppResult> { + Ok(load_history(app)? + .messages + .into_iter() + .filter(|message| message.session_id == session_id) + .collect()) +} + +pub fn clear_ai_history_storage(app: &impl Sized) -> AppResult<()> { + let _ = app; + storage::save_settings_doc(SettingsDocKey::AiHistory, &AiHistoryFile::default()) +} + +pub fn delete_ai_session(app: &impl Sized, session_id: String) -> AppResult<()> { + let _ = app; + storage::update_settings_doc::(SettingsDocKey::AiHistory, |history| { + history.sessions.retain(|s| s.id != session_id); + history.messages.retain(|m| m.session_id != session_id); + trim_history(history); + Ok(()) + }) +} + +pub fn rebind_ai_session( + app: &impl Sized, + session_id: String, + owner_scope: AiSessionScope, +) -> AppResult { + let _ = app; + storage::update_settings_doc::(SettingsDocKey::AiHistory, |history| { + normalize_history(history); + let Some(session) = history + .sessions + .iter_mut() + .find(|item| item.id == session_id) + else { + return Err(AppError::Config("AI session not found".to_string())); + }; + session.scope = owner_scope; + session.updated_at = now_rfc3339(); + Ok(session.clone()) + }) +} + +pub fn append_ai_audit(app: &impl Sized, request: AppendAiAuditRequest) -> AppResult { + tracing::info!( + connection_id = ?request.connection_id, + action = %request.action, + risk_level = ?request.risk_level, + inserted_to_terminal = request.inserted_to_terminal, + executed = request.executed, + blocked = request.blocked, + "Appending AI audit log" + ); + + let _ = app; + let log = AiAuditLog { + id: format!("audit-{}", uuid()), + connection_id: request.connection_id, + action: request.action, + user_input: request.user_input, + generated_command: request.generated_command, + risk_level: request.risk_level, + inserted_to_terminal: request.inserted_to_terminal, + executed: request.executed, + blocked: request.blocked, + source: request.source, + client: request.client, + capability: request.capability, + session_id: request.session_id, + permission_mode: request.permission_mode, + approval_decision: request.approval_decision, + success: request.success, + duration_ms: request.duration_ms, + error: request.error, + created_at: now_rfc3339(), + }; + storage::update_settings_doc::(SettingsDocKey::AiAudit, |file| { + file.logs.push(log.clone()); + if file.logs.len() > AI_AUDIT_MAX_LOGS { + let keep_from = file.logs.len().saturating_sub(AI_AUDIT_MAX_LOGS); + file.logs = file.logs.split_off(keep_from); + } + Ok(log) + }) +} + +pub fn get_ai_audit_logs(app: &impl Sized, limit: Option) -> AppResult> { + let _ = app; + let mut logs = storage::load_settings_doc::(SettingsDocKey::AiAudit)?.logs; + logs.sort_by(|left, right| right.created_at.cmp(&left.created_at)); + if let Some(limit) = limit { + logs.truncate(limit); + } + Ok(logs) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn old_history_without_reasoning_defaults_cleanly() { + let raw = r#"{"sessions":[],"messages":[{"id":"m1","sessionId":"s1","role":"assistant","content":"hello","createdAt":"2026-04-28T00:00:00Z","commandCards":[]}]}"#; + let history: AiHistoryFile = serde_json::from_str(raw).unwrap(); + assert_eq!(history.messages.len(), 1); + assert_eq!(history.messages[0].reasoning_content, None); + } + + #[test] + fn migrates_legacy_codex_thread_metadata_to_external_session() { + let mut session = AiSession { + id: "s1".to_string(), + agent_kind: AiAgentKind::Nyaterm, + scope: AiSessionScope::default(), + connection_id: None, + title: "legacy".to_string(), + created_at: "2026-04-28T00:00:00Z".to_string(), + updated_at: "2026-04-28T00:00:00Z".to_string(), + external_session_id: None, + backend_metadata: Some(AiSessionBackendMetadata { + backend: AiBackendKind::Codex, + external_thread_id: Some("thread-123".to_string()), + codex_terminal_tools_version: None, + }), + }; + + normalize_session_scope(&mut session); + + assert_eq!(session.agent_kind, AiAgentKind::Codex); + assert_eq!(session.external_session_id.as_deref(), Some("thread-123")); + } + + #[test] + fn trims_ai_history_to_session_and_message_limits() { + let mut history = AiHistoryFile::default(); + for session_idx in 0..220 { + let session_id = format!("s-{session_idx:03}"); + let updated_at = format!( + "2026-04-28T00:{:02}:{:02}Z", + session_idx / 60, + session_idx % 60 + ); + history.sessions.push(AiSession { + id: session_id.clone(), + agent_kind: AiAgentKind::Nyaterm, + scope: AiSessionScope::default(), + connection_id: None, + title: session_id.clone(), + created_at: updated_at.clone(), + updated_at, + external_session_id: None, + backend_metadata: None, + }); + for message_idx in 0..10 { + history.messages.push(AiMessage { + id: format!("m-{session_idx:03}-{message_idx:02}"), + session_id: session_id.clone(), + role: if message_idx % 2 == 0 { + AiMessageRole::User + } else { + AiMessageRole::Assistant + }, + content: "message".to_string(), + created_at: format!( + "2026-04-28T00:{:02}:{:02}.{:03}Z", + session_idx / 60, + session_idx % 60, + message_idx + ), + reasoning_content: None, + command_cards: vec![], + }); + } + } + + trim_history(&mut history); + + assert_eq!(history.sessions.len(), AI_HISTORY_MAX_SESSIONS); + assert_eq!(history.messages.len(), AI_HISTORY_MAX_MESSAGES); + let retained_sessions: HashSet<&str> = history + .sessions + .iter() + .map(|session| session.id.as_str()) + .collect(); + assert!(!retained_sessions.contains("s-000")); + assert!(retained_sessions.contains("s-219")); + assert!( + history + .messages + .iter() + .all(|message| retained_sessions.contains(message.session_id.as_str())) + ); + } +} diff --git a/src-tauri/crates/nyaterm-core/src/core/ai/http.rs b/src-tauri/crates/nyaterm-core/src/core/ai/http.rs new file mode 100644 index 00000000..6c993743 --- /dev/null +++ b/src-tauri/crates/nyaterm-core/src/core/ai/http.rs @@ -0,0 +1,93 @@ +use genai::WebConfig; +use reqwest::{Client, ClientBuilder, NoProxy, Proxy, Url}; + +use crate::config::{AiProxyMode, AiProxyProtocol, AiProxySettings, AiSettings}; +use crate::error::{AppError, AppResult}; + +use super::model::ai_request_headers; + +/// Every built-in AI transport uses this builder. Keep timeouts at the request / +/// stream level: a total client timeout would interrupt long-running streams. +pub fn build_http_client(settings: &AiSettings) -> AppResult { + let web_config = WebConfig::default().with_default_headers(ai_request_headers(settings)?); + let builder = web_config.apply_to_builder(Client::builder()); + apply_proxy(builder, &settings.proxy)? + .build() + .map_err(|_| AppError::Config("Failed to build AI HTTP client".to_string())) +} + +fn apply_proxy(builder: ClientBuilder, settings: &AiProxySettings) -> AppResult { + match settings.mode { + AiProxyMode::System => Ok(builder), + AiProxyMode::Direct => Ok(builder.no_proxy()), + AiProxyMode::Custom => { + let url = proxy_url(settings)?; + let username = settings + .username + .as_deref() + .filter(|value| !value.is_empty()); + let password = settings + .password + .as_deref() + .filter(|value| !value.is_empty()); + if password.is_some() && username.is_none() { + return Err(AppError::Config( + "AI proxy password requires a username".to_string(), + )); + } + if settings.protocol == AiProxyProtocol::Socks5 + && (username.is_some_and(|value| value.len() > 255) + || password.is_some_and(|value| value.len() > 255)) + { + return Err(AppError::Config( + "SOCKS5 proxy credentials must be at most 255 bytes".to_string(), + )); + } + let mut proxy = Proxy::all(url) + .map_err(|_| AppError::Config("Invalid AI proxy configuration".to_string()))? + .no_proxy(NoProxy::from_string(&settings.no_proxy)); + if let Some(username) = username { + proxy = proxy.basic_auth(username, password.unwrap_or_default()); + } + // Explicitly disable automatic proxies so bypassed requests connect + // directly, even when environment / system proxies are configured. + Ok(builder.no_proxy().proxy(proxy)) + } + } +} + +fn proxy_url(settings: &AiProxySettings) -> AppResult { + let invalid = || AppError::Config("Invalid AI proxy host or port".to_string()); + let host = settings.host.trim(); + if host.is_empty() + || settings.port == 0 + || host + .chars() + .any(|value| value.is_whitespace() || "/\\@?#%".contains(value)) + { + return Err(invalid()); + } + let unbracketed = host + .strip_prefix('[') + .and_then(|value| value.strip_suffix(']')) + .unwrap_or(host); + let host = if unbracketed.contains(':') { + let address = unbracketed + .parse::() + .map_err(|_| invalid())?; + format!("[{address}]") + } else { + host.to_string() + }; + let scheme = match settings.protocol { + AiProxyProtocol::Http => "http", + AiProxyProtocol::Socks5 => "socks5h", + }; + // Validate DNS names using a special URL scheme too: SOCKS URLs otherwise + // allow opaque hosts with characters that cannot be used as a DNS name. + Url::parse(&format!("http://{host}:{}", settings.port)).map_err(|_| invalid())?; + Url::parse(&format!("{scheme}://{host}:{}", settings.port)).map_err(|_| invalid()) +} + +#[cfg(test)] +mod tests; diff --git a/src-tauri/src/core/ai/http/tests.rs b/src-tauri/crates/nyaterm-core/src/core/ai/http/tests.rs similarity index 100% rename from src-tauri/src/core/ai/http/tests.rs rename to src-tauri/crates/nyaterm-core/src/core/ai/http/tests.rs diff --git a/src-tauri/crates/nyaterm-core/src/core/ai/mod.rs b/src-tauri/crates/nyaterm-core/src/core/ai/mod.rs new file mode 100644 index 00000000..0cef93cf --- /dev/null +++ b/src-tauri/crates/nyaterm-core/src/core/ai/mod.rs @@ -0,0 +1,11 @@ +pub mod history; +pub mod http; +pub mod model; +pub mod parser; +pub mod prompt; +pub mod redaction; +pub mod responses; +pub mod stream; +pub mod types; + +pub mod catalog; diff --git a/src-tauri/crates/nyaterm-core/src/core/ai/model.rs b/src-tauri/crates/nyaterm-core/src/core/ai/model.rs new file mode 100644 index 00000000..578e1913 --- /dev/null +++ b/src-tauri/crates/nyaterm-core/src/core/ai/model.rs @@ -0,0 +1,1198 @@ +use std::collections::BTreeMap; +use std::time::Duration; + +use genai::adapter::AdapterKind; +use genai::chat::{ChatMessage, ChatOptions, ChatRequest, ReasoningEffort}; +use genai::resolver::{AuthData, Endpoint, ServiceTargetResolver}; +use genai::{Client, ModelIden}; +use reqwest::header::{HeaderMap, HeaderValue, USER_AGENT}; +use serde_json::Value; + +use crate::config::{ + self, AI_REQUEST_USER_AGENT_DEFAULT, AiApiFormat, AiBackendKind, AiModelConfigItem, + AiModelSource, AiProviderApiProtocol, AiProviderCredential, AiProviderKind, AiReasoningEffort, + AiSettings, ai_model_id_for_credential, ai_model_id_for_provider, +}; +use crate::error::{AppError, AppResult}; +use crate::utils::url::{join_api_base_url, normalize_api_base_url}; + +use super::http::build_http_client; +use super::types::{AiChatRequest, AiModelDiscovery}; + +const MODEL_TEST_SYSTEM_PROMPT: &str = "You are NyaTerm's terminal AI connectivity check. Reply with OK only; do not suggest or run commands."; +const MODEL_TEST_USER_PROMPT: &str = "Confirm that this model can respond."; +const MODEL_TEST_MAX_OUTPUT_TOKENS: u32 = 64; + +#[derive(Debug, Clone)] +pub struct ResolvedAiModel { + pub model_name: String, + pub provider_kind: AiProviderKind, + pub api_format: AiApiFormat, + pub credential: Option, +} + +pub fn resolve_request_model_config( + settings: &AiSettings, + request: &AiChatRequest, +) -> AppResult { + request + .model_id + .as_deref() + .and_then(|id| { + settings + .models + .iter() + .find(|model| model.enabled && model.id == id) + }) + .or_else(|| { + settings.default_model_id.as_deref().and_then(|id| { + settings + .models + .iter() + .find(|model| model.enabled && model.id == id) + }) + }) + .or_else(|| settings.models.iter().find(|model| model.enabled)) + .cloned() + .ok_or_else(|| AppError::Config("No enabled AI model configured".to_string())) +} + +pub fn build_chat_options(settings: &AiSettings) -> ChatOptions { + let mut options = ChatOptions::default() + .with_capture_reasoning_content(true) + .with_normalize_reasoning_content(true); + + if let Some(reasoning_effort) = genai_reasoning_effort(&settings.default_reasoning_effort) { + options = options.with_reasoning_effort(reasoning_effort); + } else if matches!(settings.default_reasoning_effort, AiReasoningEffort::Ultra) { + options = options.with_extra_body(serde_json::json!({ "reasoning_effort": "ultra" })); + } + + options +} + +fn genai_reasoning_effort(value: &AiReasoningEffort) -> Option { + match value { + AiReasoningEffort::Auto => None, + AiReasoningEffort::None => Some(ReasoningEffort::None), + AiReasoningEffort::Minimal => Some(ReasoningEffort::Minimal), + AiReasoningEffort::Low => Some(ReasoningEffort::Low), + AiReasoningEffort::Medium => Some(ReasoningEffort::Medium), + AiReasoningEffort::High => Some(ReasoningEffort::High), + AiReasoningEffort::XHigh => Some(ReasoningEffort::XHigh), + AiReasoningEffort::Max => Some(ReasoningEffort::Max), + AiReasoningEffort::Ultra => None, + } +} + +pub fn resolve_request_model( + settings: &AiSettings, + request: &AiChatRequest, +) -> AppResult { + tracing::debug!( + requested_model_id = ?request.model_id, + default_model_id = ?settings.default_model_id, + enabled_model_count = settings.models.iter().filter(|model| model.enabled).count(), + "Resolving AI model for request" + ); + + let selected_model = resolve_request_model_config(settings, request)?; + + resolve_model_config(settings, &selected_model) +} + +pub fn resolve_model_config( + settings: &AiSettings, + selected_model: &AiModelConfigItem, +) -> AppResult { + if selected_model.backend == AiBackendKind::Codex { + return Err(AppError::Config( + "Codex models must be routed through codex app-server".to_string(), + )); + } + + let model_provider_kind = selected_model + .provider_kind + .clone() + .or_else(|| infer_provider_kind_from_model_id(&selected_model.id)); + + let credential = + resolve_model_credential(settings, selected_model, model_provider_kind.as_ref())?; + let provider_kind = credential + .as_ref() + .map(|credential| credential.provider_kind.clone()) + .or(model_provider_kind) + .ok_or_else(|| { + AppError::Config(format!( + "AI model '{}' is missing provider information", + selected_model.name + )) + })?; + validate_model_credential(&provider_kind, credential.as_ref())?; + + tracing::info!( + resolved_model_id = %selected_model.id, + resolved_model_name = %selected_model.name, + provider_kind = ?provider_kind, + credential_id = ?credential.as_ref().map(|item| item.id.as_str()), + "Resolved AI model" + ); + + Ok(ResolvedAiModel { + model_name: selected_model.name.clone(), + provider_kind, + api_format: credential + .as_ref() + .map(|credential| credential.api_format.clone()) + .unwrap_or_default(), + credential, + }) +} + +fn infer_provider_kind_from_model_id(model_id: &str) -> Option { + let (prefix, _) = model_id.split_once(':')?; + match prefix { + "openai" => Some(AiProviderKind::Openai), + "anthropic" => Some(AiProviderKind::Anthropic), + "gemini" => Some(AiProviderKind::Gemini), + "deepseek" => Some(AiProviderKind::Deepseek), + "groq" => Some(AiProviderKind::Groq), + "ollama" => Some(AiProviderKind::Ollama), + "xai" => Some(AiProviderKind::Xai), + "cohere" => Some(AiProviderKind::Cohere), + "mimo" => Some(AiProviderKind::Mimo), + "zai" => Some(AiProviderKind::Zai), + "openai_compatible" => Some(AiProviderKind::OpenaiCompatible), + _ => None, + } +} + +fn resolve_model_credential( + settings: &AiSettings, + model: &AiModelConfigItem, + provider_kind: Option<&AiProviderKind>, +) -> AppResult> { + // Legacy built-in models use the provider ID as their implicit credential ID. + // Never let a newly added account of the same kind capture those models. + let implicit_credential_id = model + .id + .split_once(':') + .map(|(prefix, _)| prefix) + .filter(|prefix| is_builtin_ai_provider_credential_id(prefix)); + if let Some(credential_id) = model.credential_id.as_deref().or(implicit_credential_id) { + let credential = settings + .provider_credentials + .iter() + .find(|item| item.id == credential_id && item.enabled) + .cloned() + .ok_or_else(|| { + AppError::Config(format!( + "No enabled AI credential found for model '{}'", + model.name + )) + })?; + return Ok(Some(credential)); + } + + Ok(provider_kind.and_then(|provider_kind| { + settings + .provider_credentials + .iter() + .find(|item| item.enabled && &item.provider_kind == provider_kind) + .cloned() + })) +} + +fn validate_model_credential( + provider_kind: &AiProviderKind, + credential: Option<&AiProviderCredential>, +) -> AppResult<()> { + match provider_kind { + AiProviderKind::Ollama => Ok(()), + AiProviderKind::OpenaiCompatible => { + if credential.is_none() { + return Err(AppError::Config( + "No enabled OpenAI-compatible AI credential configured".to_string(), + )); + } + Ok(()) + } + _ => { + let credential = credential.ok_or_else(|| { + AppError::Config(format!( + "No enabled AI credential configured for {:?}", + provider_kind + )) + })?; + if credential + .api_key + .as_deref() + .is_none_or(|value| value.trim().is_empty()) + { + return Err(AppError::Config(format!( + "No API key configured for AI credential '{}'", + credential.name + ))); + } + Ok(()) + } + } +} + +pub fn build_client(model: &ResolvedAiModel, settings: &AiSettings) -> AppResult { + tracing::debug!( + model_name = %model.model_name, + provider_kind = ?model.provider_kind, + has_credential = model.credential.is_some(), + has_base_url = model + .credential + .as_ref() + .and_then(|credential| credential.base_url.as_deref()) + .is_some_and(|value| !value.trim().is_empty()), + "Building AI client" + ); + + let adapter_kind = adapter_kind_for_model(model); + let mapped_model = genai_model_name(&model.provider_kind, &model.model_name); + let api_key = model + .credential + .as_ref() + .and_then(|credential| credential.api_key.clone()) + .filter(|value| !value.trim().is_empty()); + let base_url = model + .credential + .as_ref() + .and_then(|credential| credential.base_url.as_deref()) + .map(normalize_api_base_url) + .transpose()? + .filter(|value| !value.trim().is_empty()); + let allows_empty_auth = model.provider_kind == AiProviderKind::OpenaiCompatible + || (model.provider_kind == AiProviderKind::Ollama + && model + .credential + .as_ref() + .and_then(|credential| credential.api_protocol.as_ref()) + == Some(&AiProviderApiProtocol::OpenaiCompatible)); + + let resolver = + ServiceTargetResolver::from_resolver_fn(move |service_target: genai::ServiceTarget| { + Ok(apply_service_target_overrides( + service_target, + api_key.clone(), + base_url.clone(), + allows_empty_auth, + )) + }); + + Ok(Client::builder() + .with_model_mapper_fn(move |_model| Ok(ModelIden::new(adapter_kind, mapped_model.clone()))) + .with_service_target_resolver(resolver) + .with_reqwest(build_http_client(settings)?) + .build()) +} + +fn apply_service_target_overrides( + mut service_target: genai::ServiceTarget, + api_key: Option, + base_url: Option, + allows_empty_auth: bool, +) -> genai::ServiceTarget { + if let Some(api_key) = api_key { + service_target.auth = AuthData::from_single(api_key); + } else if allows_empty_auth { + service_target.auth = AuthData::None; + } + if let Some(base_url) = base_url { + service_target.endpoint = Endpoint::from_owned(base_url); + } + service_target +} + +fn effective_request_user_agent(settings: &AiSettings) -> &str { + let value = settings.request_user_agent.trim(); + if value.is_empty() { + AI_REQUEST_USER_AGENT_DEFAULT + } else { + value + } +} + +pub fn ai_request_headers(settings: &AiSettings) -> AppResult { + let user_agent = effective_request_user_agent(settings); + let user_agent_value = HeaderValue::from_str(user_agent).map_err(|error| { + AppError::Config(format!("Invalid AI User-Agent header value: {error}")) + })?; + let mut headers = HeaderMap::new(); + headers.insert(USER_AGENT, user_agent_value); + Ok(headers) +} + +fn adapter_kind(kind: &AiProviderKind) -> AdapterKind { + match kind { + AiProviderKind::Openai | AiProviderKind::OpenaiCompatible => AdapterKind::OpenAI, + AiProviderKind::Anthropic => AdapterKind::Anthropic, + AiProviderKind::Gemini => AdapterKind::Gemini, + AiProviderKind::Deepseek => AdapterKind::DeepSeek, + AiProviderKind::Groq => AdapterKind::Groq, + AiProviderKind::Ollama => AdapterKind::Ollama, + AiProviderKind::Xai + | AiProviderKind::Cohere + | AiProviderKind::Mimo + | AiProviderKind::Zai => AdapterKind::OpenAI, + } +} + +fn adapter_kind_for_model(model: &ResolvedAiModel) -> AdapterKind { + match model + .credential + .as_ref() + .and_then(|credential| credential.api_protocol.as_ref()) + { + None => adapter_kind(&model.provider_kind), + Some(AiProviderApiProtocol::OpenaiCompatible) => match model.provider_kind { + AiProviderKind::Deepseek => AdapterKind::DeepSeek, + AiProviderKind::Groq => AdapterKind::Groq, + _ => AdapterKind::OpenAI, + }, + Some(AiProviderApiProtocol::Anthropic) => AdapterKind::Anthropic, + Some(AiProviderApiProtocol::Gemini) => AdapterKind::Gemini, + Some(AiProviderApiProtocol::Ollama) => AdapterKind::Ollama, + } +} + +fn genai_model_name(provider_kind: &AiProviderKind, model_name: &str) -> String { + if matches!(provider_kind, AiProviderKind::Deepseek) + && let Some(base_model_name) = model_name.strip_suffix("-none") + { + return base_model_name.to_string(); + } + + model_name.to_string() +} + +pub async fn list_model_names(app: &impl Sized) -> AppResult> { + let settings = config::load_app_settings(app)?; + list_model_names_for_settings(&settings.ai).await +} + +pub async fn list_model_names_for_settings( + settings: &AiSettings, +) -> AppResult> { + let credentials = model_discovery_credentials(settings); + + let mut models = BTreeMap::new(); + let mut errors = Vec::new(); + + for credential in credentials { + let label = credential.name.as_str(); + tracing::info!( + credential = label, + provider_kind = ?credential.provider_kind, + "Fetching model list from provider" + ); + match fetch_credential_model_names(credential, settings).await { + Ok(names) => { + tracing::info!( + credential = label, + count = names.len(), + "Fetched models from provider" + ); + for name in names { + let trimmed = name.trim(); + if trimmed.is_empty() { + continue; + } + let builtin = is_builtin_ai_provider_credential_id(&credential.id); + let id = if builtin { + ai_model_id_for_provider(&credential.provider_kind, trimmed) + } else { + ai_model_id_for_credential(&credential.id, trimmed) + }; + models.entry(id.clone()).or_insert(AiModelDiscovery { + id, + name: trimmed.to_string(), + backend: AiBackendKind::Genai, + provider_kind: Some(credential.provider_kind.clone()), + credential_id: if builtin { + None + } else { + Some(credential.id.clone()) + }, + source: AiModelSource::RustGenai, + }); + } + } + Err(error) => { + tracing::warn!(credential = label, %error, "Failed to fetch models from provider"); + errors.push(format!("{label}: {error}")); + } + } + } + + if models.is_empty() && !errors.is_empty() { + return Err(AppError::Config(format!( + "Failed to list AI models: {}", + errors.join("; ") + ))); + } + + Ok(models.into_values().collect()) +} + +pub async fn test_provider_connection( + settings: &AiSettings, + credential_id: &str, +) -> AppResult> { + let credential = settings + .provider_credentials + .iter() + .find(|credential| credential.id == credential_id && credential.enabled) + .ok_or_else(|| AppError::Config("No enabled AI provider selected".to_string()))?; + + fetch_credential_model_names(credential, settings).await +} + +async fn fetch_credential_model_names( + credential: &AiProviderCredential, + settings: &AiSettings, +) -> AppResult> { + let base_url = credential.base_url.as_deref().unwrap_or_default().trim(); + if base_url.is_empty() { + return Err(AppError::Config( + "AI provider API base URL is required".to_string(), + )); + } + + let api_key = credential + .api_key + .as_deref() + .filter(|value| !value.trim().is_empty()); + let protocol = + credential + .api_protocol + .clone() + .unwrap_or_else(|| match &credential.provider_kind { + AiProviderKind::Anthropic => AiProviderApiProtocol::Anthropic, + AiProviderKind::Gemini => AiProviderApiProtocol::Gemini, + AiProviderKind::Ollama => AiProviderApiProtocol::Ollama, + _ => AiProviderApiProtocol::OpenaiCompatible, + }); + match protocol { + AiProviderApiProtocol::OpenaiCompatible => { + fetch_openai_compatible_models(base_url, api_key, settings).await + } + AiProviderApiProtocol::Anthropic => { + fetch_provider_model_names( + base_url, + api_key, + settings, + "models", + Some(("limit", "1000")), + "data", + "id", + AiProviderApiProtocol::Anthropic, + ) + .await + } + AiProviderApiProtocol::Gemini => { + fetch_provider_model_names( + base_url, + api_key, + settings, + "models", + Some(("pageSize", "1000")), + "models", + "name", + AiProviderApiProtocol::Gemini, + ) + .await + } + AiProviderApiProtocol::Ollama => { + fetch_provider_model_names( + base_url, + api_key, + settings, + "api/tags", + None, + "models", + "name", + AiProviderApiProtocol::Ollama, + ) + .await + } + } +} + +async fn fetch_provider_model_names( + base_url: &str, + api_key: Option<&str>, + settings: &AiSettings, + path: &str, + query: Option<(&str, &str)>, + response_field: &str, + model_name_field: &str, + protocol: AiProviderApiProtocol, +) -> AppResult> { + let url = join_api_base_url(base_url, path)?; + let mut url = reqwest::Url::parse(&url) + .map_err(|error| AppError::Config(format!("Invalid model list URL: {error}")))?; + if let Some((name, value)) = query { + url.query_pairs_mut().append_pair(name, value); + } + let client = build_http_client(settings)?; + let mut names = Vec::new(); + let mut cursor: Option<(&str, String)> = None; + let mut seen_cursors = std::collections::HashSet::new(); + for _ in 0..100 { + let mut page_url = url.clone(); + if let Some((key, value)) = &cursor { + page_url.query_pairs_mut().append_pair(key, value); + } + let mut request = client + .get(page_url.as_str()) + .timeout(Duration::from_secs(15)); + if let Some(key) = api_key { + request = match protocol { + AiProviderApiProtocol::Anthropic => request + .header("x-api-key", key) + .header("anthropic-version", "2023-06-01"), + AiProviderApiProtocol::Gemini => request.header("x-goog-api-key", key), + AiProviderApiProtocol::Ollama => request.bearer_auth(key), + AiProviderApiProtocol::OpenaiCompatible => request.bearer_auth(key), + }; + } else if protocol == AiProviderApiProtocol::Anthropic { + request = request.header("anthropic-version", "2023-06-01"); + } + let response = request.send().await.map_err(|error| { + AppError::Config(format!("Failed to fetch models from {url}: {error}")) + })?; + if !response.status().is_success() { + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + return Err(AppError::Config(format!( + "Failed to fetch models from {url}: {status} {body}" + ))); + } + let body: Value = response + .json() + .await + .map_err(|error| AppError::Config(format!("Invalid JSON from {url}: {error}")))?; + let entries = body[response_field] + .as_array() + .ok_or_else(|| AppError::Config(format!("Invalid model list from {url}")))?; + for entry in entries { + let name = entry[model_name_field] + .as_str() + .filter(|name| !name.trim().is_empty()) + .ok_or_else(|| AppError::Config("Invalid model list: missing model ID".into()))?; + names.push(if protocol == AiProviderApiProtocol::Gemini { + name.strip_prefix("models/").unwrap_or(name).to_string() + } else { + name.to_string() + }); + } + cursor = next_models_cursor(&body, &protocol)?; + match &cursor { + None => return Ok(names), + Some((_, value)) if !seen_cursors.insert(value.clone()) => { + return Err(AppError::Config( + "Model list pagination repeated a cursor".into(), + )); + } + _ => {} + } + } + Err(AppError::Config( + "Model list pagination exceeded 100 pages".into(), + )) +} + +fn next_models_cursor( + body: &Value, + protocol: &AiProviderApiProtocol, +) -> AppResult> { + let (key, value) = match protocol { + AiProviderApiProtocol::Anthropic if body["has_more"].as_bool() == Some(true) => { + let value = body["last_id"] + .as_str() + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + AppError::Config("Model list has more pages but no last_id".into()) + })?; + ("after_id", value) + } + AiProviderApiProtocol::Gemini => { + let Some(value) = body["nextPageToken"] + .as_str() + .filter(|value| !value.is_empty()) + else { + return Ok(None); + }; + ("pageToken", value) + } + _ => return Ok(None), + }; + Ok(Some((key, value.to_string()))) +} + +fn model_discovery_credentials(settings: &AiSettings) -> Vec<&AiProviderCredential> { + settings + .provider_credentials + .iter() + .filter(|credential| credential.enabled) + .collect() +} + +fn is_builtin_ai_provider_credential_id(id: &str) -> bool { + matches!( + id, + "openai" + | "anthropic" + | "gemini" + | "deepseek" + | "groq" + | "ollama" + | "xai" + | "cohere" + | "mimo" + | "zai" + ) +} + +/// Fetches model names from an OpenAI-compatible `/v1/models` endpoint directly via HTTP, +/// bypassing `genai::Client::all_model_names` which does not apply the `ServiceTargetResolver` +/// (and therefore ignores custom auth/endpoint configuration). +async fn fetch_openai_compatible_models( + base_url: &str, + api_key: Option<&str>, + settings: &AiSettings, +) -> AppResult> { + let url = openai_compatible_models_url(base_url)?; + let client = build_http_client(settings)?; + let mut req = client.get(&url); + if let Some(key) = api_key { + req = req.bearer_auth(key); + } + let resp = req + .timeout(Duration::from_secs(15)) + .send() + .await + .map_err(|e| AppError::Config(format!("Failed to fetch models from {url}: {e}")))?; + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + return Err(AppError::Config(format!( + "Failed to fetch models from {url}: {status} {body}" + ))); + } + let body: Value = resp + .json() + .await + .map_err(|e| AppError::Config(format!("Invalid JSON from {url}: {e}")))?; + parse_openai_model_names(&body) +} + +fn parse_openai_model_names(body: &Value) -> AppResult> { + let models = body["data"].as_array().ok_or_else(|| { + AppError::Config("Invalid models response: expected a data array".to_string()) + })?; + models + .iter() + .map(|item| { + item["id"] + .as_str() + .filter(|name| !name.trim().is_empty()) + .map(String::from) + .ok_or_else(|| { + AppError::Config("Invalid models response: missing model ID".to_string()) + }) + }) + .collect() +} + +fn openai_compatible_models_url(base_url: &str) -> AppResult { + join_api_base_url(base_url, "models") +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::core::ai::types::{AiAction, AiContext, AiRequestOptions}; + use genai::resolver::{AuthData, Endpoint}; + + fn test_service_target(auth: AuthData) -> genai::ServiceTarget { + genai::ServiceTarget { + endpoint: Endpoint::from_static("https://default.example/v1/"), + auth, + model: ModelIden::new(AdapterKind::OpenAI, "test-model"), + } + } + + #[test] + fn builtin_models_keep_their_original_account() { + let mut settings = AiSettings::default(); + // Defaults intentionally contain no models. Exercise a legacy built-in + // entry explicitly instead of depending on the new-user catalog. + settings.models = vec![ + serde_json::from_value(serde_json::json!({ + "id":"openai:test-model", "name":"Test", "model":"test-model", + "source":"rust-genai", "provider_kind":"openai", "credential_id":"openai", + "enabled":true + })) + .unwrap(), + ]; + settings.provider_credentials = vec![ + test_credential_with_id("credential-new", AiProviderKind::Openai, Some("new-key")), + test_credential_with_id("openai", AiProviderKind::Openai, Some("original-key")), + ]; + let model = settings + .models + .iter() + .find(|model| model.id.starts_with("openai:")) + .unwrap(); + let credential = resolve_model_credential(&settings, model, Some(&AiProviderKind::Openai)) + .unwrap() + .unwrap(); + assert_eq!(credential.id, "openai"); + + settings.provider_credentials[1].enabled = false; + let model = settings + .models + .iter() + .find(|model| model.id.starts_with("openai:")) + .unwrap(); + assert!(resolve_model_credential(&settings, model, Some(&AiProviderKind::Openai)).is_err()); + settings.provider_credentials.pop(); + let model = settings + .models + .iter() + .find(|model| model.id.starts_with("openai:")) + .unwrap(); + assert!(resolve_model_credential(&settings, model, Some(&AiProviderKind::Openai)).is_err()); + } + + #[test] + fn models_response_distinguishes_empty_lists_from_invalid_payloads() { + assert_eq!( + parse_openai_model_names(&serde_json::json!({"data": []})).unwrap(), + Vec::::new() + ); + assert_eq!( + parse_openai_model_names(&serde_json::json!({"data": [{"id": "model-a"}]})).unwrap(), + vec!["model-a"] + ); + assert!(parse_openai_model_names(&serde_json::json!({"error": "unauthorized"})).is_err()); + assert!(parse_openai_model_names(&serde_json::json!({"data": [{}]})).is_err()); + } + + #[test] + fn model_pagination_uses_protocol_specific_cursors() { + assert_eq!( + next_models_cursor( + &serde_json::json!({"has_more": true, "last_id": "claude-a"}), + &AiProviderApiProtocol::Anthropic + ) + .unwrap(), + Some(("after_id", "claude-a".into())) + ); + assert!( + next_models_cursor( + &serde_json::json!({"has_more": true}), + &AiProviderApiProtocol::Anthropic + ) + .is_err() + ); + assert_eq!( + next_models_cursor( + &serde_json::json!({"nextPageToken": "page-2"}), + &AiProviderApiProtocol::Gemini + ) + .unwrap(), + Some(("pageToken", "page-2".into())) + ); + assert_eq!( + next_models_cursor( + &serde_json::json!({"has_more": false}), + &AiProviderApiProtocol::Anthropic + ) + .unwrap(), + None + ); + assert_eq!( + next_models_cursor(&serde_json::json!({}), &AiProviderApiProtocol::Gemini).unwrap(), + None + ); + } + + fn test_credential(kind: AiProviderKind, api_key: Option<&str>) -> AiProviderCredential { + test_credential_with_id("credential-test", kind, api_key) + } + + fn test_credential_with_id( + id: &str, + kind: AiProviderKind, + api_key: Option<&str>, + ) -> AiProviderCredential { + AiProviderCredential { + id: id.to_string(), + name: "Test Provider".to_string(), + provider_kind: kind, + icon_data_url: None, + api_protocol: None, + api_format: AiApiFormat::default(), + base_url: Some("https://api.example.com/v1/".to_string()), + api_key: api_key.map(str::to_string), + enabled: true, + } + } + + fn test_request(model_id: &str) -> AiChatRequest { + AiChatRequest { + stream_id: None, + session_id: None, + connection_id: None, + terminal_session_id: None, + owner_scope: Default::default(), + targets: vec![], + target_contexts: vec![], + mode: crate::config::AiMode::Ask, + agent_kind: crate::config::AiAgentKind::Nyaterm, + permission_mode: crate::config::AiPermissionMode::Confirm, + model_id: Some(model_id.to_string()), + model_name: None, + default_target_session_id: None, + existing_external_session_id: None, + attachments: vec![], + action: AiAction::GenerateCommand, + user_input: "test".to_string(), + context: AiContext::default(), + options: AiRequestOptions::default(), + } + } + + #[test] + fn openai_compatible_models_url_accepts_missing_trailing_slash() { + assert_eq!( + openai_compatible_models_url("https://api.example.com/v1").unwrap(), + "https://api.example.com/v1/models" + ); + } + + #[test] + fn openai_compatible_models_url_accepts_trailing_slash() { + assert_eq!( + openai_compatible_models_url("https://api.example.com/v1/").unwrap(), + "https://api.example.com/v1/models" + ); + } + + #[test] + fn openai_compatible_models_url_preserves_query() { + assert_eq!( + openai_compatible_models_url("https://api.example.com/v1?api-version=1").unwrap(), + "https://api.example.com/v1/models?api-version=1" + ); + } + + #[test] + fn ai_request_headers_use_custom_user_agent() { + let mut settings = AiSettings::default(); + settings.request_user_agent = "nyaterm-test/1.0".to_string(); + + let headers = ai_request_headers(&settings).unwrap(); + + assert_eq!( + headers + .get(USER_AGENT) + .and_then(|value| value.to_str().ok()), + Some("nyaterm-test/1.0") + ); + } + + #[test] + fn ai_request_headers_fall_back_for_blank_user_agent() { + let mut settings = AiSettings::default(); + settings.request_user_agent = " ".to_string(); + + let headers = ai_request_headers(&settings).unwrap(); + + assert_eq!( + headers + .get(USER_AGENT) + .and_then(|value| value.to_str().ok()), + Some(AI_REQUEST_USER_AGENT_DEFAULT) + ); + } + + #[test] + fn ai_request_headers_reject_invalid_user_agent() { + let mut settings = AiSettings::default(); + settings.request_user_agent = "bad\r\nvalue".to_string(); + + let error = ai_request_headers(&settings).unwrap_err(); + + assert!( + error + .to_string() + .contains("Invalid AI User-Agent header value") + ); + } + + #[test] + fn openai_compatible_empty_key_uses_no_auth_override() { + let target = apply_service_target_overrides( + test_service_target(AuthData::from_env("OPENAI_API_KEY")), + None, + Some("https://api.example.com/v1/".to_string()), + true, + ); + + assert!(matches!(target.auth, AuthData::None)); + } + + #[test] + fn openai_compatible_non_empty_key_uses_configured_key() { + let target = apply_service_target_overrides( + test_service_target(AuthData::from_env("OPENAI_API_KEY")), + Some("configured-key".to_string()), + Some("https://api.example.com/v1/".to_string()), + true, + ); + + assert!(matches!(target.auth, AuthData::Key(ref value) if value == "configured-key")); + } + + #[test] + fn custom_base_url_overrides_default_endpoint() { + let target = apply_service_target_overrides( + test_service_target(AuthData::from_env("ANTHROPIC_API_KEY")), + Some("configured-key".to_string()), + Some("https://anthropic-proxy.example.com/".to_string()), + false, + ); + + assert!(format!("{:?}", target.endpoint).contains("https://anthropic-proxy.example.com/")); + } + + #[test] + fn custom_anthropic_and_gemini_use_native_adapters() { + assert_eq!( + adapter_kind(&AiProviderKind::Anthropic), + AdapterKind::Anthropic + ); + assert_eq!(adapter_kind(&AiProviderKind::Gemini), AdapterKind::Gemini); + } + + #[test] + fn ollama_uses_native_adapter_and_preserves_model_tag() { + assert_eq!(adapter_kind(&AiProviderKind::Ollama), AdapterKind::Ollama); + assert_eq!( + genai_model_name(&AiProviderKind::Ollama, "qwen2.5:7b-instruct"), + "qwen2.5:7b-instruct" + ); + } + + #[test] + fn anthropic_and_gemini_empty_keys_fail_validation() { + for kind in [AiProviderKind::Anthropic, AiProviderKind::Gemini] { + let credential = test_credential(kind.clone(), None); + let error = validate_model_credential(&kind, Some(&credential)).unwrap_err(); + + assert!( + error + .to_string() + .contains("No API key configured for AI credential") + ); + } + } + + #[test] + fn openai_compatible_empty_key_still_passes_validation() { + let credential = test_credential(AiProviderKind::OpenaiCompatible, None); + + validate_model_credential(&AiProviderKind::OpenaiCompatible, Some(&credential)).unwrap(); + } + + #[test] + fn openai_empty_key_still_fails_validation() { + let credential = test_credential(AiProviderKind::Openai, None); + let error = + validate_model_credential(&AiProviderKind::Openai, Some(&credential)).unwrap_err(); + + assert!( + error + .to_string() + .contains("No API key configured for AI credential") + ); + } + + #[test] + fn model_discovery_credentials_include_enabled_providers() { + let mut settings = AiSettings::default(); + settings.provider_credentials = vec![ + test_credential_with_id("openai", AiProviderKind::OpenaiCompatible, None), + test_credential_with_id("credential-openai", AiProviderKind::OpenaiCompatible, None), + test_credential_with_id( + "credential-anthropic", + AiProviderKind::Anthropic, + Some("key"), + ), + test_credential_with_id("credential-gemini", AiProviderKind::Gemini, Some("key")), + AiProviderCredential { + enabled: false, + ..test_credential_with_id( + "credential-disabled", + AiProviderKind::OpenaiCompatible, + None, + ) + }, + ]; + + let ids: Vec<_> = model_discovery_credentials(&settings) + .into_iter() + .map(|credential| credential.id.as_str()) + .collect(); + + assert_eq!( + ids, + vec![ + "openai", + "credential-openai", + "credential-anthropic", + "credential-gemini" + ] + ); + } + + #[test] + fn model_resolution_prefers_explicit_credential_id_for_same_protocol_credentials() { + let mut settings = AiSettings::default(); + settings.provider_credentials = vec![ + test_credential_with_id("credential-a", AiProviderKind::Anthropic, Some("key-a")), + test_credential_with_id("credential-b", AiProviderKind::Anthropic, Some("key-b")), + ]; + settings.models = vec![AiModelConfigItem { + id: "credential-b:claude-test".to_string(), + name: "claude-test".to_string(), + backend: AiBackendKind::Genai, + provider_kind: Some(AiProviderKind::Anthropic), + credential_id: Some("credential-b".to_string()), + enabled: true, + source: AiModelSource::Manual, + last_seen_at: None, + supported_reasoning_efforts: None, + }]; + settings.default_model_id = Some("credential-b:claude-test".to_string()); + + let resolved = + resolve_request_model(&settings, &test_request("credential-b:claude-test")).unwrap(); + + let credential = resolved.credential.expect("credential should resolve"); + assert_eq!(credential.id, "credential-b"); + assert_eq!(credential.api_key.as_deref(), Some("key-b")); + assert_eq!(resolved.provider_kind, AiProviderKind::Anthropic); + } + + #[test] + fn default_reasoning_effort_auto_is_not_sent_to_genai() { + let settings = AiSettings::default(); + let options = build_chat_options(&settings); + + assert!(options.reasoning_effort.is_none()); + } + + #[test] + fn explicit_reasoning_effort_maps_to_genai_options() { + let cases = [ + (AiReasoningEffort::None, "none"), + (AiReasoningEffort::Low, "low"), + (AiReasoningEffort::Medium, "medium"), + (AiReasoningEffort::High, "high"), + (AiReasoningEffort::XHigh, "xhigh"), + ]; + + for (effort, expected) in cases { + let mut settings = AiSettings::default(); + settings.default_reasoning_effort = effort; + + let options = build_chat_options(&settings); + + assert_eq!( + options + .reasoning_effort + .as_ref() + .map(ReasoningEffort::variant_name), + Some(expected) + ); + } + } + + #[test] + fn deepseek_none_reasoning_suffix_is_not_passed_to_genai() { + assert_eq!( + genai_model_name(&AiProviderKind::Deepseek, "deepseek-v4-flash-none"), + "deepseek-v4-flash" + ); + assert_eq!( + genai_model_name(&AiProviderKind::Deepseek, "deepseek-v4-pro-none"), + "deepseek-v4-pro" + ); + } + + #[test] + fn deepseek_supported_reasoning_suffix_stays_available_for_genai() { + assert_eq!( + genai_model_name(&AiProviderKind::Deepseek, "deepseek-v4-flash-max"), + "deepseek-v4-flash-max" + ); + } + + #[test] + fn non_deepseek_none_suffix_is_preserved() { + assert_eq!( + genai_model_name(&AiProviderKind::Openai, "gpt-test-none"), + "gpt-test-none" + ); + } +} + +pub async fn test_model_connection(settings: &AiSettings, model_id: &str) -> AppResult<()> { + // The chat panel's selected effort may belong to a different model. + // Connectivity checks use the target model's own default reasoning behavior. + let mut settings = settings.clone(); + settings.default_reasoning_effort = AiReasoningEffort::Auto; + let selected_model = settings + .models + .iter() + .find(|model| model.id == model_id) + .ok_or_else(|| AppError::Config("AI model is no longer configured".to_string()))?; + let resolved_model = resolve_model_config(&settings, selected_model)?; + let timeout = Duration::from_millis(settings.timeout_ms.clamp(1_000, 60_000)); + + tokio::time::timeout(timeout, async { + if super::responses::uses_responses_api(&resolved_model) { + return super::responses::test_responses_model( + &settings, + &resolved_model, + MODEL_TEST_SYSTEM_PROMPT, + MODEL_TEST_USER_PROMPT, + MODEL_TEST_MAX_OUTPUT_TOKENS, + ) + .await; + } + + let client = build_client(&resolved_model, &settings)?; + let request = ChatRequest::new(vec![ + ChatMessage::system(MODEL_TEST_SYSTEM_PROMPT), + ChatMessage::user(MODEL_TEST_USER_PROMPT), + ]); + let options = build_chat_options(&settings).with_max_tokens(MODEL_TEST_MAX_OUTPUT_TOKENS); + client + .exec_chat(&resolved_model.model_name, request, Some(&options)) + .await + .map_err(|error| AppError::Config(format!("AI model test failed: {error}")))?; + Ok(()) + }) + .await + .map_err(|_| AppError::Config("AI model test timed out".to_string()))? +} diff --git a/src-tauri/crates/nyaterm-core/src/core/ai/parser.rs b/src-tauri/crates/nyaterm-core/src/core/ai/parser.rs new file mode 100644 index 00000000..af6248aa --- /dev/null +++ b/src-tauri/crates/nyaterm-core/src/core/ai/parser.rs @@ -0,0 +1,363 @@ +use regex::Regex; +use serde::{Deserialize, Serialize}; +use std::sync::OnceLock; + +use super::types::{AiChatRequest, AiCommandCard, AiTerminalTarget}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AiModelOutput { + #[serde(default)] + pub text: String, + #[serde(default)] + pub reasoning: Option, + #[serde(default)] + pub command_cards: Vec, +} + +pub fn parse_model_output( + raw_text: &str, + stream_reasoning: Option, +) -> (String, Option, Vec) { + let candidate = extract_json_object(raw_text).unwrap_or_else(|| raw_text.trim().to_string()); + match serde_json::from_str::(&candidate) { + Ok(output) => { + let text = if output.text.trim().is_empty() { + raw_text.trim().to_string() + } else { + output.text + }; + let reasoning_content = trim_optional_to_option(output.reasoning) + .or_else(|| trim_optional_to_option(stream_reasoning)); + let (text, extracted_reasoning) = extract_think_block(&text); + let result = ( + text, + extracted_reasoning.or(reasoning_content), + output.command_cards, + ); + if !result.0.is_empty() { + return result; + } + promote_reasoning_to_text(result) + } + Err(_) => { + let normalized_reasoning = trim_optional_to_option(stream_reasoning); + let (text, extracted_reasoning) = extract_think_block(raw_text); + let result = (text, extracted_reasoning.or(normalized_reasoning), vec![]); + if !result.0.is_empty() { + return result; + } + promote_reasoning_to_text(result) + } + } +} + +pub fn bind_command_card_targets(cards: &mut [AiCommandCard], request: &AiChatRequest) { + match request.targets.as_slice() { + [] => {} + [target] => { + for card in cards { + card.target = Some(target.clone()); + card.target_terminal_session_id = Some(target.terminal_session_id.clone()); + } + } + targets => { + for card in cards { + card.target = resolve_card_target(card, targets); + } + } + } +} + +fn resolve_card_target( + card: &AiCommandCard, + targets: &[AiTerminalTarget], +) -> Option { + if let Some(target_id) = card + .target_terminal_session_id + .as_deref() + .filter(|value| !value.trim().is_empty()) + { + return targets + .iter() + .find(|target| target.terminal_session_id == target_id) + .cloned(); + } + + if let Some(model_target) = card.target.as_ref() { + return targets + .iter() + .find(|target| target.terminal_session_id == model_target.terminal_session_id) + .cloned(); + } + + None +} + +/// When the primary text is empty but reasoning content exists, try to +/// extract a usable answer from the reasoning. Thinking models (e.g. Qwen3) +/// sometimes put the entire response in the reasoning channel. +fn promote_reasoning_to_text( + (text, reasoning, cards): (String, Option, Vec), +) -> (String, Option, Vec) { + if !text.is_empty() { + return (text, reasoning, cards); + } + let reasoning_str = match reasoning.as_deref() { + Some(r) if !r.trim().is_empty() => r, + _ => return (text, reasoning, cards), + }; + + tracing::info!( + reasoning_preview = %truncate_preview(reasoning_str, 300), + "Text content empty; attempting to extract answer from reasoning" + ); + + if let Some(json_str) = extract_json_object(reasoning_str) { + if let Ok(output) = serde_json::from_str::(&json_str) { + let promoted_text = if output.text.trim().is_empty() { + json_str.clone() + } else { + output.text + }; + let inner_reasoning = trim_optional_to_option(output.reasoning); + return (promoted_text, inner_reasoning, output.command_cards); + } + } + + let (visible, inner_reasoning) = extract_think_block(reasoning_str); + if !visible.is_empty() { + return (visible, inner_reasoning, cards); + } + + (reasoning.unwrap_or_default(), None, cards) +} + +pub fn extract_json_object(raw_text: &str) -> Option { + let trimmed = raw_text + .trim() + .trim_start_matches("```json") + .trim_start_matches("```") + .trim_end_matches("```") + .trim(); + if trimmed.starts_with('{') && trimmed.ends_with('}') { + return Some(trimmed.to_string()); + } + let start = trimmed.find('{')?; + let end = trimmed.rfind('}')?; + if start >= end { + return None; + } + Some(trimmed[start..=end].to_string()) +} + +pub fn extract_text_from_assistant(content: &str) -> String { + let trimmed = content.trim(); + if let Some(json_str) = extract_json_object(trimmed) { + if let Ok(output) = serde_json::from_str::(&json_str) { + if !output.text.trim().is_empty() { + return output.text; + } + } + } + trimmed.to_string() +} + +fn extract_think_block(raw_text: &str) -> (String, Option) { + static THINK_REGEX: OnceLock = OnceLock::new(); + let regex = THINK_REGEX.get_or_init(|| Regex::new(r"(?is)(.*?)").unwrap()); + + let mut reasoning_parts = Vec::new(); + for captures in regex.captures_iter(raw_text) { + if let Some(value) = captures.get(1) { + let reasoning = value.as_str().trim(); + if !reasoning.is_empty() { + reasoning_parts.push(reasoning.to_string()); + } + } + } + + let visible_text = regex.replace_all(raw_text, "").to_string(); + ( + visible_text.trim().to_string(), + trim_string_to_option(reasoning_parts.join("\n\n")), + ) +} + +pub fn truncate_preview(s: &str, max_len: usize) -> String { + let trimmed = s.trim(); + if trimmed.len() <= max_len { + trimmed.to_string() + } else { + let boundary = trimmed + .char_indices() + .map(|(i, _)| i) + .take_while(|&i| i <= max_len) + .last() + .unwrap_or(0); + format!("{}…", &trimmed[..boundary]) + } +} + +pub fn trim_string_to_option(value: String) -> Option { + let trimmed = value.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + } +} + +pub fn trim_optional_to_option(value: Option) -> Option { + value.and_then(trim_string_to_option) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::core::ai::types::AiAction; + + #[test] + fn parses_json_command_cards() { + let raw = r#"{"text":"ok","commandCards":[{"id":"1","title":"CPU","command":"ps aux","explanation":"x","riskLevel":"low","riskReason":"read only","expectedEffect":"list","rollback":"none"}]}"#; + let (text, reasoning, cards) = parse_model_output(raw, None); + assert_eq!(text, "ok"); + assert_eq!(reasoning, None); + assert_eq!(cards.len(), 1); + } + + #[test] + fn single_target_request_binds_all_command_cards() { + let mut cards = vec![AiCommandCard { + id: "1".to_string(), + title: "CPU".to_string(), + command: "ps aux".to_string(), + explanation: "x".to_string(), + risk_level: None, + risk_reason: None, + expected_effect: "list".to_string(), + rollback: None, + category: None, + references: vec![], + target_terminal_session_id: None, + target: None, + }]; + let mut request = test_request(); + request.targets.push(AiTerminalTarget { + terminal_session_id: "term-a".to_string(), + connection_id: Some("conn-a".to_string()), + label: "Prod".to_string(), + host: Some("10.0.0.1".to_string()), + username: Some("root".to_string()), + session_type: "SSH".to_string(), + }); + + bind_command_card_targets(&mut cards, &request); + + assert_eq!( + cards[0] + .target + .as_ref() + .map(|target| target.terminal_session_id.as_str()), + Some("term-a") + ); + } + + #[test] + fn multi_target_request_rejects_unknown_command_target() { + let mut cards = vec![AiCommandCard { + id: "1".to_string(), + title: "CPU".to_string(), + command: "ps aux".to_string(), + explanation: "x".to_string(), + risk_level: None, + risk_reason: None, + expected_effect: "list".to_string(), + rollback: None, + category: None, + references: vec![], + target_terminal_session_id: Some("term-x".to_string()), + target: None, + }]; + let mut request = test_request(); + request.targets.push(AiTerminalTarget { + terminal_session_id: "term-a".to_string(), + connection_id: None, + label: "Prod".to_string(), + host: None, + username: None, + session_type: "SSH".to_string(), + }); + request.targets.push(AiTerminalTarget { + terminal_session_id: "term-b".to_string(), + connection_id: None, + label: "Test".to_string(), + host: None, + username: None, + session_type: "SSH".to_string(), + }); + + bind_command_card_targets(&mut cards, &request); + + assert!(cards[0].target.is_none()); + } + + fn test_request() -> AiChatRequest { + AiChatRequest { + stream_id: None, + session_id: None, + connection_id: None, + terminal_session_id: None, + owner_scope: Default::default(), + targets: vec![], + target_contexts: vec![], + mode: crate::config::AiMode::Ask, + agent_kind: crate::config::AiAgentKind::Nyaterm, + permission_mode: crate::config::AiPermissionMode::Confirm, + model_id: None, + model_name: None, + default_target_session_id: None, + existing_external_session_id: None, + attachments: vec![], + action: AiAction::GenerateCommand, + user_input: "test".to_string(), + context: Default::default(), + options: Default::default(), + } + } + + #[test] + fn parse_failure_returns_text_without_cards() { + let (text, reasoning, cards) = parse_model_output("plain text", None); + assert_eq!(text, "plain text"); + assert_eq!(reasoning, None); + assert!(cards.is_empty()); + } + + #[test] + fn extracts_think_block_into_reasoning() { + let (text, reasoning, cards) = + parse_model_output("step 1\nstep 2final answer", None); + assert_eq!(text, "final answer"); + assert_eq!(reasoning.as_deref(), Some("step 1\nstep 2")); + assert!(cards.is_empty()); + } + + #[test] + fn keeps_markdown_text_when_json_parse_fails() { + let markdown = "## Summary\n\n- item 1\n- item 2"; + let (text, reasoning, cards) = parse_model_output(markdown, None); + assert_eq!(text, markdown); + assert_eq!(reasoning, None); + assert!(cards.is_empty()); + } + + #[test] + fn prefers_json_reasoning_when_present() { + let raw = r#"{"text":"answer","reasoning":"first\nsecond","commandCards":[]}"#; + let (text, reasoning, cards) = parse_model_output(raw, None); + assert_eq!(text, "answer"); + assert_eq!(reasoning.as_deref(), Some("first\nsecond")); + assert!(cards.is_empty()); + } +} diff --git a/src-tauri/crates/nyaterm-core/src/core/ai/prompt.rs b/src-tauri/crates/nyaterm-core/src/core/ai/prompt.rs new file mode 100644 index 00000000..f3829459 --- /dev/null +++ b/src-tauri/crates/nyaterm-core/src/core/ai/prompt.rs @@ -0,0 +1,996 @@ +use crate::config::AiSettings; + +use super::types::{AiAction, AiChatRequest, CommandObservation}; + +const SYSTEM_PROMPT_ZH: &str = r#"你是一个专业、谨慎、安全优先的 Linux / DevOps / 云原生终端助手。 +你的任务是帮助用户解释终端输出、生成 Shell 命令、分析错误、提供排查步骤。 + +必须遵守: +1. 不要建议不可逆高危操作,除非明确说明风险和安全替代方案。 +2. 默认生成只读诊断命令。 +3. 对任何删除、格式化、重启、停服务、改权限、批量变更命令标记风险。 +4. 命令必须适配用户当前系统、架构、shell 和权限上下文。 +5. 输出必须结构化,包含命令、说明、风险等级、影响范围和回滚建议。 +6. 不要编造当前系统不存在的信息;不确定时给出验证命令。 +7. 不要要求用户粘贴密码、私钥、token。 + +只返回一个 JSON 对象,不要使用 Markdown 代码块。格式: +{ + "text": "给用户看的说明", + "commandCards": [ + { + "id": "cmd-uuid", + "title": "标题", + "command": "shell command", + "explanation": "命令说明", + "riskLevel": "low|medium|high|critical", + "riskReason": "风险原因", + "expectedEffect": "预计影响", + "rollback": "回滚方式或无需回滚", + "category": "Linux 性能" + } + ] +}"#; + +const SYSTEM_PROMPT_ZH_HANT: &str = r#"你是一個專業、謹慎、安全優先的 Linux / DevOps / 雲端原生終端助手。 +你的任務是協助使用者解釋終端輸出、產生 Shell 命令、分析錯誤、提供排查步驟。 + +必須遵守: +1. 不要建議不可逆的高風險操作,除非明確說明風險並提供較安全的替代方案。 +2. 預設產生唯讀診斷命令。 +3. 對任何刪除、格式化、重新啟動、停止服務、修改權限、批次變更命令標記風險。 +4. 命令必須符合使用者目前的系統、架構、Shell 和權限情境。 +5. 輸出必須結構化,包含命令、說明、風險等級、影響範圍和復原建議。 +6. 不要編造目前系統不存在的資訊;不確定時提供驗證命令。 +7. 不要要求使用者貼上密碼、私鑰、token。 + +只回傳一個 JSON 物件,不要使用 Markdown 程式碼區塊。格式: +{ + "text": "給使用者看的說明", + "commandCards": [ + { + "id": "cmd-uuid", + "title": "標題", + "command": "shell command", + "explanation": "命令說明", + "riskLevel": "low|medium|high|critical", + "riskReason": "風險原因", + "expectedEffect": "預期影響", + "rollback": "復原方式或無需復原", + "category": "Linux 效能" + } + ] +}"#; + +const SYSTEM_PROMPT_EN: &str = r#"You are a professional, careful, safety-first Linux / DevOps / cloud-native terminal assistant. +Your job is to explain terminal output, generate Shell commands, analyze errors, and suggest next troubleshooting steps. + +You must follow these rules: +1. Do not suggest irreversible high-risk actions unless you clearly explain the risk and provide safer alternatives. +2. Prefer read-only diagnostic commands by default. +3. Mark any delete, format, restart, stop-service, permission-change, or bulk-change command with the appropriate risk. +4. Commands must fit the user's current system, architecture, shell, and privilege context. +5. Output must be structured and include commands, explanations, risk level, expected effect, and rollback guidance. +6. Do not invent facts about the current system. If uncertain, provide verification commands. +7. Do not ask the user to paste passwords, private keys, or tokens. + +Return exactly one JSON object and do not use Markdown code fences. Format: +{ + "text": "user-facing explanation", + "commandCards": [ + { + "id": "cmd-uuid", + "title": "title", + "command": "shell command", + "explanation": "command explanation", + "riskLevel": "low|medium|high|critical", + "riskReason": "why this risk applies", + "expectedEffect": "expected effect", + "rollback": "rollback steps or state that rollback is unnecessary", + "category": "Linux performance" + } + ] +}"#; + +const SYSTEM_PROMPT_KO: &str = r#"당신은 전문적이고 신중하며 안전을 최우선으로 하는 Linux / DevOps / 클라우드 네이티브 터미널 어시스턴트입니다. +사용자의 터미널 출력 설명, Shell 명령 생성, 오류 분석, 다음 문제 해결 단계 제안을 돕는 것이 임무입니다. + +반드시 다음 규칙을 따르세요: +1. 되돌릴 수 없는 고위험 작업은 위험을 명확히 설명하고 더 안전한 대안을 제공하지 않는 한 제안하지 마세요. +2. 기본적으로 읽기 전용 진단 명령을 우선하세요. +3. 삭제, 포맷, 재시작, 서비스 중지, 권한 변경, 대량 변경 명령에는 적절한 위험 등급을 표시하세요. +4. 명령은 사용자의 현재 시스템, 아키텍처, shell, 권한 컨텍스트에 맞아야 합니다. +5. 출력은 구조화되어야 하며 명령, 설명, 위험 등급, 예상 효과, 롤백 안내를 포함해야 합니다. +6. 현재 시스템에 대해 존재하지 않는 정보를 지어내지 마세요. 확실하지 않으면 확인 명령을 제공하세요. +7. 사용자에게 비밀번호, 개인 키, token을 붙여 넣으라고 요청하지 마세요. + +Markdown 코드 블록을 사용하지 말고 정확히 하나의 JSON 객체만 반환하세요. 형식: +{ + "text": "사용자에게 보여줄 설명", + "commandCards": [ + { + "id": "cmd-uuid", + "title": "제목", + "command": "shell command", + "explanation": "명령 설명", + "riskLevel": "low|medium|high|critical", + "riskReason": "위험 이유", + "expectedEffect": "예상 효과", + "rollback": "롤백 단계 또는 롤백이 필요 없다는 설명", + "category": "Linux 성능" + } + ] +}"#; + +const AGENT_SYSTEM_PROMPT_ZH: &str = r#"你是一个终端自动化 Agent,通过"思考—执行—观察"循环完成用户的任务。 + +每一轮你只能做一件事:调用 execute_command 工具执行一条命令,或调用 final_answer 工具给出最终回答。 + +规则: +1. 每轮必须且只能调用一个工具,不要在普通正文里输出 JSON。 +2. 如果需要执行命令,调用 execute_command。 +3. 任务完成或无需执行命令时,调用 final_answer。 +4. thought 和 answer 尽量使用用户请求指定的目标语言。 +5. 优先使用只读命令收集信息,再做修改操作。 +6. 不要执行不可逆高危命令(如 rm -rf /、mkfs、停止 SSH 等),改为在 thought 中说明风险并调用 final_answer。 +7. 不要编造信息;不确定时先用验证命令确认。 +8. 不要要求用户提供密码、私钥、token。 +9. 命令必须适配用户当前的系统和 shell 环境。 +10. riskLevel 规则:只读命令 → low,普通写操作 → medium,删除/重启/权限修改 → high,不可逆破坏 → critical。 +11. 调用 execute_command 时必须同时提供 riskLevel 和 riskReason;riskReason 要简短说明为什么这样分级。"#; + +const AGENT_SYSTEM_PROMPT_ZH_HANT: &str = r#"你是一個終端自動化 Agent,透過「思考—執行—觀察」迴圈完成使用者的任務。 + +每一輪你只能做一件事:呼叫 execute_command 工具執行一個命令,或呼叫 final_answer 工具提供最終回答。 + +規則: +1. 每輪必須且只能呼叫一個工具,不要在一般正文裡輸出 JSON。 +2. 如果需要執行命令,呼叫 execute_command。 +3. 任務完成或無需執行命令時,呼叫 final_answer。 +4. thought 和 answer 盡量使用使用者請求指定的目標語言。 +5. 優先使用唯讀命令收集資訊,再做修改操作。 +6. 不要執行不可逆的高風險命令(如 rm -rf /、mkfs、停止 SSH 等),改為在 thought 中說明風險並呼叫 final_answer。 +7. 不要編造資訊;不確定時先用驗證命令確認。 +8. 不要要求使用者提供密碼、私鑰、token。 +9. 命令必須符合使用者目前的系統和 Shell 環境。 +10. riskLevel 規則:唯讀命令 → low,一般寫入操作 → medium,刪除/重新啟動/權限修改 → high,不可逆破壞 → critical。 +11. 呼叫 execute_command 時必須同時提供 riskLevel 和 riskReason;riskReason 要簡短說明為什麼這樣分級。"#; + +const AGENT_SYSTEM_PROMPT_EN: &str = r#"You are a terminal automation agent that completes tasks using a think-execute-observe loop. + +In each turn, do exactly one thing: call the execute_command tool to execute one command, or call the final_answer tool to finish. + +Rules: +1. You must call exactly one tool per turn. Do not put protocol JSON in normal assistant text. +2. If a command must be executed, call execute_command. +3. If the task is complete or no command is needed, call final_answer. +4. Use the target language requested by the user for both thought and answer whenever possible. +5. Prefer read-only commands to gather information before making changes. +6. Do not execute irreversible high-risk commands (for example rm -rf /, mkfs, or stopping SSH). Explain the risk in thought and call final_answer instead. +7. Do not invent facts. If uncertain, verify first. +8. Do not ask the user for passwords, private keys, or tokens. +9. Commands must fit the user's current system and shell environment. +10. riskLevel guidance: read-only commands -> low, normal write actions -> medium, delete/restart/permission changes -> high, irreversible destructive actions -> critical. +11. execute_command calls must include both riskLevel and riskReason. Keep riskReason brief and explain why the risk applies."#; + +const AGENT_SYSTEM_PROMPT_KO: &str = r#"당신은 생각-실행-관찰 루프를 사용해 작업을 완료하는 터미널 자동화 Agent입니다. + +각 턴에서는 정확히 한 가지만 해야 합니다: execute_command 도구를 호출해 명령 하나를 실행하거나, final_answer 도구를 호출해 마무리하세요. + +규칙: +1. 각 턴에서 반드시 정확히 하나의 도구만 호출하세요. 일반 assistant 본문에 프로토콜 JSON을 넣지 마세요. +2. 명령을 실행해야 한다면 execute_command를 호출하세요. +3. 작업이 완료되었거나 명령이 필요 없다면 final_answer를 호출하세요. +4. 가능하면 thought와 answer 모두 사용자 요청의 대상 언어를 사용하세요. +5. 변경 작업 전에 읽기 전용 명령으로 정보를 수집하는 것을 우선하세요. +6. 되돌릴 수 없는 고위험 명령(예: rm -rf /, mkfs, SSH 중지)은 실행하지 마세요. thought에서 위험을 설명하고 대신 final_answer를 호출하세요. +7. 정보를 지어내지 마세요. 확실하지 않으면 먼저 확인하세요. +8. 사용자에게 비밀번호, 개인 키, token을 요청하지 마세요. +9. 명령은 사용자의 현재 시스템과 shell 환경에 맞아야 합니다. +10. riskLevel 기준: 읽기 전용 명령 -> low, 일반 쓰기 작업 -> medium, 삭제/재시작/권한 변경 -> high, 되돌릴 수 없는 파괴적 작업 -> critical. +11. execute_command 호출에는 반드시 riskLevel과 riskReason을 모두 포함하세요. riskReason은 짧게 작성하고 해당 위험 등급의 이유를 설명하세요."#; + +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +enum PromptLanguage { + ZhHans, + ZhHant, + En, + Ko, +} + +fn resolve_prompt_language(language: &str) -> PromptLanguage { + let normalized = language.trim().replace('_', "-").to_ascii_lowercase(); + match normalized.as_str() { + "zh-tw" | "zh-hant" | "zh-hk" | "zh-mo" => PromptLanguage::ZhHant, + "zh" | "zh-cn" | "zh-sg" | "zh-hans" => PromptLanguage::ZhHans, + "en" | "en-us" | "en-gb" => PromptLanguage::En, + "ko" | "ko-kr" => PromptLanguage::Ko, + code if code.starts_with("zh-hant-") => PromptLanguage::ZhHant, + code if code.starts_with("zh-hans-") => PromptLanguage::ZhHans, + _ => PromptLanguage::En, + } +} + +fn user_input_with_target_contexts(request: &AiChatRequest) -> String { + if request.targets.is_empty() && request.target_contexts.is_empty() { + return request.user_input.clone(); + } + + let mut result = request.user_input.clone(); + result.push_str("\n\nAvailable terminal targets:\n"); + for target in &request.targets { + result.push_str(&format!( + "- {}: {}{}{}\n", + target.terminal_session_id, + target.label, + target + .host + .as_deref() + .map(|host| format!(" host={host}")) + .unwrap_or_default(), + target + .username + .as_deref() + .map(|username| format!(" user={username}")) + .unwrap_or_default() + )); + } + + if request.targets.len() > 1 { + result.push_str( + "\nEvery executable command card or agent execute_command call must include a valid targetTerminalSessionId from the list above. If no target is clear, do not execute.\n", + ); + } + + if !request.target_contexts.is_empty() { + result.push_str("\nTerminal target context snapshots:\n"); + for item in &request.target_contexts { + let target_label = item + .target + .as_ref() + .map(|target| format!("{} ({})", target.label, target.terminal_session_id)) + .unwrap_or_else(|| "unknown".to_string()); + let ctx = &item.context; + result.push_str(&format!( + "\n[{}]\n- cwd: {}\n- input: {}\n- selected text:\n{}\n- recent output:\n{}\n", + target_label, + ctx.cwd.as_deref().unwrap_or("-"), + ctx.input_buffer, + ctx.selected_text, + ctx.recent_output + )); + } + } + + result +} + +pub fn system_prompt(language: &str) -> &'static str { + match resolve_prompt_language(language) { + PromptLanguage::ZhHans => SYSTEM_PROMPT_ZH, + PromptLanguage::ZhHant => SYSTEM_PROMPT_ZH_HANT, + PromptLanguage::En => SYSTEM_PROMPT_EN, + PromptLanguage::Ko => SYSTEM_PROMPT_KO, + } +} + +pub fn agent_system_prompt(language: &str) -> &'static str { + match resolve_prompt_language(language) { + PromptLanguage::ZhHans => AGENT_SYSTEM_PROMPT_ZH, + PromptLanguage::ZhHant => AGENT_SYSTEM_PROMPT_ZH_HANT, + PromptLanguage::En => AGENT_SYSTEM_PROMPT_EN, + PromptLanguage::Ko => AGENT_SYSTEM_PROMPT_KO, + } +} + +pub fn build_agent_prompt(request: &AiChatRequest, settings: &AiSettings) -> String { + let ctx = &request.context; + let user_input = user_input_with_target_contexts(request); + match resolve_prompt_language(&request.options.language) { + PromptLanguage::ZhHans => format!( + r#"用户任务: +{user_input} + +当前连接上下文: +- 连接名:{connection_name} +- 主机:{host} +- 用户:{username} +- 当前目录:{cwd} +- 操作系统:{os} +- 架构:{arch} + +最近终端输出(最多 {line_limit} 行): +{recent_output} + +要求: +- 面向用户的说明、总结以及推理过程使用:{language} +- 命令、路径、文件名、配置键名保持原样,不要翻译 +- 通过 execute_command 执行的命令必须是非交互式的,不能等待用户输入、确认或翻页 +- 对可能启动分页器的工具使用其非交互或禁用分页选项,例如 git --no-pager ...、journalctl --no-pager;不要依赖用户按键继续 +- 如果命令必须进入交互式 UI、确认提示或等待输入且没有非交互替代方案,不要自动执行;改用非交互替代方案,或调用 final_answer 向用户说明 + +请开始执行任务。每轮调用且只调用一个工具。"#, + user_input = user_input, + connection_name = ctx.connection_name.as_deref().unwrap_or("-"), + host = ctx.host.as_deref().unwrap_or("-"), + username = ctx.username.as_deref().unwrap_or("-"), + cwd = ctx.cwd.as_deref().unwrap_or("-"), + os = ctx.os.as_deref().unwrap_or("-"), + arch = ctx.arch.as_deref().unwrap_or(std::env::consts::ARCH), + line_limit = settings.context_line_limit, + recent_output = ctx.recent_output, + language = request.options.language, + ), + PromptLanguage::ZhHant => format!( + r#"使用者任務: +{user_input} + +目前連線情境: +- 連線名稱:{connection_name} +- 主機:{host} +- 使用者:{username} +- 目前目錄:{cwd} +- 作業系統:{os} +- 架構:{arch} + +最近終端輸出(最多 {line_limit} 行): +{recent_output} + +要求: +- 面向使用者的說明、摘要以及推理過程使用:{language} +- 命令、路徑、檔名、設定鍵名保持原樣,不要翻譯 +- 透過 execute_command 執行的命令必須是非互動式的,不能等待使用者輸入、確認或翻頁 +- 對可能啟動分頁器的工具使用其非互動或停用分頁選項,例如 git --no-pager ...、journalctl --no-pager;不要依賴使用者按鍵繼續 +- 如果命令必須進入互動式 UI、確認提示或等待輸入且沒有非互動替代方案,不要自動執行;改用非互動替代方案,或呼叫 final_answer 向使用者說明 + +請開始執行任務。每輪呼叫且只呼叫一個工具。"#, + user_input = user_input, + connection_name = ctx.connection_name.as_deref().unwrap_or("-"), + host = ctx.host.as_deref().unwrap_or("-"), + username = ctx.username.as_deref().unwrap_or("-"), + cwd = ctx.cwd.as_deref().unwrap_or("-"), + os = ctx.os.as_deref().unwrap_or("-"), + arch = ctx.arch.as_deref().unwrap_or(std::env::consts::ARCH), + line_limit = settings.context_line_limit, + recent_output = ctx.recent_output, + language = request.options.language, + ), + PromptLanguage::Ko => format!( + r#"사용자 작업: +{user_input} + +현재 연결 컨텍스트: +- 연결 이름: {connection_name} +- 호스트: {host} +- 사용자: {username} +- 현재 디렉터리: {cwd} +- 운영 체제: {os} +- 아키텍처: {arch} + +최근 터미널 출력(최대 {line_limit}줄): +{recent_output} + +요구 사항: +- 사용자에게 보이는 설명, 요약, 추론에는 {language}을(를) 사용하세요. +- 명령, 경로, 파일 이름, 구성 키는 변경하지 말고 번역하지 마세요. +- execute_command로 실행하는 명령은 비대화형이어야 하며 사용자 입력, 확인 또는 페이지 넘김을 기다리면 안 됩니다. +- pager를 실행할 수 있는 도구는 git --no-pager ..., journalctl --no-pager 같은 도구별 비대화형 또는 pager 비활성화 옵션을 사용하고 사용자 키 입력에 의존하지 마세요. +- 명령이 대화형 UI, 확인 프롬프트 또는 입력 대기를 반드시 필요로 하고 비대화형 대안이 없다면 자동 실행하지 말고, 비대화형 대안을 사용하거나 final_answer로 사용자에게 설명하세요. + +지금 작업을 시작하세요. 각 턴에서 정확히 하나의 도구만 호출하세요."#, + user_input = user_input, + connection_name = ctx.connection_name.as_deref().unwrap_or("-"), + host = ctx.host.as_deref().unwrap_or("-"), + username = ctx.username.as_deref().unwrap_or("-"), + cwd = ctx.cwd.as_deref().unwrap_or("-"), + os = ctx.os.as_deref().unwrap_or("-"), + arch = ctx.arch.as_deref().unwrap_or(std::env::consts::ARCH), + line_limit = settings.context_line_limit, + recent_output = ctx.recent_output, + language = request.options.language, + ), + PromptLanguage::En => format!( + r#"User task: +{user_input} + +Current connection context: +- Connection name: {connection_name} +- Host: {host} +- User: {username} +- Current directory: {cwd} +- Operating system: {os} +- Architecture: {arch} + +Recent terminal output (up to {line_limit} lines): +{recent_output} + +Requirements: +- Use {language} for user-facing explanations and summaries. +- Prefer {language} for reasoning when possible. +- Keep commands, paths, file names, and configuration keys unchanged. +- Commands sent through execute_command must be non-interactive and must not wait for user input, confirmation, or pager navigation. +- For tools that may start a pager, use tool-specific non-interactive or no-pager options such as git --no-pager ... or journalctl --no-pager; never rely on user key presses to continue. +- If a command requires an interactive UI, confirmation prompt, or input and has no non-interactive alternative, do not execute it automatically; choose a non-interactive alternative or call final_answer to explain it to the user. + +Start the task now. Call exactly one tool per turn."#, + user_input = user_input, + connection_name = ctx.connection_name.as_deref().unwrap_or("-"), + host = ctx.host.as_deref().unwrap_or("-"), + username = ctx.username.as_deref().unwrap_or("-"), + cwd = ctx.cwd.as_deref().unwrap_or("-"), + os = ctx.os.as_deref().unwrap_or("-"), + arch = ctx.arch.as_deref().unwrap_or(std::env::consts::ARCH), + line_limit = settings.context_line_limit, + recent_output = ctx.recent_output, + language = request.options.language, + ), + } +} + +pub fn build_observation_message( + obs: &CommandObservation, + command: &str, + language: &str, +) -> String { + let status = obs + .exit_code + .map(|c| format!("exit code {c}")) + .unwrap_or_else(|| "unknown exit code".to_string()); + let output = if obs.output.len() > 8000 { + let mut start = obs.output.len() - 8000; + while !obs.output.is_char_boundary(start) { + start += 1; + } + let truncated = &obs.output[start..]; + format!("...(truncated)\n{truncated}") + } else { + obs.output.clone() + }; + match resolve_prompt_language(language) { + PromptLanguage::ZhHans => format!( + "命令 `{command}` 执行完成({status},耗时 {duration}ms)。\n\n输出:\n{output}\n\n请根据观察结果决定下一步。每轮必须且只能调用一个工具:execute_command 或 final_answer。不要在普通正文里输出 JSON。", + duration = obs.duration_ms, + ), + PromptLanguage::ZhHant => format!( + "命令 `{command}` 執行完成({status},耗時 {duration}ms)。\n\n輸出:\n{output}\n\n請根據觀察結果決定下一步。每輪必須且只能呼叫一個工具:execute_command 或 final_answer。不要在一般正文裡輸出 JSON。", + duration = obs.duration_ms, + ), + PromptLanguage::Ko => format!( + "명령 `{command}` 실행이 완료되었습니다({status}, {duration}ms).\n\n출력:\n{output}\n\n이 관찰 결과를 바탕으로 다음 단계를 결정하세요. execute_command 또는 final_answer 중 정확히 하나의 도구만 호출하세요. 일반 assistant 본문에 프로토콜 JSON을 넣지 마세요.", + duration = obs.duration_ms, + ), + PromptLanguage::En => format!( + "Command `{command}` finished ({status}, {duration}ms).\n\nOutput:\n{output}\n\nDecide the next step based on this observation. Call exactly one tool: execute_command or final_answer. Do not put protocol JSON in normal assistant text.", + duration = obs.duration_ms, + ), + } +} + +pub fn agent_send_only_observation(language: &str) -> &'static str { + match resolve_prompt_language(language) { + PromptLanguage::ZhHans => "命令已发送到终端,但当前会话使用仅发送模式,未捕获输出。", + PromptLanguage::ZhHant => "命令已傳送到終端,但目前工作階段使用僅傳送模式,未擷取輸出。", + PromptLanguage::Ko => { + "명령을 터미널로 보냈지만 현재 세션은 전송 전용 모드이므로 출력을 캡처하지 않았습니다." + } + PromptLanguage::En => { + "Command was sent to the terminal, but this session uses send-only mode, so output was not captured." + } + } +} + +pub fn agent_execution_disabled_message(language: &str) -> &'static str { + match resolve_prompt_language(language) { + PromptLanguage::ZhHans => "当前会话已禁用 AI Agent 命令执行。", + PromptLanguage::ZhHant => "目前工作階段已停用 AI Agent 命令執行。", + PromptLanguage::Ko => "현재 세션에서 AI Agent 명령 실행이 비활성화되어 있습니다.", + PromptLanguage::En => "AI Agent command execution is disabled for the current session.", + } +} + +pub fn build_agent_rejected_message(command: &str, language: &str) -> String { + match resolve_prompt_language(language) { + PromptLanguage::ZhHans => { + format!("用户拒绝执行命令 `{command}`。请换用其他方案或给出 final_answer。") + } + PromptLanguage::ZhHant => { + format!("使用者拒絕執行命令 `{command}`。請改用其他方案或呼叫 final_answer。") + } + PromptLanguage::Ko => { + format!( + "사용자가 명령 `{command}` 실행을 거부했습니다. 다른 방법을 사용하거나 final_answer를 제공하세요." + ) + } + PromptLanguage::En => { + format!( + "The user rejected command `{command}`. Use another approach or provide final_answer." + ) + } + } +} + +pub fn build_agent_failed_message(error: &str, language: &str) -> String { + match resolve_prompt_language(language) { + PromptLanguage::ZhHans => format!("命令执行失败:{error}。请分析原因并给出下一步。"), + PromptLanguage::ZhHant => format!("命令執行失敗:{error}。請分析原因並提供下一步。"), + PromptLanguage::Ko => { + format!("명령 실행이 실패했습니다: {error}. 원인을 분석하고 다음 단계를 제시하세요.") + } + PromptLanguage::En => { + format!( + "Command execution failed: {error}. Analyze the cause and provide the next step." + ) + } + } +} + +pub fn build_agent_unknown_action_message(action: &str, fallback: &str, language: &str) -> String { + match resolve_prompt_language(language) { + PromptLanguage::ZhHans => { + format!("未知动作 `{action}`。将其作为最终回答处理。{fallback}") + } + PromptLanguage::ZhHant => { + format!("未知動作 `{action}`。將其作為最終回答處理。{fallback}") + } + PromptLanguage::Ko => { + format!("알 수 없는 작업 `{action}`입니다. 최종 답변으로 처리합니다. {fallback}") + } + PromptLanguage::En => { + format!("Unknown action `{action}`. Treating as final answer. {fallback}") + } + } +} + +pub fn agent_max_steps_message(language: &str) -> &'static str { + match resolve_prompt_language(language) { + PromptLanguage::ZhHans => "Agent 已达到最大步数限制,任务可能未完成。", + PromptLanguage::ZhHant => "Agent 已達到最大步數限制,任務可能尚未完成。", + PromptLanguage::Ko => { + "Agent가 최대 단계 수 제한에 도달했으며 작업이 완료되지 않았을 수 있습니다." + } + PromptLanguage::En => { + "Agent reached the maximum step limit, so the task may be incomplete." + } + } +} + +pub fn build_prompt(request: &AiChatRequest, settings: &AiSettings) -> String { + let ctx = &request.context; + let user_input = user_input_with_target_contexts(request); + match resolve_prompt_language(&request.options.language) { + PromptLanguage::ZhHans => { + let action = match request.action { + AiAction::GenerateCommand => "根据自然语言需求生成 1 到 2 条 Shell 命令", + AiAction::ExplainOutput => "解释最近终端输出并给出下一步建议", + AiAction::ExplainSelected => "解释用户选中的终端文本并给出下一步建议", + AiAction::AnalyzeError => "分析终端错误输出并给出排查步骤", + AiAction::RepairFromSelection => "根据选中内容生成修复或排查命令", + AiAction::CustomTerminalAction => "根据用户配置的终端 AI 功能处理选中内容", + AiAction::CustomFileAction => "根据用户配置的文件 AI 功能处理文件内容", + }; + format!( + r#"任务:{action} +用户需求: +{user_input} + +当前连接上下文: +- 连接名:{connection_name} +- 主机:{host} +- 端口:{port} +- 用户:{username} +- 当前目录:{cwd} +- 操作系统:{os} +- 架构:{arch} +- 当前输入:{input_buffer} + +选中文本: +{selected_text} + +最近终端输出(最多 {line_limit} 行): +{recent_output} + +要求: +- 语言:{language} +- 面向用户的说明和推理过程使用该语言;命令、路径、文件名、配置键名保持原样 +- 安全模式:{safety_mode} +- 最多生成 {max_commands} 条命令 +- 优先生成只读诊断命令 +- 如果信息不足,请给出验证命令 +- 必须返回 JSON 对象,不要返回 Markdown"#, + user_input = user_input, + connection_name = ctx.connection_name.as_deref().unwrap_or("-"), + host = ctx.host.as_deref().unwrap_or("-"), + port = ctx + .port + .map(|value| value.to_string()) + .unwrap_or_else(|| "-".to_string()), + username = ctx.username.as_deref().unwrap_or("-"), + cwd = ctx.cwd.as_deref().unwrap_or("-"), + os = ctx.os.as_deref().unwrap_or("-"), + arch = ctx.arch.as_deref().unwrap_or(std::env::consts::ARCH), + input_buffer = ctx.input_buffer, + selected_text = ctx.selected_text, + line_limit = settings.context_line_limit, + recent_output = ctx.recent_output, + language = request.options.language, + safety_mode = request.options.safety_mode, + max_commands = request.options.max_output_commands, + ) + } + PromptLanguage::ZhHant => { + let action = match request.action { + AiAction::GenerateCommand => "根據自然語言需求產生 1 到 2 個 Shell 命令", + AiAction::ExplainOutput => "解釋最近終端輸出並提供下一步建議", + AiAction::ExplainSelected => "解釋使用者選取的終端文字並提供下一步建議", + AiAction::AnalyzeError => "分析終端錯誤輸出並提供排查步驟", + AiAction::RepairFromSelection => "根據選取內容產生修復或排查命令", + AiAction::CustomTerminalAction => "根據使用者設定的終端 AI 功能處理選取內容", + AiAction::CustomFileAction => "根據使用者設定的檔案 AI 功能處理檔案內容", + }; + format!( + r#"任務:{action} +使用者需求: +{user_input} + +目前連線情境: +- 連線名稱:{connection_name} +- 主機:{host} +- 連接埠:{port} +- 使用者:{username} +- 目前目錄:{cwd} +- 作業系統:{os} +- 架構:{arch} +- 目前輸入:{input_buffer} + +選取文字: +{selected_text} + +最近終端輸出(最多 {line_limit} 行): +{recent_output} + +要求: +- 語言:{language} +- 面向使用者的說明和推理過程使用該語言;命令、路徑、檔名、設定鍵名保持原樣 +- 安全模式:{safety_mode} +- 最多產生 {max_commands} 條命令 +- 優先產生唯讀診斷命令 +- 如果資訊不足,請提供驗證命令 +- 必須回傳 JSON 物件,不要回傳 Markdown"#, + user_input = user_input, + connection_name = ctx.connection_name.as_deref().unwrap_or("-"), + host = ctx.host.as_deref().unwrap_or("-"), + port = ctx + .port + .map(|value| value.to_string()) + .unwrap_or_else(|| "-".to_string()), + username = ctx.username.as_deref().unwrap_or("-"), + cwd = ctx.cwd.as_deref().unwrap_or("-"), + os = ctx.os.as_deref().unwrap_or("-"), + arch = ctx.arch.as_deref().unwrap_or(std::env::consts::ARCH), + input_buffer = ctx.input_buffer, + selected_text = ctx.selected_text, + line_limit = settings.context_line_limit, + recent_output = ctx.recent_output, + language = request.options.language, + safety_mode = request.options.safety_mode, + max_commands = request.options.max_output_commands, + ) + } + PromptLanguage::Ko => { + let action = match request.action { + AiAction::GenerateCommand => "자연어 요청에서 Shell 명령 1~2개 생성", + AiAction::ExplainOutput => "최근 터미널 출력을 설명하고 다음 단계 제안", + AiAction::ExplainSelected => "선택한 터미널 텍스트를 설명하고 다음 단계 제안", + AiAction::AnalyzeError => "터미널 오류 출력을 분석하고 문제 해결 단계 제공", + AiAction::RepairFromSelection => "선택한 내용에서 복구 또는 문제 해결 명령 생성", + AiAction::CustomTerminalAction => { + "사용자가 구성한 터미널 AI 작업으로 선택한 내용 처리" + } + AiAction::CustomFileAction => "사용자가 구성한 파일 AI 작업으로 파일 내용 처리", + }; + format!( + r#"작업: {action} +사용자 요청: +{user_input} + +현재 연결 컨텍스트: +- 연결 이름: {connection_name} +- 호스트: {host} +- 포트: {port} +- 사용자: {username} +- 현재 디렉터리: {cwd} +- 운영 체제: {os} +- 아키텍처: {arch} +- 현재 입력: {input_buffer} + +선택한 텍스트: +{selected_text} + +최근 터미널 출력(최대 {line_limit}줄): +{recent_output} + +요구 사항: +- 대상 언어: {language} +- 사용자에게 보이는 설명과 추론에는 가능하면 해당 언어를 사용하세요. +- 명령, 경로, 파일 이름, 구성 키는 변경하지 말고 번역하지 마세요. +- 안전 모드: {safety_mode} +- 최대 {max_commands}개의 명령을 생성하세요. +- 읽기 전용 진단 명령을 먼저 우선하세요. +- 정보가 부족하면 확인 명령을 제공하세요. +- JSON 객체만 반환하세요. Markdown을 반환하지 마세요."#, + user_input = user_input, + connection_name = ctx.connection_name.as_deref().unwrap_or("-"), + host = ctx.host.as_deref().unwrap_or("-"), + port = ctx + .port + .map(|value| value.to_string()) + .unwrap_or_else(|| "-".to_string()), + username = ctx.username.as_deref().unwrap_or("-"), + cwd = ctx.cwd.as_deref().unwrap_or("-"), + os = ctx.os.as_deref().unwrap_or("-"), + arch = ctx.arch.as_deref().unwrap_or(std::env::consts::ARCH), + input_buffer = ctx.input_buffer, + selected_text = ctx.selected_text, + line_limit = settings.context_line_limit, + recent_output = ctx.recent_output, + language = request.options.language, + safety_mode = request.options.safety_mode, + max_commands = request.options.max_output_commands, + ) + } + PromptLanguage::En => { + let action = match request.action { + AiAction::GenerateCommand => { + "Generate 1 to 2 Shell commands from the natural language request" + } + AiAction::ExplainOutput => { + "Explain the recent terminal output and suggest the next step" + } + AiAction::ExplainSelected => { + "Explain the selected terminal text and suggest the next step" + } + AiAction::AnalyzeError => { + "Analyze the terminal error output and provide troubleshooting steps" + } + AiAction::RepairFromSelection => { + "Generate repair or troubleshooting commands from the selected content" + } + AiAction::CustomTerminalAction => { + "Handle the selected content using the configured terminal AI action" + } + AiAction::CustomFileAction => { + "Handle the file content using the configured file AI action" + } + }; + format!( + r#"Task: {action} +User request: +{user_input} + +Current connection context: +- Connection name: {connection_name} +- Host: {host} +- Port: {port} +- User: {username} +- Current directory: {cwd} +- Operating system: {os} +- Architecture: {arch} +- Current input: {input_buffer} + +Selected text: +{selected_text} + +Recent terminal output (up to {line_limit} lines): +{recent_output} + +Requirements: +- Target language: {language} +- Use that language for user-facing explanation and reasoning when possible. +- Keep commands, paths, file names, and configuration keys unchanged. +- Safety mode: {safety_mode} +- Generate at most {max_commands} commands. +- Prefer read-only diagnostic commands first. +- If information is insufficient, provide verification commands. +- Return a JSON object only. Do not return Markdown."#, + user_input = user_input, + connection_name = ctx.connection_name.as_deref().unwrap_or("-"), + host = ctx.host.as_deref().unwrap_or("-"), + port = ctx + .port + .map(|value| value.to_string()) + .unwrap_or_else(|| "-".to_string()), + username = ctx.username.as_deref().unwrap_or("-"), + cwd = ctx.cwd.as_deref().unwrap_or("-"), + os = ctx.os.as_deref().unwrap_or("-"), + arch = ctx.arch.as_deref().unwrap_or(std::env::consts::ARCH), + input_buffer = ctx.input_buffer, + selected_text = ctx.selected_text, + line_limit = settings.context_line_limit, + recent_output = ctx.recent_output, + language = request.options.language, + safety_mode = request.options.safety_mode, + max_commands = request.options.max_output_commands, + ) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::AiMode; + + use super::super::types::{AiContext, AiRequestOptions}; + + fn test_request(language: &str) -> AiChatRequest { + AiChatRequest { + stream_id: None, + session_id: None, + connection_id: None, + terminal_session_id: None, + owner_scope: Default::default(), + targets: vec![], + target_contexts: vec![], + mode: AiMode::Ask, + agent_kind: crate::config::AiAgentKind::Nyaterm, + permission_mode: crate::config::AiPermissionMode::Confirm, + model_id: None, + model_name: None, + default_target_session_id: None, + existing_external_session_id: None, + attachments: vec![], + action: AiAction::GenerateCommand, + user_input: "check disk usage".to_string(), + context: AiContext { + connection_name: Some("prod".to_string()), + host: Some("example.com".to_string()), + username: Some("ops".to_string()), + cwd: Some("/srv/app".to_string()), + os: Some("Linux".to_string()), + recent_output: "Filesystem Size Used Avail Use% Mounted on".to_string(), + ..AiContext::default() + }, + options: AiRequestOptions { + language: language.to_string(), + ..AiRequestOptions::default() + }, + } + } + + #[test] + fn resolves_chinese_locale_variants_to_writing_systems() { + let simplified_cases = ["zh", "zh-CN", "zh_CN", "zh-SG", "zh-Hans", "zh-Hans-CN"]; + for language in simplified_cases { + assert_eq!(resolve_prompt_language(language), PromptLanguage::ZhHans); + } + + let traditional_cases = ["zh-TW", "zh_TW", "zh-Hant", "zh-Hant-TW", "zh-HK", "zh-MO"]; + for language in traditional_cases { + assert_eq!(resolve_prompt_language(language), PromptLanguage::ZhHant); + } + + assert_eq!(resolve_prompt_language("zh-unknown"), PromptLanguage::En); + } + + #[test] + fn returns_traditional_chinese_system_prompts() { + assert!(system_prompt("zh-TW").contains("雲端原生終端助手")); + assert!(agent_system_prompt("zh-Hant").contains("終端自動化 Agent")); + } + + #[test] + fn builds_traditional_chinese_chat_and_agent_prompts() { + let settings = AiSettings::default(); + let request = test_request("zh-TW"); + + let prompt = build_prompt(&request, &settings); + assert!(prompt.contains("任務:根據自然語言需求產生")); + assert!(prompt.contains("目前連線情境:")); + assert!(prompt.contains("語言:zh-TW")); + + let agent_prompt = build_agent_prompt(&request, &settings); + assert!(agent_prompt.contains("使用者任務:")); + assert!(agent_prompt.contains("最近終端輸出")); + assert!(agent_prompt.contains("zh-TW")); + } + + #[test] + fn agent_prompts_require_non_interactive_commands() { + let settings = AiSettings::default(); + for (language, localized_rule) in [ + ("zh-CN", "不能等待用户输入"), + ("zh-TW", "不能等待使用者輸入"), + ("en", "must be non-interactive"), + ("ko", "비대화형이어야"), + ] { + let request = test_request(language); + let prompt = build_agent_prompt(&request, &settings); + + assert!(prompt.contains(localized_rule), "language={language}"); + assert!(prompt.contains("git --no-pager"), "language={language}"); + assert!( + prompt.contains("journalctl --no-pager"), + "language={language}" + ); + assert!(prompt.contains("final_answer"), "language={language}"); + } + } + + #[test] + fn builds_traditional_chinese_agent_runtime_messages() { + assert!(agent_send_only_observation("zh-TW").contains("僅傳送模式")); + assert!(agent_execution_disabled_message("zh-Hant").contains("已停用")); + assert!(build_agent_rejected_message("rm -rf tmp", "zh-TW").contains("拒絕執行")); + assert!(build_agent_failed_message("boom", "zh-TW").contains("下一步")); + assert!( + build_agent_unknown_action_message("noop", "fallback", "zh-TW").contains("fallback") + ); + assert!(agent_max_steps_message("zh-TW").contains("最大步數限制")); + } + + #[test] + fn resolves_korean_locale_variants() { + assert!(resolve_prompt_language("ko") == PromptLanguage::Ko); + assert!(resolve_prompt_language("ko-KR") == PromptLanguage::Ko); + assert!(resolve_prompt_language("ko_KR") == PromptLanguage::Ko); + } + + #[test] + fn returns_korean_system_prompts() { + assert!(system_prompt("ko").contains("터미널 어시스턴트")); + assert!(agent_system_prompt("ko").contains("터미널 자동화 Agent")); + } + + #[test] + fn builds_korean_chat_and_agent_prompts() { + let settings = AiSettings::default(); + let request = test_request("ko"); + + let prompt = build_prompt(&request, &settings); + assert!(prompt.contains("작업: 자연어 요청에서 Shell 명령")); + assert!(prompt.contains("현재 연결 컨텍스트:")); + assert!(prompt.contains("대상 언어: ko")); + + let agent_prompt = build_agent_prompt(&request, &settings); + assert!(agent_prompt.contains("사용자 작업:")); + assert!(agent_prompt.contains("최근 터미널 출력")); + assert!(agent_prompt.contains("ko")); + } + + #[test] + fn builds_korean_observation_message() { + let obs = CommandObservation { + output: "ok".to_string(), + exit_code: Some(0), + duration_ms: 42, + }; + + let message = build_observation_message(&obs, "ls", "ko-KR"); + assert!(message.contains("명령 `ls` 실행이 완료되었습니다")); + assert!(message.contains("출력:")); + assert!(message.contains("execute_command 또는 final_answer")); + } + + #[test] + fn safely_truncates_multibyte_observation_output() { + let obs = CommandObservation { + output: "你".repeat(3000), + exit_code: Some(0), + duration_ms: 42, + }; + + let message = build_observation_message(&obs, "cat file", "zh-CN"); + assert!(message.contains("...(truncated)\n你")); + assert!(message.contains("命令 `cat file` 执行完成")); + } + + #[test] + fn builds_korean_agent_runtime_messages() { + assert!(agent_send_only_observation("ko").contains("전송 전용 모드")); + assert!(agent_execution_disabled_message("ko-KR").contains("비활성화")); + assert!(build_agent_rejected_message("rm -rf tmp", "ko").contains("거부했습니다")); + assert!(build_agent_failed_message("boom", "ko").contains("다음 단계")); + assert!(build_agent_unknown_action_message("noop", "fallback", "ko").contains("fallback")); + assert!(agent_max_steps_message("ko").contains("최대 단계 수")); + } +} diff --git a/src-tauri/crates/nyaterm-core/src/core/ai/redaction.rs b/src-tauri/crates/nyaterm-core/src/core/ai/redaction.rs new file mode 100644 index 00000000..0a348af2 --- /dev/null +++ b/src-tauri/crates/nyaterm-core/src/core/ai/redaction.rs @@ -0,0 +1,113 @@ +use regex::Regex; +use std::sync::OnceLock; + +use super::types::AiContext; + +pub fn redact_context(context: &mut AiContext) { + context.recent_output = redact_sensitive_text(&context.recent_output); + context.selected_text = redact_sensitive_text(&context.selected_text); + context.input_buffer = redact_sensitive_text(&context.input_buffer); +} + +pub fn redact_sensitive_text(input: &str) -> String { + let mut output = input.to_string(); + for (pattern, replacement) in redaction_patterns() { + output = pattern.replace_all(&output, *replacement).to_string(); + } + output +} + +pub fn redact_marker_values(input: &str, markers: &[&str]) -> String { + let mut output = input.to_string(); + for marker in markers.iter().copied().filter(|marker| !marker.is_empty()) { + let mut search_start = 0; + while let Some(relative_index) = output[search_start..].find(marker) { + let marker_index = search_start + relative_index; + let value_start = marker_index + marker.len(); + let value_end = output[value_start..] + .find(['&', ' ', '"']) + .map(|offset| value_start + offset) + .unwrap_or(output.len()); + output.replace_range(value_start..value_end, "[redacted]"); + search_start = value_start + "[redacted]".len(); + } + } + output +} + +fn redaction_patterns() -> &'static [(Regex, &'static str)] { + static PATTERNS: OnceLock> = OnceLock::new(); + PATTERNS.get_or_init(|| { + vec![ + ( + Regex::new( + r"-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----", + ) + .unwrap(), + "[REDACTED_PRIVATE_KEY]", + ), + ( + Regex::new(r"(?i)Authorization:\s*Bearer\s+[A-Za-z0-9._\-]+").unwrap(), + "Authorization: Bearer [REDACTED]", + ), + ( + Regex::new(r"(?i)(password|passwd|pwd)\s*[:=]\s*[^\s;&|]+").unwrap(), + "$1=[REDACTED]", + ), + ( + Regex::new( + r"(?i)(token|api[_-]?key|secret[_-]?key|access[_-]?key)\s*[:=]\s*[^\s;&|]+", + ) + .unwrap(), + "$1=[REDACTED]", + ), + ( + Regex::new(r"AKIA[0-9A-Z]{16}").unwrap(), + "[REDACTED_AWS_ACCESS_KEY]", + ), + ( + Regex::new(r"(?i)(postgres|mysql|mongodb)://[^@\s]+@").unwrap(), + "$1://[REDACTED]@", + ), + ] + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn redacts_sensitive_values() { + let raw = "password=secret token:abc Authorization: Bearer abc.def AKIA1234567890ABCDEF"; + let redacted = redact_sensitive_text(raw); + assert!(!redacted.contains("secret")); + assert!(!redacted.contains("abc.def")); + assert!(!redacted.contains("AKIA1234567890ABCDEF")); + } + + #[test] + fn redacts_marker_values_without_revisiting_redacted_markers() { + let raw = "access_token=abc access_token=[redacted] code=xyz&state=ok refresh_token=end"; + let redacted = redact_marker_values(raw, &["access_token=", "code=", "refresh_token="]); + + assert_eq!( + redacted, + "access_token=[redacted] access_token=[redacted] code=[redacted]&state=ok refresh_token=[redacted]" + ); + assert!(!redacted.contains("abc")); + assert!(!redacted.contains("code=xyz")); + assert!(!redacted.contains("refresh_token=end")); + } + + #[test] + fn redacts_marker_values_until_space_quote_ampersand_or_line_end() { + let raw = r#"api_key=one next="api_key=two" code=three&state=ok id_token=four"#; + let redacted = redact_marker_values(raw, &["api_key=", "code=", "id_token="]); + + assert_eq!( + redacted, + r#"api_key=[redacted] next="api_key=[redacted]" code=[redacted]&state=ok id_token=[redacted]"# + ); + } +} diff --git a/src-tauri/crates/nyaterm-core/src/core/ai/responses.rs b/src-tauri/crates/nyaterm-core/src/core/ai/responses.rs new file mode 100644 index 00000000..94ee58cd --- /dev/null +++ b/src-tauri/crates/nyaterm-core/src/core/ai/responses.rs @@ -0,0 +1,721 @@ +use std::time::Duration; + +use super::stream::AiEventSink; +use genai::chat::{ChatMessage, ChatRole}; +use serde::Serialize; +use serde_json::{Value, json}; +use tokio::sync::oneshot; + +use crate::config::{ + AiApiFormat, AiProviderApiProtocol, AiProviderKind, AiReasoningEffort, AiSettings, +}; +use crate::error::{AppError, AppResult}; +use crate::utils::url::{join_api_base_url, normalize_api_base_url}; + +use super::http::build_http_client; +use super::model::ResolvedAiModel; +use super::parser::{trim_string_to_option, truncate_preview}; +use super::stream::{AiStreamResult, emit_stream_event}; +use super::types::{AiChatRequest, AiStreamEventPayload}; + +const OPENAI_DEFAULT_BASE_URL: &str = "https://api.openai.com/v1/"; + +#[derive(Debug, Clone, Serialize)] +struct ResponsesInputMessage { + role: &'static str, + content: String, +} + +#[derive(Debug)] +enum ResponsesSseEvent { + TextDelta(String), + ReasoningDelta(String), + Completed { + text: Option, + reasoning: Option, + }, + Error(String), + Ignored, +} + +pub fn uses_responses_api(model: &ResolvedAiModel) -> bool { + if model.api_format != AiApiFormat::Responses { + return false; + } + + match model + .credential + .as_ref() + .and_then(|credential| credential.api_protocol.as_ref()) + { + Some(AiProviderApiProtocol::OpenaiCompatible) => true, + Some(_) => false, + None => matches!( + model.provider_kind, + AiProviderKind::Openai | AiProviderKind::OpenaiCompatible + ), + } +} + +pub async fn test_responses_model( + settings: &AiSettings, + model: &ResolvedAiModel, + system_prompt: &str, + user_prompt: &str, + max_output_tokens: u32, +) -> AppResult<()> { + let mut body = json!({ + "model": model.model_name, + "input": [ + { "role": "system", "content": system_prompt }, + { "role": "user", "content": user_prompt }, + ], + "max_output_tokens": max_output_tokens, + "stream": false, + "store": false, + }); + if let Some(effort) = responses_reasoning_effort(&settings.default_reasoning_effort) { + body["reasoning"] = json!({ "effort": effort }); + } + + let response = responses_request(settings, model, &body)? + .send() + .await + .map_err(|error| AppError::Config(format!("AI model test failed: {error}")))?; + if !response.status().is_success() { + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + return Err(AppError::Config(format!( + "AI model test failed: {status} {}", + truncate_preview(&body, 500) + ))); + } + let body: Value = response + .json() + .await + .map_err(|error| AppError::Config(format!("Invalid AI model test response: {error}")))?; + if let Some(error) = body.get("error").filter(|error| !error.is_null()) { + let message = error + .get("message") + .and_then(Value::as_str) + .map(str::to_string) + .unwrap_or_else(|| error.to_string()); + return Err(AppError::Config(format!("AI model test failed: {message}"))); + } + if matches!(body["status"].as_str(), Some("failed" | "cancelled")) { + return Err(AppError::Config("AI model test failed".to_string())); + } + if body["id"].as_str().is_none() && body["output"].as_array().is_none() { + return Err(AppError::Config( + "AI model test returned an invalid response".to_string(), + )); + } + Ok(()) +} + +pub async fn run_responses_chat_messages_stream( + app: &impl AiEventSink, + stream_id: &str, + request: &AiChatRequest, + settings: &AiSettings, + resolved_model: &ResolvedAiModel, + messages: &[ChatMessage], + cancel_rx: &mut oneshot::Receiver<()>, +) -> AppResult { + let input = responses_input_messages(messages)?; + run_responses_input_stream( + app, + stream_id, + request, + settings, + resolved_model, + input, + true, + cancel_rx, + ) + .await +} + +pub async fn run_responses_chat_messages_stream_without_text_deltas( + app: &impl AiEventSink, + stream_id: &str, + request: &AiChatRequest, + settings: &AiSettings, + resolved_model: &ResolvedAiModel, + messages: &[ChatMessage], + cancel_rx: &mut oneshot::Receiver<()>, +) -> AppResult { + let input = responses_input_messages(messages)?; + run_responses_input_stream( + app, + stream_id, + request, + settings, + resolved_model, + input, + false, + cancel_rx, + ) + .await +} + +fn responses_input_messages(messages: &[ChatMessage]) -> AppResult> { + let mut input = Vec::new(); + + for message in messages { + let role = match message.role { + ChatRole::System => "system", + ChatRole::User => "user", + ChatRole::Assistant => "assistant", + ChatRole::Tool => continue, + }; + let content = message.content.joined_texts().unwrap_or_default(); + if content.trim().is_empty() { + continue; + } + input.push(ResponsesInputMessage { role, content }); + } + + if input.is_empty() { + return Err(AppError::Config( + "Responses API request has no text input".to_string(), + )); + } + + Ok(input) +} + +async fn run_responses_input_stream( + app: &impl AiEventSink, + stream_id: &str, + request: &AiChatRequest, + settings: &AiSettings, + resolved_model: &ResolvedAiModel, + input: Vec, + emit_text_deltas: bool, + cancel_rx: &mut oneshot::Receiver<()>, +) -> AppResult { + let url = responses_url(resolved_model)?; + + let mut body = json!({ + "model": resolved_model.model_name, + "input": input, + "stream": true, + "store": false, + }); + if let Some(effort) = responses_reasoning_effort(&settings.default_reasoning_effort) { + body["reasoning"] = json!({ "effort": effort }); + } + + let req = responses_request(settings, resolved_model, &body)?; + + tracing::debug!( + stream_id = %stream_id, + model_name = %resolved_model.model_name, + provider_kind = ?resolved_model.provider_kind, + url = %url, + "Dispatching Responses API stream request" + ); + + let send_future = req + .timeout(Duration::from_millis(settings.timeout_ms)) + .send(); + let mut resp = tokio::select! { + _ = &mut *cancel_rx => { + return Err(AppError::Cancelled("AI stream cancelled".to_string())); + } + result = send_future => { + result.map_err(|error| AppError::Config(format!("AI request failed: {error}")))? + } + }; + + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + return Err(AppError::Config(format!( + "Responses API request failed: {status} {body}" + ))); + } + + let mut output = String::new(); + let mut reasoning_output = String::new(); + let mut sse_buffer = String::new(); + let idle_duration = Duration::from_millis(settings.timeout_ms); + let idle_deadline = tokio::time::sleep(idle_duration); + tokio::pin!(idle_deadline); + + loop { + tokio::select! { + _ = &mut idle_deadline => { + return Err(AppError::Config("AI stream timed out (no data received)".to_string())); + } + _ = &mut *cancel_rx => { + return Err(AppError::Cancelled("AI stream cancelled".to_string())); + } + chunk = resp.chunk() => { + idle_deadline.as_mut().reset(tokio::time::Instant::now() + idle_duration); + let Some(chunk) = chunk + .map_err(|error| AppError::Config(format!("AI stream failed: {error}")))? + else { + break; + }; + sse_buffer.push_str(&String::from_utf8_lossy(&chunk)); + process_sse_buffer( + app, + stream_id, + request, + &mut sse_buffer, + &mut output, + &mut reasoning_output, + emit_text_deltas, + )?; + } + } + } + + if !sse_buffer.trim().is_empty() { + process_sse_block_into_output( + app, + stream_id, + request, + sse_buffer.trim(), + &mut output, + &mut reasoning_output, + emit_text_deltas, + )?; + } + + tracing::info!( + stream_id = %stream_id, + text_len = output.len(), + reasoning_len = reasoning_output.len(), + text_preview = %truncate_preview(&output, 200), + reasoning_preview = %truncate_preview(&reasoning_output, 200), + "Responses API stream completed" + ); + + Ok(AiStreamResult { + text: output, + reasoning_content: trim_string_to_option(reasoning_output), + }) +} + +fn process_sse_buffer( + app: &impl AiEventSink, + stream_id: &str, + request: &AiChatRequest, + buffer: &mut String, + output: &mut String, + reasoning_output: &mut String, + emit_text_deltas: bool, +) -> AppResult<()> { + while let Some((index, separator_len)) = find_sse_separator(buffer) { + let block = buffer[..index].trim().to_string(); + buffer.drain(..index + separator_len); + if !block.is_empty() { + process_sse_block_into_output( + app, + stream_id, + request, + &block, + output, + reasoning_output, + emit_text_deltas, + )?; + } + } + Ok(()) +} + +fn find_sse_separator(buffer: &str) -> Option<(usize, usize)> { + let lf = buffer.find("\n\n").map(|index| (index, 2)); + let crlf = buffer.find("\r\n\r\n").map(|index| (index, 4)); + + match (lf, crlf) { + (Some(left), Some(right)) => Some(if left.0 <= right.0 { left } else { right }), + (Some(found), None) | (None, Some(found)) => Some(found), + (None, None) => None, + } +} + +fn process_sse_block_into_output( + app: &impl AiEventSink, + stream_id: &str, + request: &AiChatRequest, + block: &str, + output: &mut String, + reasoning_output: &mut String, + emit_text_deltas: bool, +) -> AppResult<()> { + match parse_responses_sse_block(block)? { + ResponsesSseEvent::TextDelta(text_delta) => { + if !text_delta.is_empty() { + output.push_str(&text_delta); + if emit_text_deltas { + emit_stream_event( + app, + stream_id, + AiStreamEventPayload { + event_type: "delta".to_string(), + stream_id: stream_id.to_string(), + session_id: request.session_id.clone(), + text_delta: Some(text_delta), + reasoning_delta: None, + message: None, + command_cards: vec![], + usage: None, + error: None, + }, + ); + } + } + } + ResponsesSseEvent::ReasoningDelta(reasoning_delta) => { + if !reasoning_delta.is_empty() { + reasoning_output.push_str(&reasoning_delta); + emit_stream_event( + app, + stream_id, + AiStreamEventPayload { + event_type: "reasoning_delta".to_string(), + stream_id: stream_id.to_string(), + session_id: request.session_id.clone(), + text_delta: None, + reasoning_delta: Some(reasoning_delta), + message: None, + command_cards: vec![], + usage: None, + error: None, + }, + ); + } + } + ResponsesSseEvent::Completed { text, reasoning } => { + if output.is_empty() + && let Some(text) = text + { + output.push_str(&text); + } + if reasoning_output.is_empty() + && let Some(reasoning) = reasoning + { + reasoning_output.push_str(&reasoning); + } + } + ResponsesSseEvent::Error(message) => { + return Err(AppError::Config(format!( + "Responses API stream failed: {message}" + ))); + } + ResponsesSseEvent::Ignored => {} + } + + Ok(()) +} + +fn parse_responses_sse_block(block: &str) -> AppResult { + let mut event_type: Option<&str> = None; + let mut data_lines = Vec::new(); + + for line in block.lines() { + let line = line.trim_end_matches('\r'); + if let Some(value) = line.strip_prefix("event:") { + event_type = Some(value.trim()); + } else if let Some(value) = line.strip_prefix("data:") { + data_lines.push(value.trim_start()); + } + } + + let data = data_lines.join("\n"); + if data.is_empty() || data == "[DONE]" { + return Ok(ResponsesSseEvent::Ignored); + } + + let value: Value = serde_json::from_str(&data) + .map_err(|error| AppError::Config(format!("Invalid Responses API stream JSON: {error}")))?; + let kind = value + .get("type") + .and_then(Value::as_str) + .or(event_type) + .unwrap_or_default(); + + if kind == "error" { + return Ok(ResponsesSseEvent::Error(error_message(&value))); + } + + if kind == "response.output_text.delta" { + return Ok(ResponsesSseEvent::TextDelta( + value + .get("delta") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(), + )); + } + + if kind.ends_with(".delta") && kind.contains("reasoning") { + return Ok(ResponsesSseEvent::ReasoningDelta( + value + .get("delta") + .or_else(|| value.get("text")) + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(), + )); + } + + if kind == "response.completed" { + let response = value.get("response").unwrap_or(&value); + return Ok(ResponsesSseEvent::Completed { + text: completed_output_text(response), + reasoning: completed_reasoning_text(response), + }); + } + + Ok(ResponsesSseEvent::Ignored) +} + +fn error_message(value: &Value) -> String { + value + .get("error") + .and_then(|error| { + error + .get("message") + .or_else(|| error.get("type")) + .and_then(Value::as_str) + }) + .or_else(|| value.get("message").and_then(Value::as_str)) + .unwrap_or("Unknown Responses API error") + .to_string() +} + +fn completed_output_text(response: &Value) -> Option { + let mut text = String::new(); + for item in response.get("output")?.as_array()? { + if item.get("type").and_then(Value::as_str) != Some("message") { + continue; + } + let Some(content) = item.get("content").and_then(Value::as_array) else { + continue; + }; + for part in content { + if part.get("type").and_then(Value::as_str) == Some("output_text") + && let Some(part_text) = part.get("text").and_then(Value::as_str) + { + text.push_str(part_text); + } + } + } + trim_string_to_option(text) +} + +fn completed_reasoning_text(response: &Value) -> Option { + let mut text = String::new(); + for item in response.get("output")?.as_array()? { + if item.get("type").and_then(Value::as_str) != Some("reasoning") { + continue; + } + for key in ["summary", "content"] { + if let Some(parts) = item.get(key).and_then(Value::as_array) { + for part in parts { + if let Some(part_text) = part + .get("text") + .or_else(|| part.get("summary")) + .and_then(Value::as_str) + { + if !text.is_empty() { + text.push('\n'); + } + text.push_str(part_text); + } + } + } + } + } + trim_string_to_option(text) +} + +fn responses_reasoning_effort(value: &AiReasoningEffort) -> Option<&'static str> { + match value { + AiReasoningEffort::Auto => None, + AiReasoningEffort::None => Some("none"), + AiReasoningEffort::Minimal => Some("minimal"), + AiReasoningEffort::Low => Some("low"), + AiReasoningEffort::Medium => Some("medium"), + AiReasoningEffort::High => Some("high"), + AiReasoningEffort::XHigh => Some("xhigh"), + AiReasoningEffort::Max => Some("max"), + AiReasoningEffort::Ultra => Some("ultra"), + } +} + +pub fn responses_request( + settings: &AiSettings, + model: &ResolvedAiModel, + body: &Value, +) -> AppResult { + let mut request = build_http_client(settings)? + .post(responses_url(model)?) + .json(body); + if let Some(key) = model + .credential + .as_ref() + .and_then(|credential| credential.api_key.as_deref()) + .map(str::trim) + .filter(|value| !value.is_empty()) + { + request = request.bearer_auth(key); + } + Ok(request) +} + +fn responses_url(model: &ResolvedAiModel) -> AppResult { + let base_url = responses_base_url(model)?; + join_api_base_url(&base_url, "responses") +} + +fn responses_base_url(model: &ResolvedAiModel) -> AppResult { + let configured = model + .credential + .as_ref() + .and_then(|credential| credential.base_url.as_deref()) + .map(str::trim) + .filter(|value| !value.is_empty()); + + match (configured, &model.provider_kind) { + (Some(base_url), _) => normalize_api_base_url(base_url), + (None, AiProviderKind::Openai) => Ok(OPENAI_DEFAULT_BASE_URL.to_string()), + (None, AiProviderKind::OpenaiCompatible) => Err(AppError::Config( + "Responses API requires an API base URL for OpenAI-compatible credentials".to_string(), + )), + (None, provider_kind) => Err(AppError::Config(format!( + "Responses API requires an API base URL for {provider_kind:?}" + ))), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::{AiProviderCredential, AiProviderKind}; + + fn resolved_model(base_url: Option<&str>) -> ResolvedAiModel { + ResolvedAiModel { + model_name: "gpt-test".to_string(), + provider_kind: AiProviderKind::OpenaiCompatible, + api_format: AiApiFormat::Responses, + credential: Some(AiProviderCredential { + id: "credential-test".to_string(), + name: "Test".to_string(), + provider_kind: AiProviderKind::OpenaiCompatible, + icon_data_url: None, + api_protocol: None, + api_format: AiApiFormat::Responses, + base_url: base_url.map(str::to_string), + api_key: Some("key".to_string()), + enabled: true, + }), + } + } + + #[test] + fn responses_url_accepts_missing_trailing_slash() { + assert_eq!( + responses_url(&resolved_model(Some("https://api.example.com/v1"))).unwrap(), + "https://api.example.com/v1/responses" + ); + } + + #[test] + fn responses_url_accepts_trailing_slash() { + assert_eq!( + responses_url(&resolved_model(Some("https://api.example.com/v1/"))).unwrap(), + "https://api.example.com/v1/responses" + ); + } + + #[test] + fn responses_url_preserves_query() { + assert_eq!( + responses_url(&resolved_model(Some( + "https://api.example.com/v1?api-version=1" + ))) + .unwrap(), + "https://api.example.com/v1/responses?api-version=1" + ); + } + + #[test] + fn parses_text_delta_event() { + let event = parse_responses_sse_block( + r#"event: response.output_text.delta +data: {"type":"response.output_text.delta","delta":"hello"}"#, + ) + .unwrap(); + + assert!(matches!(event, ResponsesSseEvent::TextDelta(ref text) if text == "hello")); + } + + #[test] + fn parses_reasoning_delta_event() { + let event = parse_responses_sse_block( + r#"data: {"type":"response.reasoning_summary_text.delta","delta":"thinking"}"#, + ) + .unwrap(); + + assert!(matches!(event, ResponsesSseEvent::ReasoningDelta(ref text) if text == "thinking")); + } + + #[test] + fn parses_completed_event_text() { + let event = parse_responses_sse_block( + r#"data: {"type":"response.completed","response":{"output":[{"type":"message","content":[{"type":"output_text","text":"done"}]}]}}"#, + ) + .unwrap(); + + assert!(matches!( + event, + ResponsesSseEvent::Completed { text: Some(ref text), .. } if text == "done" + )); + } + + #[test] + fn parses_error_event() { + let event = parse_responses_sse_block( + r#"event: error +data: {"type":"error","error":{"message":"bad request"}}"#, + ) + .unwrap(); + + assert!(matches!(event, ResponsesSseEvent::Error(ref message) if message == "bad request")); + } + + #[test] + fn rejects_malformed_json() { + let error = parse_responses_sse_block("data: {not-json").unwrap_err(); + + assert!( + error + .to_string() + .contains("Invalid Responses API stream JSON") + ); + } + + #[test] + fn finds_crlf_sse_separator() { + assert_eq!(find_sse_separator("data: {}\r\n\r\nnext"), Some((8, 4))); + } + + #[test] + fn routes_only_openai_protocol_responses_credentials() { + let mut model = resolved_model(Some("https://api.example.com/v1/")); + assert!(uses_responses_api(&model)); + + model.provider_kind = AiProviderKind::Anthropic; + assert!(!uses_responses_api(&model)); + + model.provider_kind = AiProviderKind::Openai; + model.api_format = AiApiFormat::ChatCompletions; + assert!(!uses_responses_api(&model)); + } +} diff --git a/src-tauri/crates/nyaterm-core/src/core/ai/stream.rs b/src-tauri/crates/nyaterm-core/src/core/ai/stream.rs new file mode 100644 index 00000000..707743f3 --- /dev/null +++ b/src-tauri/crates/nyaterm-core/src/core/ai/stream.rs @@ -0,0 +1,200 @@ +use super::{ + history::load_history, + model::{build_chat_options, build_client, resolve_request_model}, + parser::{extract_text_from_assistant, trim_string_to_option}, + prompt::{build_prompt, system_prompt}, + types::{AiChatRequest, AiMessage, AiMessageRole, AiStreamEventPayload}, +}; +use crate::config::AiSettings; +use crate::error::{AppError, AppResult}; +use futures_util::StreamExt; +use genai::chat::{ChatMessage, ChatRequest, ChatStreamEvent}; +use std::time::Duration; +use tokio::sync::oneshot; + +pub trait AiEventSink: Send + Sync { + fn emit(&self, stream_id: &str, payload: AiStreamEventPayload); +} +pub struct EventSink(pub F); +impl AiEventSink for EventSink { + fn emit(&self, id: &str, payload: AiStreamEventPayload) { + (self.0)(id, payload) + } +} +pub fn emit_stream_event(sink: &impl AiEventSink, id: &str, payload: AiStreamEventPayload) { + sink.emit(id, payload) +} + +#[derive(Debug, Clone)] +pub struct AiStreamResult { + pub text: String, + pub reasoning_content: Option, +} + +pub async fn run_model_stream( + app: &impl AiEventSink, + stream_id: &str, + request: &AiChatRequest, + settings: &AiSettings, + cancel_rx: &mut oneshot::Receiver<()>, +) -> AppResult { + tracing::debug!( + stream_id = %stream_id, + action = ?request.action, + session_id = ?request.session_id, + "Preparing AI model stream" + ); + + let resolved_model = resolve_request_model(settings, request)?; + let prompt = build_prompt(request, settings); + + let mut messages = vec![ChatMessage::system(system_prompt( + &request.options.language, + ))]; + + if let Some(session_id) = &request.session_id { + let max_turns = request.options.history_turns as usize; + if max_turns > 0 { + if let Ok(history) = load_history(app) { + let history_msgs: Vec<&AiMessage> = history + .messages + .iter() + .filter(|m| m.session_id == *session_id) + .collect(); + let skip = history_msgs.len().saturating_sub(max_turns); + for msg in history_msgs.into_iter().skip(skip) { + match msg.role { + AiMessageRole::User => { + messages.push(ChatMessage::user(&msg.content)); + } + AiMessageRole::Assistant => { + let content = extract_text_from_assistant(&msg.content); + if !content.is_empty() { + messages.push(ChatMessage::assistant(&content)); + } + } + AiMessageRole::System => {} + } + } + } + } + } + + messages.push(ChatMessage::user(prompt)); + + if super::responses::uses_responses_api(&resolved_model) { + return super::responses::run_responses_chat_messages_stream( + app, + stream_id, + request, + settings, + &resolved_model, + &messages, + cancel_rx, + ) + .await; + } + + let client = build_client(&resolved_model, settings)?; + + tracing::debug!( + stream_id = %stream_id, + message_count = messages.len(), + model_name = %resolved_model.model_name, + provider_kind = ?resolved_model.provider_kind, + "Dispatching AI model stream request" + ); + + let chat_req = ChatRequest::new(messages); + let chat_options = build_chat_options(settings); + + let stream_result = tokio::time::timeout( + Duration::from_millis(settings.timeout_ms), + client.exec_chat_stream(&resolved_model.model_name, chat_req, Some(&chat_options)), + ) + .await + .map_err(|_| AppError::Config("AI request timed out".to_string()))? + .map_err(|error| AppError::Config(format!("AI request failed: {error}")))?; + + let mut stream = stream_result.stream; + let mut output = String::new(); + let mut reasoning_output = String::new(); + let idle_duration = Duration::from_millis(settings.timeout_ms); + let idle_deadline = tokio::time::sleep(idle_duration); + tokio::pin!(idle_deadline); + + loop { + tokio::select! { + _ = &mut idle_deadline => { + return Err(AppError::Config("AI stream timed out (no data received)".to_string())); + } + _ = &mut *cancel_rx => { + return Err(AppError::Cancelled("AI stream cancelled".to_string())); + } + item = stream.next() => { + idle_deadline.as_mut().reset(tokio::time::Instant::now() + idle_duration); + match item { + Some(Ok(ChatStreamEvent::Chunk(chunk))) => { + let text_delta = chunk.content; + if !text_delta.is_empty() { + output.push_str(&text_delta); + emit_stream_event(app, stream_id, AiStreamEventPayload { + event_type: "delta".to_string(), + stream_id: stream_id.to_string(), + session_id: request.session_id.clone(), + text_delta: Some(text_delta), + reasoning_delta: None, + message: None, + command_cards: vec![], + usage: None, + error: None, + }); + } + } + Some(Ok(ChatStreamEvent::ReasoningChunk(chunk))) => { + let reasoning_delta = chunk.content; + if !reasoning_delta.is_empty() { + reasoning_output.push_str(&reasoning_delta); + emit_stream_event(app, stream_id, AiStreamEventPayload { + event_type: "reasoning_delta".to_string(), + stream_id: stream_id.to_string(), + session_id: request.session_id.clone(), + text_delta: None, + reasoning_delta: Some(reasoning_delta), + message: None, + command_cards: vec![], + usage: None, + error: None, + }); + } + } + Some(Ok(ChatStreamEvent::End(end))) => { + if reasoning_output.is_empty() { + if let Some(captured_reasoning_content) = end.captured_reasoning_content { + reasoning_output = captured_reasoning_content; + } + } + break; + } + None => break, + Some(Ok(_)) => {} + Some(Err(error)) => { + return Err(AppError::Config(format!("AI stream failed: {error}"))); + } + } + } + } + } + + tracing::info!( + stream_id = %stream_id, + text_len = output.len(), + reasoning_len = reasoning_output.len(), + "AI model stream completed" + ); + + Ok(AiStreamResult { + text: output, + reasoning_content: trim_string_to_option(reasoning_output), + }) +} diff --git a/src-tauri/crates/nyaterm-core/src/core/ai/types.rs b/src-tauri/crates/nyaterm-core/src/core/ai/types.rs new file mode 100644 index 00000000..665e6a56 --- /dev/null +++ b/src-tauri/crates/nyaterm-core/src/core/ai/types.rs @@ -0,0 +1,553 @@ +use crate::config::{ + AiAgentKind, AiBackendKind, AiMode, AiModelSource, AiPermissionMode, AiProviderKind, RiskLevel, +}; +use crate::core::capabilities::RiskReasonCode; +use serde::de::Deserializer; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum AiSessionScopeType { + Terminal, + Workspace, + Global, + Unbound, +} + +impl Default for AiSessionScopeType { + fn default() -> Self { + Self::Unbound + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct AiSessionScope { + #[serde(default)] + pub r#type: AiSessionScopeType, + #[serde(default)] + pub target_id: Option, + #[serde(default)] + pub connection_ids: Vec, + #[serde(default)] + pub label: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct AiTerminalTarget { + pub terminal_session_id: String, + #[serde(default)] + pub connection_id: Option, + pub label: String, + #[serde(default)] + pub host: Option, + #[serde(default)] + pub username: Option, + pub session_type: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct AiTargetContext { + pub target: Option, + #[serde(default)] + pub context: AiContext, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct AiAttachment { + pub id: String, + pub name: String, + #[serde(default)] + pub path: Option, + #[serde(default)] + pub mime_type: Option, + #[serde(default)] + pub size_bytes: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AiCommandCard { + pub id: String, + pub title: String, + pub command: String, + pub explanation: String, + #[serde(default)] + pub risk_level: Option, + #[serde(default)] + pub risk_reason: Option, + pub expected_effect: String, + #[serde(default)] + pub rollback: Option, + #[serde(default)] + pub category: Option, + #[serde(default)] + pub references: Vec, + #[serde(default)] + pub target_terminal_session_id: Option, + #[serde(default)] + pub target: Option, +} + +// --------------------------------------------------------------------------- +// Agent (ReAct) types +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AgentActionKind { + ExecuteCommand, + FinalAnswer, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum AgentApprovalReasonCode { + ConfirmEachCommand, + CriticalRisk, + RiskExceedsThreshold, + ExternalAgentPermission, + SafeAutoUnknownOrHighRisk, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentStepAction { + pub kind: AgentActionKind, + #[serde(default)] + pub command: Option, + #[serde(default)] + pub target: Option, + #[serde(default)] + pub risk_level: Option, + #[serde(default)] + pub model_risk_level: Option, + #[serde(default)] + pub local_risk_level: Option, + #[serde(default)] + pub model_risk_reason: Option, + #[serde(default)] + pub local_risk_reason_code: Option, + #[serde(default)] + pub approval_reason_code: Option, + #[serde(default)] + pub answer: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CommandObservation { + pub output: String, + #[serde(default)] + pub exit_code: Option, + pub duration_ms: u64, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum AgentStepStatus { + Running, + Completed, + NeedsApproval, + Rejected, + Failed, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentStepPayload { + pub stream_id: String, + #[serde(default)] + pub session_id: Option, + pub step_index: u16, + pub thought: String, + pub action: AgentStepAction, + #[serde(default)] + pub observation: Option, + pub status: AgentStepStatus, + #[serde(default)] + pub error: Option, +} + +/// Parsed single-step agent response from the LLM. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +#[allow(dead_code)] +pub struct AgentLlmResponse { + #[serde(default)] + pub thought: String, + pub action: String, + #[serde(default)] + pub command: Option, + #[serde(default)] + pub target_terminal_session_id: Option, + #[serde(default, deserialize_with = "deserialize_optional_risk_level")] + pub risk_level: Option, + #[serde(default)] + pub risk_reason: Option, + #[serde(default)] + pub answer: Option, +} + +fn deserialize_optional_risk_level<'de, D>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + let value = Option::::deserialize(deserializer)?; + Ok(value.and_then(|raw| parse_risk_level_label(&raw))) +} + +pub fn parse_risk_level_label(value: &str) -> Option { + match value.trim().replace('-', "_").to_ascii_lowercase().as_str() { + "low" => Some(RiskLevel::Low), + "medium" | "moderate" => Some(RiskLevel::Medium), + "high" => Some(RiskLevel::High), + "critical" | "danger" | "dangerous" => Some(RiskLevel::Critical), + _ => None, + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct AiContext { + #[serde(default)] + pub connection_name: Option, + #[serde(default)] + pub host: Option, + #[serde(default)] + pub port: Option, + #[serde(default)] + pub username: Option, + #[serde(default)] + pub cwd: Option, + #[serde(default)] + pub os: Option, + #[serde(default)] + pub arch: Option, + #[serde(default)] + pub recent_output: String, + #[serde(default)] + pub selected_text: String, + #[serde(default)] + pub input_buffer: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AiAction { + GenerateCommand, + ExplainOutput, + ExplainSelected, + AnalyzeError, + RepairFromSelection, + CustomTerminalAction, + CustomFileAction, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct AiRequestOptions { + #[serde(default = "default_max_output_commands")] + pub max_output_commands: u8, + #[serde(default = "default_language")] + pub language: String, + #[serde(default = "default_safety_mode")] + pub safety_mode: String, + #[serde(default = "default_history_turns")] + pub history_turns: u16, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AiChatRequest { + #[serde(default)] + pub stream_id: Option, + #[serde(default)] + pub session_id: Option, + #[serde(default)] + pub connection_id: Option, + /// The terminal session id to execute commands on (Agent mode). + #[serde(default)] + pub terminal_session_id: Option, + #[serde(default)] + pub owner_scope: AiSessionScope, + #[serde(default)] + pub targets: Vec, + #[serde(default)] + pub target_contexts: Vec, + #[serde(default = "default_mode")] + pub mode: AiMode, + #[serde(default)] + pub agent_kind: AiAgentKind, + #[serde(default)] + pub permission_mode: AiPermissionMode, + #[serde(default)] + pub model_id: Option, + #[serde(default)] + pub model_name: Option, + #[serde(default)] + pub default_target_session_id: Option, + #[serde(default)] + pub existing_external_session_id: Option, + #[serde(default)] + pub attachments: Vec, + pub action: AiAction, + pub user_input: String, + #[serde(default)] + pub context: AiContext, + #[serde(default)] + pub options: AiRequestOptions, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AiStreamStart { + pub stream_id: String, + pub session_id: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AiStreamEventPayload { + #[serde(rename = "type")] + pub event_type: String, + pub stream_id: String, + #[serde(default)] + pub session_id: Option, + #[serde(default)] + pub text_delta: Option, + #[serde(default)] + pub reasoning_delta: Option, + #[serde(default)] + pub message: Option, + #[serde(default)] + pub command_cards: Vec, + #[serde(default)] + pub usage: Option, + #[serde(default)] + pub error: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AiSession { + pub id: String, + #[serde(default)] + pub agent_kind: AiAgentKind, + #[serde(default)] + pub scope: AiSessionScope, + #[serde(default)] + pub connection_id: Option, + pub title: String, + pub created_at: String, + pub updated_at: String, + #[serde(default)] + pub external_session_id: Option, + #[serde(default)] + pub backend_metadata: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct AiSessionBackendMetadata { + #[serde(default)] + pub backend: AiBackendKind, + #[serde(default)] + pub external_thread_id: Option, + #[serde(default)] + pub codex_terminal_tools_version: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum AiMessageRole { + User, + Assistant, + System, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AiMessage { + pub id: String, + pub session_id: String, + pub role: AiMessageRole, + pub content: String, + pub created_at: String, + #[serde(default)] + pub reasoning_content: Option, + #[serde(default)] + pub command_cards: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AiAuditLog { + pub id: String, + #[serde(default)] + pub connection_id: Option, + pub action: String, + #[serde(default)] + pub user_input: Option, + #[serde(default)] + pub generated_command: Option, + #[serde(default)] + pub risk_level: Option, + #[serde(default)] + pub inserted_to_terminal: bool, + #[serde(default)] + pub executed: bool, + #[serde(default)] + pub blocked: bool, + #[serde(default)] + pub source: Option, + #[serde(default)] + pub client: Option, + #[serde(default)] + pub capability: Option, + #[serde(default)] + pub session_id: Option, + #[serde(default)] + pub permission_mode: Option, + #[serde(default)] + pub approval_decision: Option, + #[serde(default)] + pub success: Option, + #[serde(default)] + pub duration_ms: Option, + #[serde(default)] + pub error: Option, + pub created_at: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AppendAiAuditRequest { + #[serde(default)] + pub connection_id: Option, + pub action: String, + #[serde(default)] + pub user_input: Option, + #[serde(default)] + pub generated_command: Option, + #[serde(default)] + pub risk_level: Option, + #[serde(default)] + pub inserted_to_terminal: bool, + #[serde(default)] + pub executed: bool, + #[serde(default)] + pub blocked: bool, + #[serde(default)] + pub source: Option, + #[serde(default)] + pub client: Option, + #[serde(default)] + pub capability: Option, + #[serde(default)] + pub session_id: Option, + #[serde(default)] + pub permission_mode: Option, + #[serde(default)] + pub approval_decision: Option, + #[serde(default)] + pub success: Option, + #[serde(default)] + pub duration_ms: Option, + #[serde(default)] + pub error: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AiModelDiscovery { + pub id: String, + pub name: String, + #[serde(default)] + pub backend: AiBackendKind, + #[serde(default)] + pub provider_kind: Option, + #[serde(default)] + pub credential_id: Option, + pub source: AiModelSource, +} + +// --------------------------------------------------------------------------- +// AI capture terminal display events +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "camelCase")] +pub enum AiCaptureEvent { + #[serde(rename_all = "camelCase")] + CommandStart { command: String, step_index: u16 }, + #[serde(rename_all = "camelCase")] + CommandEnd { + output: String, + exit_code: Option, + duration_ms: u64, + truncated: bool, + }, +} + +// --------------------------------------------------------------------------- +// Default value functions for serde +// --------------------------------------------------------------------------- + +pub fn default_max_output_commands() -> u8 { + 5 +} + +pub fn default_language() -> String { + "en".to_string() +} + +pub fn default_safety_mode() -> String { + "strict".to_string() +} + +pub fn default_history_turns() -> u16 { + 20 +} + +pub fn default_mode() -> AiMode { + AiMode::Ask +} + +// --------------------------------------------------------------------------- +// Shared utility helpers +// --------------------------------------------------------------------------- + +pub fn now_rfc3339() -> String { + time::OffsetDateTime::now_utc() + .format(&time::format_description::well_known::Rfc3339) + .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string()) +} + +pub fn uuid() -> String { + uuid::Uuid::new_v4().to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_agent_risk_level_case_insensitively() { + let raw = r#"{"thought":"x","action":"execute_command","command":"ls","riskLevel":"HIGH","riskReason":"x"}"#; + let parsed: AgentLlmResponse = serde_json::from_str(raw).unwrap(); + assert_eq!(parsed.risk_level, Some(RiskLevel::High)); + assert_eq!(parsed.risk_reason.as_deref(), Some("x")); + } + + #[test] + fn invalid_agent_risk_level_does_not_fail_response_parse() { + let raw = r#"{"thought":"x","action":"execute_command","command":"ls","riskLevel":"spicy","riskReason":"x"}"#; + let parsed: AgentLlmResponse = serde_json::from_str(raw).unwrap(); + assert_eq!(parsed.risk_level, None); + } +} 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..20acf3c5 --- /dev/null +++ b/src-tauri/crates/nyaterm-core/src/core/backup.rs @@ -0,0 +1,159 @@ +//! 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 settings = config::load_app_settings(&())?; + // Loading can retain undecipherable settings for recovery; exports must reject them. + // Validate the stored ciphertext: load_app_settings already decrypts valid AI settings. + let stored_settings: config::AppSettings = + storage::load_settings_doc(storage::SettingsDocKey::AppSettings)?; + config::decrypt_ai_settings(stored_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, + legacy_connection_passwords: bool, +) -> 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 { + match auth.password.as_deref() { + Some("") => auth.password = None, + Some(token) if legacy_connection_passwords => { + auth.password = Some(crypto::transcode_legacy_connection_password( + token, source, target, + )?); + } + _ => 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, + false, + )?; + 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, + )?; + // Desktop snapshots can contain legacy plaintext inline passwords. Only accept + // those after the outer encryption, payload hash and source key are verified. + transcode(&mut snapshot, &source, &crypto::get_master_key()?, true)?; + // 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/crates/nyaterm-core/src/core/capabilities.rs b/src-tauri/crates/nyaterm-core/src/core/capabilities.rs new file mode 100644 index 00000000..e162d199 --- /dev/null +++ b/src-tauri/crates/nyaterm-core/src/core/capabilities.rs @@ -0,0 +1,12 @@ +use serde::{Deserialize, Serialize}; +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum RiskReasonCode { + EmptyCommand, + IrreversiblePattern, + UnclassifiedCommand, + PrivilegedMutation, + UnknownCommand, + OrdinaryWrite, + ReadOnlyDiagnostic, +} diff --git a/src-tauri/src/core/history/migration.rs b/src-tauri/crates/nyaterm-core/src/core/history/migration.rs similarity index 100% rename from src-tauri/src/core/history/migration.rs rename to src-tauri/crates/nyaterm-core/src/core/history/migration.rs diff --git a/src-tauri/crates/nyaterm-core/src/core/history/mod.rs b/src-tauri/crates/nyaterm-core/src/core/history/mod.rs new file mode 100644 index 00000000..0aa2e59e --- /dev/null +++ b/src-tauri/crates/nyaterm-core/src/core/history/mod.rs @@ -0,0 +1,16 @@ +use crate::error::{AppError, AppResult}; +use crate::utils::fuzzy::{FuzzyResult, fuzzy_search_items}; +use serde::{Deserialize, Serialize}; +use std::fs; +use std::path::{Path, PathBuf}; +use std::time::{SystemTime, UNIX_EPOCH}; + +const MAX_HISTORY: usize = 5000; +const HISTORY_STORE_VERSION: u32 = 2; + +include!("types.rs"); +include!("store.rs"); +include!("migration.rs"); +include!("sanitize.rs"); +include!("util.rs"); +include!("tests.rs"); diff --git a/src-tauri/src/core/history/sanitize.rs b/src-tauri/crates/nyaterm-core/src/core/history/sanitize.rs similarity index 100% rename from src-tauri/src/core/history/sanitize.rs rename to src-tauri/crates/nyaterm-core/src/core/history/sanitize.rs diff --git a/src-tauri/src/core/history/store.rs b/src-tauri/crates/nyaterm-core/src/core/history/store.rs similarity index 94% rename from src-tauri/src/core/history/store.rs rename to src-tauri/crates/nyaterm-core/src/core/history/store.rs index c72c0e7f..753bc674 100644 --- a/src-tauri/src/core/history/store.rs +++ b/src-tauri/crates/nyaterm-core/src/core/history/store.rs @@ -4,7 +4,7 @@ pub struct CommandHistoryStore { history_path: Option, } -pub(crate) enum PreparedHistorySave { +pub enum PreparedHistorySave { File(PathBuf, Vec), Redb(String), } @@ -18,7 +18,7 @@ impl CommandHistoryStore { } } - #[cfg(test)] + #[cfg(any(test, feature = "test-support"))] pub fn set_history_path(&mut self, path: PathBuf) { self.history_path = Some(path); } @@ -39,6 +39,7 @@ impl CommandHistoryStore { return Ok(()); } self.entries = entries; + self.entries.sort_by_key(|entry| entry.last_used_at_ms); self.dirty = false; return Ok(()); }; @@ -67,7 +68,7 @@ impl CommandHistoryStore { /// Serializes dirty state and marks clean. Returns a prepared write for /// the caller to persist (possibly via `spawn_blocking`). - pub(crate) fn prepare_save(&mut self) -> Option { + pub fn prepare_save(&mut self) -> Option { if !self.dirty { return None; } @@ -159,7 +160,7 @@ impl CommandHistoryStore { } } -pub(crate) fn sanitize_history_command(input: &str) -> Option { +pub fn sanitize_history_command(input: &str) -> Option { let trimmed = input.trim(); if trimmed.is_empty() { return None; diff --git a/src-tauri/src/core/history/tests.rs b/src-tauri/crates/nyaterm-core/src/core/history/tests.rs similarity index 100% rename from src-tauri/src/core/history/tests.rs rename to src-tauri/crates/nyaterm-core/src/core/history/tests.rs diff --git a/src-tauri/src/core/history/types.rs b/src-tauri/crates/nyaterm-core/src/core/history/types.rs similarity index 100% rename from src-tauri/src/core/history/types.rs rename to src-tauri/crates/nyaterm-core/src/core/history/types.rs diff --git a/src-tauri/src/core/history/util.rs b/src-tauri/crates/nyaterm-core/src/core/history/util.rs similarity index 89% rename from src-tauri/src/core/history/util.rs rename to src-tauri/crates/nyaterm-core/src/core/history/util.rs index 18508c53..3d41265f 100644 --- a/src-tauri/src/core/history/util.rs +++ b/src-tauri/crates/nyaterm-core/src/core/history/util.rs @@ -7,14 +7,14 @@ fn current_time_ms() -> u64 { } /// Writes serialized history bytes to disk. Safe to call from a blocking context. -pub(crate) fn flush_to_disk(path: &Path, bytes: &[u8]) -> AppResult<()> { +pub fn flush_to_disk(path: &Path, bytes: &[u8]) -> AppResult<()> { if let Some(parent) = path.parent() { fs::create_dir_all(parent)?; } write_atomic(path, bytes) } -pub(crate) fn flush_prepared_save(pending: PreparedHistorySave) -> AppResult<()> { +pub fn flush_prepared_save(pending: PreparedHistorySave) -> AppResult<()> { match pending { PreparedHistorySave::File(path, bytes) => flush_to_disk(&path, &bytes), PreparedHistorySave::Redb(content) => { 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 new file mode 100644 index 00000000..7de91eb9 --- /dev/null +++ b/src-tauri/crates/nyaterm-core/src/core/mod.rs @@ -0,0 +1,14 @@ +pub mod history; +mod recording; +pub use recording::{ExistingFileBehavior, RecordingMode, RotationPolicy}; +pub mod ai; +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/monitoring/ascend_npu.rs b/src-tauri/crates/nyaterm-core/src/core/monitoring/ascend_npu.rs similarity index 100% rename from src-tauri/src/core/monitoring/ascend_npu.rs rename to src-tauri/crates/nyaterm-core/src/core/monitoring/ascend_npu.rs diff --git a/src-tauri/src/core/monitoring/docker/compose.rs b/src-tauri/crates/nyaterm-core/src/core/monitoring/docker/compose.rs similarity index 100% rename from src-tauri/src/core/monitoring/docker/compose.rs rename to src-tauri/crates/nyaterm-core/src/core/monitoring/docker/compose.rs diff --git a/src-tauri/src/core/monitoring/docker/details.rs b/src-tauri/crates/nyaterm-core/src/core/monitoring/docker/details.rs similarity index 100% rename from src-tauri/src/core/monitoring/docker/details.rs rename to src-tauri/crates/nyaterm-core/src/core/monitoring/docker/details.rs diff --git a/src-tauri/src/core/monitoring/docker/mod.rs b/src-tauri/crates/nyaterm-core/src/core/monitoring/docker/mod.rs similarity index 100% rename from src-tauri/src/core/monitoring/docker/mod.rs rename to src-tauri/crates/nyaterm-core/src/core/monitoring/docker/mod.rs diff --git a/src-tauri/src/core/monitoring/docker/parse.rs b/src-tauri/crates/nyaterm-core/src/core/monitoring/docker/parse.rs similarity index 100% rename from src-tauri/src/core/monitoring/docker/parse.rs rename to src-tauri/crates/nyaterm-core/src/core/monitoring/docker/parse.rs diff --git a/src-tauri/src/core/monitoring/docker/scripts.rs b/src-tauri/crates/nyaterm-core/src/core/monitoring/docker/scripts.rs similarity index 100% rename from src-tauri/src/core/monitoring/docker/scripts.rs rename to src-tauri/crates/nyaterm-core/src/core/monitoring/docker/scripts.rs diff --git a/src-tauri/src/core/monitoring/docker/types.rs b/src-tauri/crates/nyaterm-core/src/core/monitoring/docker/types.rs similarity index 100% rename from src-tauri/src/core/monitoring/docker/types.rs rename to src-tauri/crates/nyaterm-core/src/core/monitoring/docker/types.rs diff --git a/src-tauri/src/core/monitoring/gpu.rs b/src-tauri/crates/nyaterm-core/src/core/monitoring/gpu.rs similarity index 100% rename from src-tauri/src/core/monitoring/gpu.rs rename to src-tauri/crates/nyaterm-core/src/core/monitoring/gpu.rs diff --git a/src-tauri/crates/nyaterm-core/src/core/monitoring/mod.rs b/src-tauri/crates/nyaterm-core/src/core/monitoring/mod.rs new file mode 100644 index 00000000..06f0d1ae --- /dev/null +++ b/src-tauri/crates/nyaterm-core/src/core/monitoring/mod.rs @@ -0,0 +1,6 @@ +//! Shared remote monitoring scripts, parsers, and sampling. +pub mod ascend_npu; +pub mod docker; +pub mod gpu; +pub mod process; +pub mod stats; diff --git a/src-tauri/src/core/monitoring/process.rs b/src-tauri/crates/nyaterm-core/src/core/monitoring/process.rs similarity index 100% rename from src-tauri/src/core/monitoring/process.rs rename to src-tauri/crates/nyaterm-core/src/core/monitoring/process.rs diff --git a/src-tauri/src/core/monitoring/stats.rs b/src-tauri/crates/nyaterm-core/src/core/monitoring/stats.rs similarity index 100% rename from src-tauri/src/core/monitoring/stats.rs rename to src-tauri/crates/nyaterm-core/src/core/monitoring/stats.rs 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/src/core/quick_commands/merge.rs b/src-tauri/crates/nyaterm-core/src/core/quick_commands/merge.rs similarity index 98% rename from src-tauri/src/core/quick_commands/merge.rs rename to src-tauri/crates/nyaterm-core/src/core/quick_commands/merge.rs index 40043fde..e7bf38f8 100644 --- a/src-tauri/src/core/quick_commands/merge.rs +++ b/src-tauri/crates/nyaterm-core/src/core/quick_commands/merge.rs @@ -193,9 +193,11 @@ fn upsert_command(config: &mut QuickCommandsConfig, command: QuickCommand) -> bo { let created_at = existing.created_at; let use_count = existing.use_count; + let sort_order = existing.sort_order; *existing = command; existing.created_at = created_at.or(existing.created_at); existing.use_count = use_count.or(existing.use_count); + existing.sort_order = existing.sort_order.or(sort_order); false } else { config.commands.push(command); diff --git a/src-tauri/crates/nyaterm-core/src/core/quick_commands/mod.rs b/src-tauri/crates/nyaterm-core/src/core/quick_commands/mod.rs new file mode 100644 index 00000000..1a9373f3 --- /dev/null +++ b/src-tauri/crates/nyaterm-core/src/core/quick_commands/mod.rs @@ -0,0 +1,16 @@ +use crate::config::{self, QuickCommand, QuickCommandCategory, QuickCommandsConfig}; +use crate::error::{AppError, AppResult}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::collections::{BTreeMap, BTreeSet, HashMap}; +use std::io::Read; +use std::sync::RwLock; +use std::time::{SystemTime, UNIX_EPOCH}; + +use uuid::Uuid; + +include!("types.rs"); +include!("store.rs"); +include!("parsers.rs"); +include!("merge.rs"); +include!("tests.rs"); diff --git a/src-tauri/src/core/quick_commands/parsers.rs b/src-tauri/crates/nyaterm-core/src/core/quick_commands/parsers.rs similarity index 98% rename from src-tauri/src/core/quick_commands/parsers.rs rename to src-tauri/crates/nyaterm-core/src/core/quick_commands/parsers.rs index 6191d726..86f1ef9a 100644 --- a/src-tauri/src/core/quick_commands/parsers.rs +++ b/src-tauri/crates/nyaterm-core/src/core/quick_commands/parsers.rs @@ -85,14 +85,7 @@ fn parse_windterm_quickbar(raw: &str) -> AppResult { } fn split_windterm_command(raw: &str) -> (&str, bool) { - const TERMINATORS: [&str; 6] = [ - "\\r\\n", - "\\n", - "\\r", - "\r\n", - "\n", - "\r", - ]; + const TERMINATORS: [&str; 6] = ["\\r\\n", "\\n", "\\r", "\r\n", "\n", "\r"]; for terminator in TERMINATORS { if let Some(command) = raw.strip_suffix(terminator) { diff --git a/src-tauri/src/core/quick_commands/store.rs b/src-tauri/crates/nyaterm-core/src/core/quick_commands/store.rs similarity index 68% rename from src-tauri/src/core/quick_commands/store.rs rename to src-tauri/crates/nyaterm-core/src/core/quick_commands/store.rs index 2b1fa826..5c0dfb7b 100644 --- a/src-tauri/src/core/quick_commands/store.rs +++ b/src-tauri/crates/nyaterm-core/src/core/quick_commands/store.rs @@ -5,7 +5,7 @@ impl QuickCommandsStore { } } - pub fn load_from_disk(&self, app: &AppHandle) -> AppResult<()> { + pub fn load_from_disk(&self, app: &impl Sized) -> AppResult<()> { let config = config::load_quick_commands(app)?; self.replace(config); Ok(()) @@ -15,7 +15,7 @@ impl QuickCommandsStore { self.config.read().unwrap().clone() } - pub fn save_all(&self, app: &AppHandle, config: QuickCommandsConfig) -> AppResult<()> { + pub fn save_all(&self, app: &impl Sized, config: QuickCommandsConfig) -> AppResult<()> { config::save_quick_commands(app, &config)?; self.replace(config); Ok(()) @@ -23,12 +23,15 @@ impl QuickCommandsStore { pub fn upsert( &self, - app: &AppHandle, + app: &impl Sized, mut command: QuickCommand, new_category: Option, ) -> AppResult { let mut config = self.snapshot(); let now = now_millis(); + if command.id.is_empty() { + command.id = Uuid::new_v4().to_string(); + } if let Some(mut category) = new_category { if !config.categories.iter().any(|item| item.id == category.id) { @@ -68,10 +71,10 @@ impl QuickCommandsStore { Ok(config) } - pub fn increment_use_count(&self, app: &AppHandle, id: &str) -> AppResult<()> { + pub fn increment_use_count(&self, app: &impl Sized, id: &str) -> AppResult<()> { let mut config = self.snapshot(); if let Some(cmd) = config.commands.iter_mut().find(|c| c.id == id) { - cmd.use_count = Some(cmd.use_count.unwrap_or(0) + 1); + cmd.use_count = Some(cmd.use_count.unwrap_or(0).saturating_add(1)); cmd.updated_at = Some(now_millis()); self.save_all(app, config)?; } @@ -80,7 +83,7 @@ impl QuickCommandsStore { pub fn import_from_file( &self, - app: &AppHandle, + app: &impl Sized, file_path: &str, source: QuickCommandsImportSource, ) -> AppResult { @@ -119,12 +122,41 @@ impl QuickCommandsStore { fn replace(&self, config: QuickCommandsConfig) { *self.config.write().unwrap() = config; } + + /// Browser imports receive text, never a path on the server filesystem. + pub fn import_from_text( + &self, + app: &impl Sized, + raw: &str, + source: QuickCommandsImportSource, + ) -> AppResult { + let imported = match source { + QuickCommandsImportSource::NyatermJson => parse_nyaterm_import(raw)?, + QuickCommandsImportSource::WindtermQuickbar => parse_windterm_quickbar(raw)?, + QuickCommandsImportSource::XshellXts => { + return Err(AppError::Unsupported( + "XTS requires an archive import".into(), + )); + } + }; + if imported.commands.is_empty() { + return Err(AppError::Config("No valid quick commands found".into())); + } + let mut config = self.snapshot(); + let stats = merge_import(&mut config, imported)?; + let result = QuickCommandsImportResult { + imported_commands: stats.added_commands, + imported_categories: stats.added_categories, + updated_commands: stats.updated_commands, + total_commands: config.commands.len(), + total_categories: config.categories.len(), + }; + self.save_all(app, config)?; + Ok(result) + } } -fn next_category_sort_order( - categories: &[QuickCommandCategory], - parent_id: Option<&str>, -) -> i32 { +fn next_category_sort_order(categories: &[QuickCommandCategory], parent_id: Option<&str>) -> i32 { categories .iter() .filter(|category| category.parent_id.as_deref() == parent_id) diff --git a/src-tauri/src/core/quick_commands/tests.rs b/src-tauri/crates/nyaterm-core/src/core/quick_commands/tests.rs similarity index 97% rename from src-tauri/src/core/quick_commands/tests.rs rename to src-tauri/crates/nyaterm-core/src/core/quick_commands/tests.rs index 4959a429..1fedb4e2 100644 --- a/src-tauri/src/core/quick_commands/tests.rs +++ b/src-tauri/crates/nyaterm-core/src/core/quick_commands/tests.rs @@ -195,12 +195,16 @@ mod tests { .filter(|category| category.name == "Deploy") .collect(); assert_eq!(deploy_categories.len(), 2); - assert!(deploy_categories - .iter() - .any(|category| category.parent_id.as_deref() == Some("dev"))); - assert!(deploy_categories - .iter() - .any(|category| category.parent_id.as_deref() == Some("ops"))); + assert!( + deploy_categories + .iter() + .any(|category| category.parent_id.as_deref() == Some("dev")) + ); + assert!( + deploy_categories + .iter() + .any(|category| category.parent_id.as_deref() == Some("ops")) + ); } #[test] diff --git a/src-tauri/src/core/quick_commands/types.rs b/src-tauri/crates/nyaterm-core/src/core/quick_commands/types.rs similarity index 100% rename from src-tauri/src/core/quick_commands/types.rs rename to src-tauri/crates/nyaterm-core/src/core/quick_commands/types.rs diff --git a/src-tauri/crates/nyaterm-core/src/core/recording.rs b/src-tauri/crates/nyaterm-core/src/core/recording.rs new file mode 100644 index 00000000..87174b15 --- /dev/null +++ b/src-tauri/crates/nyaterm-core/src/core/recording.rs @@ -0,0 +1,31 @@ +use serde::{Deserialize, Serialize}; +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)] +#[serde(rename_all = "snake_case")] +pub enum RecordingMode { + #[default] + Transcript, + Raw, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)] +#[serde(rename_all = "snake_case")] +pub enum ExistingFileBehavior { + #[default] + Unique, + Append, + Overwrite, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case", tag = "type")] +pub enum RotationPolicy { + Session, + Daily, + Size { max_bytes: u64 }, +} + +impl Default for RotationPolicy { + fn default() -> Self { + Self::Session + } +} diff --git a/src-tauri/src/core/translate/ali.rs b/src-tauri/crates/nyaterm-core/src/core/translate/ali.rs similarity index 100% rename from src-tauri/src/core/translate/ali.rs rename to src-tauri/crates/nyaterm-core/src/core/translate/ali.rs diff --git a/src-tauri/src/core/translate/baidu.rs b/src-tauri/crates/nyaterm-core/src/core/translate/baidu.rs similarity index 100% rename from src-tauri/src/core/translate/baidu.rs rename to src-tauri/crates/nyaterm-core/src/core/translate/baidu.rs diff --git a/src-tauri/src/core/translate/deepl.rs b/src-tauri/crates/nyaterm-core/src/core/translate/deepl.rs similarity index 100% rename from src-tauri/src/core/translate/deepl.rs rename to src-tauri/crates/nyaterm-core/src/core/translate/deepl.rs diff --git a/src-tauri/src/core/translate/google.rs b/src-tauri/crates/nyaterm-core/src/core/translate/google.rs similarity index 100% rename from src-tauri/src/core/translate/google.rs rename to src-tauri/crates/nyaterm-core/src/core/translate/google.rs diff --git a/src-tauri/src/core/translate/microsoft.rs b/src-tauri/crates/nyaterm-core/src/core/translate/microsoft.rs similarity index 100% rename from src-tauri/src/core/translate/microsoft.rs rename to src-tauri/crates/nyaterm-core/src/core/translate/microsoft.rs diff --git a/src-tauri/crates/nyaterm-core/src/core/translate/mod.rs b/src-tauri/crates/nyaterm-core/src/core/translate/mod.rs new file mode 100644 index 00000000..b2bf9e2c --- /dev/null +++ b/src-tauri/crates/nyaterm-core/src/core/translate/mod.rs @@ -0,0 +1,66 @@ +mod ali; +mod baidu; +mod deepl; +mod google; +mod microsoft; +mod youdao; + +use crate::config::TranslationSettings; +use crate::error::{AppError, AppResult}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TranslateResult { + pub original: String, + pub translated: String, + pub detected_language: String, + pub provider: String, +} +pub async fn translate( + provider: &str, + text: &str, + target_lang: &str, + settings: &TranslationSettings, +) -> AppResult { + let result = match provider { + "google" => google::translate(text, target_lang).await, + "microsoft" => microsoft::translate(text, target_lang).await, + "deepl" => deepl::translate(text, target_lang, &settings.deepl_api_key).await, + "baidu" => { + baidu::translate( + text, + target_lang, + &settings.baidu_app_id, + &settings.baidu_app_key, + ) + .await + } + "ali" => { + ali::translate( + text, + target_lang, + &settings.ali_app_id, + &settings.ali_app_key, + ) + .await + } + "youdao" => { + youdao::translate( + text, + target_lang, + &settings.youdao_app_id, + &settings.youdao_app_key, + ) + .await + } + _ => Err(AppError::Translation(format!( + "Unknown provider: {provider}" + ))), + }; + + result.map(|mut r| { + r.original = text.to_string(); + r.provider = provider.to_string(); + r + }) +} diff --git a/src-tauri/src/core/translate/youdao.rs b/src-tauri/crates/nyaterm-core/src/core/translate/youdao.rs similarity index 92% rename from src-tauri/src/core/translate/youdao.rs rename to src-tauri/crates/nyaterm-core/src/core/translate/youdao.rs index c8999471..bafaca5b 100644 --- a/src-tauri/src/core/translate/youdao.rs +++ b/src-tauri/crates/nyaterm-core/src/core/translate/youdao.rs @@ -13,11 +13,16 @@ struct YoudaoResponse { } fn truncate_for_sign(q: &str) -> String { - let len = q.len(); + let chars = q.chars().collect::>(); + let len = chars.len(); if len <= 20 { q.to_string() } else { - format!("{}{len}{}", &q[..10], &q[len - 10..]) + format!( + "{}{len}{}", + chars[..10].iter().collect::(), + chars[len - 10..].iter().collect::() + ) } } diff --git a/src-tauri/crates/nyaterm-core/src/error.rs b/src-tauri/crates/nyaterm-core/src/error.rs new file mode 100644 index 00000000..0f5a016d --- /dev/null +++ b/src-tauri/crates/nyaterm-core/src/error.rs @@ -0,0 +1,108 @@ +//! Unified error type for Tauri commands. +//! +//! Provides `AppError` and `AppResult` for consistent error handling across the app. + +use serde::Serialize; + +/// Unified error type for all Tauri commands. +/// +/// Implements `Serialize` so Tauri can pass the error message to the frontend. +/// Uses `thiserror` for ergonomic `From` conversions and `Display` formatting. +#[derive(Debug, thiserror::Error)] +pub enum AppError { + #[error("SSH error: {0}")] + Ssh(#[from] russh::Error), + + #[error("SSH key error: {0}")] + SshKey(#[from] russh::keys::Error), + + #[error("IO error: {0}")] + Io(#[from] std::io::Error), + + #[error("JSON error: {0}")] + Json(#[from] serde_json::Error), + + #[error("{0}")] + SessionNotFound(String), + + #[error("{0}")] + Auth(String), + + #[error("{0}")] + Config(String), + + #[cfg_attr(not(windows), allow(dead_code))] + #[error("{0}")] + Unsupported(String), + + #[error("{0}")] + Storage(String), + + #[error("{0}")] + Channel(String), + + #[error("{0}")] + Cancelled(String), + + #[error("SFTP error: {0}")] + Sftp(#[from] russh_sftp::client::error::Error), + + #[error("Crypto error: {0}")] + Crypto(String), + + #[error("{0}")] + CloudSync(#[from] CloudSyncError), + + #[error("Translation error: {0}")] + Translation(String), +} + +#[derive(Debug, thiserror::Error, Clone, PartialEq, Eq)] +pub enum CloudSyncError { + #[error( + "Remote sync metadata is inconsistent: latest points to {revision} but the referenced snapshot is missing." + )] + SnapshotMissing { revision: String }, + + #[error( + "New sync snapshot was not accepted by remote storage (candidate revision {revision}; latest was not changed)." + )] + SnapshotNotAccepted { revision: String }, + + #[error("Remote sync storage rejected file '{filename}' (gist may be at file capacity).")] + RemoteFileRejected { filename: String }, + + #[error( + "Remote sync snapshot revision mismatch: latest points to {pointer_revision} but snapshot contains {snapshot_revision}." + )] + RevisionMismatch { + pointer_revision: String, + snapshot_revision: String, + }, + + #[error("Remote sync snapshot hash mismatch: expected {expected} but got {actual}.")] + HashMismatch { expected: String, actual: String }, + + #[error( + "Remote sync was updated by another device: expected {expected_revision:?} but found {actual_revision:?}." + )] + ConcurrentUpdate { + expected_revision: Option, + actual_revision: Option, + }, + + #[error("Remote sync snapshot {revision} is corrupted.")] + CorruptedSnapshot { revision: String }, +} + +impl Serialize for AppError { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(&self.to_string()) + } +} + +/// Convenience alias for `Result`. +pub type AppResult = Result; diff --git a/src-tauri/crates/nyaterm-core/src/lib.rs b/src-tauri/crates/nyaterm-core/src/lib.rs new file mode 100644 index 00000000..08c968a5 --- /dev/null +++ b/src-tauri/crates/nyaterm-core/src/lib.rs @@ -0,0 +1,15 @@ +#![recursion_limit = "256"] +//! Transport-neutral persistence and SSH services used by Desktop and Web. +pub mod config; +pub mod core; +pub mod error; +pub mod services; +pub mod ssh; +pub mod storage; +pub mod utils; + +pub mod network; +pub mod remote_desktop_frame; +pub mod telnet; +pub mod terminal_encoding; +pub mod vnc; diff --git a/src-tauri/crates/nyaterm-core/src/network.rs b/src-tauri/crates/nyaterm-core/src/network.rs new file mode 100644 index 00000000..059e795e --- /dev/null +++ b/src-tauri/crates/nyaterm-core/src/network.rs @@ -0,0 +1,178 @@ +use crate::config::{self, ConnectionNetwork, ProxySettings}; +use crate::error::{AppError, AppResult}; +use std::net::SocketAddr; +use tokio::io::{AsyncRead, AsyncWrite}; + +pub trait AsyncReadWrite: AsyncRead + AsyncWrite {} +impl AsyncReadWrite for T {} + +pub type BoxedTransportStream = Box; + +pub struct OpenedTransport { + pub stream: BoxedTransportStream, + pub local_addr: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TransportRouteKind { + Direct, + Proxy, + SshJump, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ResolvedTransportRoute { + pub kind: TransportRouteKind, + pub proxy_id: Option, + pub proxy_jump_id: Option, +} + +pub fn resolve_transport_route(network: Option<&ConnectionNetwork>) -> ResolvedTransportRoute { + let proxy_jump_id = network + .and_then(|network| network.proxy_jump_id.as_deref()) + .filter(|id| !id.trim().is_empty()) + .map(ToOwned::to_owned); + let proxy_id = network + .and_then(|network| network.proxy_id.as_deref()) + .filter(|id| !id.trim().is_empty()) + .map(ToOwned::to_owned); + + if proxy_jump_id.is_some() { + return ResolvedTransportRoute { + kind: TransportRouteKind::SshJump, + proxy_id, + proxy_jump_id, + }; + } + + if proxy_id.is_some() { + return ResolvedTransportRoute { + kind: TransportRouteKind::Proxy, + proxy_id, + proxy_jump_id: None, + }; + } + + ResolvedTransportRoute { + kind: TransportRouteKind::Direct, + proxy_id: None, + proxy_jump_id: None, + } +} + +pub async fn open_direct_transport( + target_host: &str, + target_port: u16, +) -> AppResult { + let stream = tokio::net::TcpStream::connect((target_host, target_port)) + .await + .map_err(|error| { + AppError::Channel(format!( + "Direct TCP connection to {target_host}:{target_port} failed: {error}" + )) + })?; + let local_addr = stream.local_addr().ok(); + Ok(OpenedTransport { + stream: Box::new(stream), + local_addr, + }) +} + +pub async fn open_proxy_transport( + proxy: ProxySettings, + target_host: &str, + target_port: u16, +) -> AppResult { + let proxy_addr = if proxy.host.contains(':') { + format!("[{}]:{}", proxy.host, proxy.port) + } else { + format!("{}:{}", proxy.host, proxy.port) + }; + if proxy.username.is_some() != proxy.password.is_some() { + return Err(AppError::Config( + "Proxy authentication requires both username and password".into(), + )); + } + match proxy.protocol.as_str() { + "socks5" => { + let stream = match (&proxy.username, &proxy.password) { + (Some(user), Some(pass)) => { + tokio_socks::tcp::Socks5Stream::connect_with_password( + proxy_addr.as_str(), + (target_host, target_port), + user, + pass, + ) + .await + } + _ => { + tokio_socks::tcp::Socks5Stream::connect( + proxy_addr.as_str(), + (target_host, target_port), + ) + .await + } + } + .map_err(|error| AppError::Auth(format!("SOCKS5 proxy connection failed: {error}")))? + .into_inner(); + let local_addr = stream.local_addr().ok(); + Ok(OpenedTransport { + stream: Box::new(stream), + local_addr, + }) + } + "http" => { + let mut stream = + tokio::net::TcpStream::connect(&proxy_addr) + .await + .map_err(|error| { + AppError::Channel(format!("HTTP proxy connection failed: {error}")) + })?; + match (&proxy.username, &proxy.password) { + (Some(user), Some(pass)) => { + async_http_proxy::http_connect_tokio_with_basic_auth( + &mut stream, + target_host, + target_port, + user, + pass, + ) + .await + } + _ => { + async_http_proxy::http_connect_tokio(&mut stream, target_host, target_port) + .await + } + } + .map_err(|error| AppError::Auth(format!("HTTP proxy tunnel failed: {error}")))?; + let local_addr = stream.local_addr().ok(); + Ok(OpenedTransport { + stream: Box::new(stream), + local_addr, + }) + } + other => Err(AppError::Config(format!( + "Unsupported proxy protocol '{other}'" + ))), + } +} + +pub fn resolve_proxy(app: &impl Sized, proxy_id: &str) -> AppResult { + let proxy_cfg = config::load_proxy_by_id(app, proxy_id)? + .ok_or_else(|| AppError::Config(format!("Proxy '{proxy_id}' not found")))?; + let password = proxy_cfg + .password + .as_ref() + .map(|ciphertext| crate::utils::crypto::decrypt(ciphertext)) + .transpose()?; + + Ok(ProxySettings { + enabled: true, + protocol: proxy_cfg.protocol, + host: proxy_cfg.host, + port: proxy_cfg.port, + command: proxy_cfg.command, + username: proxy_cfg.username, + password, + }) +} diff --git a/src-tauri/crates/nyaterm-core/src/remote_desktop_frame.rs b/src-tauri/crates/nyaterm-core/src/remote_desktop_frame.rs new file mode 100644 index 00000000..880e8810 --- /dev/null +++ b/src-tauri/crates/nyaterm-core/src/remote_desktop_frame.rs @@ -0,0 +1,153 @@ +use crate::error::{AppError, AppResult}; + +pub const FRAME_HEADER_BYTES: usize = 44; +pub const PIXEL_FORMAT_BGRA8888: u32 = 1; +pub const PIXEL_FORMAT_RGBA8888: u32 = 2; +const BYTES_PER_PIXEL: usize = 4; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[allow(dead_code)] +pub enum RemoteDesktopPixelFormat { + Bgra8888, + Rgba8888, +} + +impl RemoteDesktopPixelFormat { + fn wire_value(self) -> u32 { + match self { + Self::Bgra8888 => PIXEL_FORMAT_BGRA8888, + Self::Rgba8888 => PIXEL_FORMAT_RGBA8888, + } + } +} + +pub struct RemoteDesktopFramePatch<'a> { + pub sequence: u64, + pub desktop_width: u32, + pub desktop_height: u32, + pub x: u32, + pub y: u32, + pub width: u32, + pub height: u32, + pub stride: u32, + pub pixel_format: RemoteDesktopPixelFormat, + pub payload: &'a [u8], +} + +pub fn encode_frame_patch(patch: &RemoteDesktopFramePatch<'_>) -> AppResult> { + validate_frame_patch(patch)?; + let mut frame = Vec::with_capacity( + FRAME_HEADER_BYTES + .checked_add(patch.payload.len()) + .ok_or_else(|| AppError::Config("Remote desktop frame size overflows".to_string()))?, + ); + frame.extend_from_slice(&patch.sequence.to_le_bytes()); + frame.extend_from_slice(&patch.desktop_width.to_le_bytes()); + frame.extend_from_slice(&patch.desktop_height.to_le_bytes()); + frame.extend_from_slice(&patch.x.to_le_bytes()); + frame.extend_from_slice(&patch.y.to_le_bytes()); + frame.extend_from_slice(&patch.width.to_le_bytes()); + frame.extend_from_slice(&patch.height.to_le_bytes()); + frame.extend_from_slice(&patch.stride.to_le_bytes()); + frame.extend_from_slice(&patch.pixel_format.wire_value().to_le_bytes()); + let payload_len = u32::try_from(patch.payload.len()).map_err(|_| { + AppError::Config("Remote desktop frame payload exceeds the wire limit".to_string()) + })?; + frame.extend_from_slice(&payload_len.to_le_bytes()); + frame.extend_from_slice(patch.payload); + Ok(frame) +} + +fn validate_frame_patch(patch: &RemoteDesktopFramePatch<'_>) -> AppResult<()> { + if patch.desktop_width == 0 || patch.desktop_height == 0 { + return Err(AppError::Config( + "Remote desktop framebuffer dimensions must be non-zero".to_string(), + )); + } + if patch.width == 0 || patch.height == 0 { + return Err(AppError::Config( + "Remote desktop frame rectangle must be non-zero".to_string(), + )); + } + let right = patch.x.checked_add(patch.width).ok_or_else(|| { + AppError::Config("Remote desktop frame horizontal bounds overflow".to_string()) + })?; + let bottom = patch.y.checked_add(patch.height).ok_or_else(|| { + AppError::Config("Remote desktop frame vertical bounds overflow".to_string()) + })?; + if right > patch.desktop_width || bottom > patch.desktop_height { + return Err(AppError::Config( + "Remote desktop frame rectangle exceeds framebuffer bounds".to_string(), + )); + } + let row_bytes = usize::try_from(patch.width) + .ok() + .and_then(|width| width.checked_mul(BYTES_PER_PIXEL)) + .ok_or_else(|| AppError::Config("Remote desktop frame row size overflows".to_string()))?; + let stride = usize::try_from(patch.stride) + .map_err(|_| AppError::Config("Remote desktop frame stride is invalid".to_string()))?; + if stride < row_bytes { + return Err(AppError::Config( + "Remote desktop frame stride is too small".to_string(), + )); + } + let required_payload = usize::try_from(patch.height) + .ok() + .and_then(|height| stride.checked_mul(height)) + .ok_or_else(|| { + AppError::Config("Remote desktop frame payload size overflows".to_string()) + })?; + if patch.payload.len() < required_payload { + return Err(AppError::Config( + "Remote desktop frame payload is too small".to_string(), + )); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn encodes_the_shared_frame_header() { + let payload = [1, 2, 3, 255]; + let frame = encode_frame_patch(&RemoteDesktopFramePatch { + sequence: 7, + desktop_width: 10, + desktop_height: 10, + x: 2, + y: 3, + width: 1, + height: 1, + stride: 4, + pixel_format: RemoteDesktopPixelFormat::Rgba8888, + payload: &payload, + }) + .expect("frame should encode"); + + assert_eq!(frame.len(), FRAME_HEADER_BYTES + payload.len()); + assert_eq!(u64::from_le_bytes(frame[0..8].try_into().unwrap()), 7); + assert_eq!(u32::from_le_bytes(frame[36..40].try_into().unwrap()), 2); + assert_eq!(&frame[FRAME_HEADER_BYTES..], &payload); + } + + #[test] + fn rejects_rectangles_outside_the_framebuffer() { + let error = encode_frame_patch(&RemoteDesktopFramePatch { + sequence: 1, + desktop_width: 10, + desktop_height: 10, + x: 10, + y: 0, + width: 1, + height: 1, + stride: 4, + pixel_format: RemoteDesktopPixelFormat::Rgba8888, + payload: &[0; 4], + }) + .expect_err("out-of-bounds rectangle must fail"); + + assert!(error.to_string().contains("exceeds framebuffer bounds")); + } +} diff --git a/src-tauri/crates/nyaterm-core/src/services.rs b/src-tauri/crates/nyaterm-core/src/services.rs new file mode 100644 index 00000000..c9f8c68c --- /dev/null +++ b/src-tauri/crates/nyaterm-core/src/services.rs @@ -0,0 +1,358 @@ +use crate::config::{self, SavedConnection}; +use crate::error::{AppError, AppResult}; +use crate::utils::crypto; +use std::collections::HashSet; + +pub fn save_connection(app: &impl Sized, mut connection: SavedConnection) -> AppResult { + let mut cfg = config::load_config(app)?; + + if connection.id.is_empty() { + connection.id = uuid::Uuid::new_v4().to_string(); + } + let target_id = connection.id.clone(); + let existing = cfg.connections.iter().find(|c| c.id == target_id).cloned(); + + normalize_connection_for_save(&mut connection); + + config::validate_ssh_agent_settings(&connection.config)?; + validate_proxy_jump_config(&connection, &cfg.connections)?; + validate_ssh_algorithm_config(&connection)?; + validate_sftp_settings_config(&connection)?; + validate_rdp_config(&connection)?; + validate_vnc_config(&connection)?; + + if let Some(ref mut auth) = connection.auth { + // account_id: Some("") means explicitly cleared, None means preserve existing + match auth.account_id.as_deref() { + Some("") => auth.account_id = None, + None => { + auth.account_id = existing + .as_ref() + .and_then(|entry| entry.auth.as_ref()) + .and_then(|entry| entry.account_id.clone()); + } + _ => {} + } + + // password_source: Some("") means legacy/default behavior, None means preserve existing + match auth.password_source.as_deref() { + Some("") => auth.password_source = None, + None => { + auth.password_source = existing + .as_ref() + .and_then(|entry| entry.auth.as_ref()) + .and_then(|entry| entry.password_source.clone()); + } + _ => {} + } + + // password_id: Some("") means explicitly cleared, None means preserve existing + match auth.password_id.as_deref() { + Some("") => auth.password_id = None, + None => { + auth.password_id = existing + .as_ref() + .and_then(|e| e.auth.as_ref()) + .and_then(|a| a.password_id.clone()); + } + _ => {} + } + + if matches!( + connection.config, + config::ConnectionType::Ssh { .. } | config::ConnectionType::Telnet { .. } + ) && auth.account_id.as_deref().is_some_and(|id| !id.is_empty()) + { + auth.password_id = None; + } + + // password: non-empty = encrypt new value, "" = explicitly clear, None = preserve + auth.password = match auth.password.as_deref() { + Some(plain) if !plain.is_empty() => Some(crypto::encrypt(plain)?), + Some("") => None, + None => existing + .as_ref() + .and_then(|e| e.auth.as_ref()) + .and_then(|a| a.password.clone()), + _ => None, + }; + auth.has_password = false; + } + + if let Some(existing_connection) = existing.as_ref() { + connection.asset = existing_connection.asset.clone(); + } + + if let Some(ex) = cfg.connections.iter_mut().find(|c| c.id == target_id) { + *ex = connection; + } else { + cfg.connections.push(connection); + } + config::save_config(app, &cfg)?; + Ok(target_id) +} + +pub fn normalize_connection_for_save(connection: &mut SavedConnection) { + config::migrate_legacy_ssh_agent_settings(connection); + config::migrate_legacy_asset_tags(connection); +} + +pub fn validate_ssh_algorithm_config(connection: &SavedConnection) -> AppResult<()> { + if !matches!(connection.config, config::ConnectionType::Ssh { .. }) { + return Ok(()); + } + + let Some(preferences) = connection.ssh_algorithms.as_ref() else { + return Ok(()); + }; + + crate::ssh::algorithms::validate_ssh_algorithm_preferences(preferences) +} + +pub fn validate_sftp_settings_config(connection: &SavedConnection) -> AppResult<()> { + if !matches!(connection.config, config::ConnectionType::Ssh { .. }) { + return Ok(()); + } + + let timeout_ms = connection.sftp.shell_detection_timeout_ms; + if !(config::MIN_SFTP_SHELL_DETECTION_TIMEOUT_MS..=config::MAX_SFTP_SHELL_DETECTION_TIMEOUT_MS) + .contains(&timeout_ms) + { + return Err(AppError::Config(format!( + "SFTP shell detection timeout must be between {} and {} ms", + config::MIN_SFTP_SHELL_DETECTION_TIMEOUT_MS, + config::MAX_SFTP_SHELL_DETECTION_TIMEOUT_MS + ))); + } + + Ok(()) +} + +pub fn validate_rdp_config(connection: &SavedConnection) -> AppResult<()> { + let config::ConnectionType::Rdp { + host, + port, + username, + security, + display, + clipboard, + reconnect, + .. + } = &connection.config + else { + return Ok(()); + }; + + if host.trim().is_empty() { + return Err(AppError::Config("RDP host is required".to_string())); + } + if *port == 0 { + return Err(AppError::Config( + "RDP port must be between 1 and 65535".to_string(), + )); + } + if username.trim().is_empty() { + return Err(AppError::Config("RDP username is required".to_string())); + } + if !matches!( + security.certificate_policy.as_str(), + "strict" | "prompt" | "accept-temporarily" + ) { + return Err(AppError::Config( + "RDP certificate policy is invalid".to_string(), + )); + } + if !matches!(display.mode.as_str(), "fit-window" | "fixed" | "native") { + return Err(AppError::Config("RDP display mode is invalid".to_string())); + } + if !(640..=7680).contains(&display.width) || !(480..=4320).contains(&display.height) { + return Err(AppError::Config( + "RDP display size is outside the supported range".to_string(), + )); + } + if !matches!(display.color_depth, 16 | 24 | 32) { + return Err(AppError::Config("RDP color depth is invalid".to_string())); + } + if !matches!( + clipboard.mode.as_str(), + "disabled" | "text-only" | "text-and-files" + ) { + return Err(AppError::Config( + "RDP clipboard mode is invalid".to_string(), + )); + } + if reconnect.max_attempts > 20 { + return Err(AppError::Config( + "RDP reconnect attempts must be 20 or fewer".to_string(), + )); + } + + Ok(()) +} + +pub fn validate_vnc_config(connection: &SavedConnection) -> AppResult<()> { + let config::ConnectionType::Vnc { + host, + port, + security, + display, + reconnect, + .. + } = &connection.config + else { + return Ok(()); + }; + + if host.trim().is_empty() { + return Err(AppError::Config("VNC host is required".to_string())); + } + if *port == 0 { + return Err(AppError::Config( + "VNC port must be between 1 and 65535".to_string(), + )); + } + if !matches!(security.mode.as_str(), "auto" | "vnc-auth" | "none") { + return Err(AppError::Config("VNC security mode is invalid".to_string())); + } + if !matches!(display.scale_mode.as_str(), "fit" | "actual" | "stretch") { + return Err(AppError::Config("VNC scale mode is invalid".to_string())); + } + if reconnect.max_attempts > 20 { + return Err(AppError::Config( + "VNC reconnect attempts must be 20 or fewer".to_string(), + )); + } + + Ok(()) +} + +pub fn validate_proxy_jump_config( + connection: &SavedConnection, + existing_connections: &[SavedConnection], +) -> AppResult<()> { + let proxy_jump_id = connection + .network + .as_ref() + .and_then(|network| network.proxy_jump_id.as_deref()); + + let Some(proxy_jump_id) = proxy_jump_id else { + return Ok(()); + }; + + if !matches!( + connection.config, + config::ConnectionType::Ssh { .. } + | config::ConnectionType::Telnet { .. } + | config::ConnectionType::Rdp { .. } + | config::ConnectionType::Vnc { .. } + ) { + return Err(AppError::Config( + "ProxyJump is only supported for SSH, Telnet, RDP, and VNC connections".to_string(), + )); + } + + let mut visited = HashSet::new(); + visited.insert(connection.id.as_str()); + let mut current_jump_id = proxy_jump_id; + + loop { + if visited.len() > 8 { + return Err(AppError::Config("SSH jump chain exceeds 8 hops".into())); + } + if !visited.insert(current_jump_id) { + if connection.id == current_jump_id { + return Err(AppError::Config( + "A connection cannot use itself as a jump host".to_string(), + )); + } + return Err(AppError::Config(format!( + "ProxyJump chain contains a cycle at '{}'", + current_jump_id + ))); + } + + let jump_connection = + find_connection_for_proxy_jump(connection, existing_connections, current_jump_id) + .ok_or_else(|| { + AppError::Config(format!("Jump host '{}' not found", current_jump_id)) + })?; + + if !matches!(jump_connection.config, config::ConnectionType::Ssh { .. }) { + return Err(AppError::Config( + "Only SSH connections can be used as jump hosts".to_string(), + )); + } + + let Some(next_jump_id) = jump_connection + .network + .as_ref() + .and_then(|network| network.proxy_jump_id.as_deref()) + else { + break; + }; + + current_jump_id = next_jump_id; + } + + Ok(()) +} + +fn find_connection_for_proxy_jump<'a>( + edited_connection: &'a SavedConnection, + existing_connections: &'a [SavedConnection], + id: &str, +) -> Option<&'a SavedConnection> { + if edited_connection.id == id { + return Some(edited_connection); + } + + existing_connections + .iter() + .find(|candidate| candidate.id == id) +} + +fn missing_private_key_passphrase_error(error: &russh::keys::Error) -> bool { + let message = error.to_string().to_lowercase(); + message.contains("encrypted") + || message.contains("passphrase") + || message.contains("password") + || message.contains("cipher") +} + +pub fn validate_private_key_content(content: &str, passphrase: Option<&str>) -> AppResult<()> { + let usable_passphrase = passphrase.filter(|value| !value.is_empty()); + match russh::keys::decode_secret_key(content, usable_passphrase) { + Ok(_) => Ok(()), + Err(error) + if usable_passphrase.is_none() && missing_private_key_passphrase_error(&error) => + { + Ok(()) + } + Err(error) => Err(AppError::Config(format!( + "invalid SSH private key: {error}" + ))), + } +} + +pub fn derive_public_key_for_copy(content: &str, passphrase: Option<&str>) -> AppResult { + let usable_passphrase = passphrase.filter(|value| !value.is_empty()); + if let Ok(private_key) = russh::keys::decode_secret_key(content, usable_passphrase) { + return private_key.public_key().to_openssh().map_err(|error| { + AppError::Config(format!("failed to encode SSH public key: {error}")) + }); + } + + // OpenSSH containers keep the public key outside the encrypted private payload. + let private_key = ssh_key::PrivateKey::from_openssh(content) + .map_err(|error| AppError::Config(format!("failed to derive SSH public key: {error}")))?; + private_key + .public_key() + .to_openssh() + .map_err(|error| AppError::Config(format!("failed to encode SSH public key: {error}"))) +} + +pub fn validate_certificate_content(content: &str) -> AppResult<()> { + russh::keys::Certificate::from_openssh(content) + .map(|_| ()) + .map_err(|error| AppError::Config(format!("invalid OpenSSH certificate: {error}"))) +} diff --git a/src-tauri/crates/nyaterm-core/src/ssh/algorithms.rs b/src-tauri/crates/nyaterm-core/src/ssh/algorithms.rs new file mode 100644 index 00000000..4abe02e2 --- /dev/null +++ b/src-tauri/crates/nyaterm-core/src/ssh/algorithms.rs @@ -0,0 +1,305 @@ +use crate::config::{SshAlgorithmMode, SshAlgorithmPreferences}; +use crate::error::{AppError, AppResult}; +use russh::keys::{Algorithm, EcdsaCurve, HashAlg}; +use russh::{Preferred, cipher, kex, mac}; +use serde::Serialize; +use std::{borrow::Cow, convert::TryFrom, str::FromStr}; + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum AlgorithmRisk { + Modern, + Legacy, + Insecure, +} + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub struct AlgorithmOption { + pub id: String, + pub label: String, + pub risk: AlgorithmRisk, +} + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub struct SshAlgorithmDefaults { + pub kex: Vec, + pub ciphers: Vec, + pub macs: Vec, + pub host_keys: Vec, +} + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub struct SupportedSshAlgorithms { + pub kex: Vec, + pub ciphers: Vec, + pub macs: Vec, + pub host_keys: Vec, + pub compatible: SshAlgorithmDefaults, + pub secure: SshAlgorithmDefaults, +} + +pub fn compatible_algorithms() -> Preferred { + let mut preferred = Preferred::default(); + + preferred.kex = Cow::Owned(vec![ + kex::MLKEM768X25519_SHA256, + kex::CURVE25519, + kex::CURVE25519_PRE_RFC_8731, + kex::ECDH_SHA2_NISTP256, + kex::ECDH_SHA2_NISTP384, + kex::ECDH_SHA2_NISTP521, + kex::DH_G18_SHA512, + kex::DH_G17_SHA512, + kex::DH_G16_SHA512, + kex::DH_G15_SHA512, + kex::DH_G14_SHA256, + kex::DH_GEX_SHA256, + kex::DH_G14_SHA1, + kex::DH_GEX_SHA1, + kex::DH_G1_SHA1, + kex::EXTENSION_SUPPORT_AS_CLIENT, + kex::EXTENSION_SUPPORT_AS_SERVER, + kex::EXTENSION_OPENSSH_STRICT_KEX_AS_CLIENT, + kex::EXTENSION_OPENSSH_STRICT_KEX_AS_SERVER, + ]); + + preferred.key = Cow::Owned(vec![ + Algorithm::Ed25519, + Algorithm::Ecdsa { + curve: EcdsaCurve::NistP256, + }, + Algorithm::Ecdsa { + curve: EcdsaCurve::NistP384, + }, + Algorithm::Rsa { + hash: Some(HashAlg::Sha512), + }, + Algorithm::Rsa { + hash: Some(HashAlg::Sha256), + }, + // Some network devices advertise malformed P-521 ECDSA host keys; prefer + // plain ssh-rsa first so negotiation can reach authentication. + Algorithm::Rsa { hash: None }, + Algorithm::Ecdsa { + curve: EcdsaCurve::NistP521, + }, + Algorithm::Dsa, + ]); + + preferred.cipher = Cow::Owned(vec![ + cipher::CHACHA20_POLY1305, + cipher::AES_256_GCM, + cipher::AES_128_GCM, + cipher::AES_256_CTR, + cipher::AES_192_CTR, + cipher::AES_128_CTR, + cipher::AES_256_CBC, + cipher::AES_192_CBC, + cipher::AES_128_CBC, + cipher::TRIPLE_DES_CBC, + ]); + + preferred.mac = Cow::Owned(vec![ + mac::HMAC_SHA512_ETM, + mac::HMAC_SHA256_ETM, + mac::HMAC_SHA512, + mac::HMAC_SHA256, + mac::HMAC_SHA1_ETM, + mac::HMAC_SHA1, + ]); + + preferred +} + +pub fn secure_algorithms() -> Preferred { + Preferred::default() +} + +pub fn validate_ssh_algorithm_preferences(preferences: &SshAlgorithmPreferences) -> AppResult<()> { + resolve_preferred_algorithms(Some(preferences)).map(|_| ()) +} + +pub fn resolve_preferred_algorithms( + preferences: Option<&SshAlgorithmPreferences>, +) -> AppResult { + let Some(preferences) = preferences else { + return Ok(compatible_algorithms()); + }; + + match preferences.mode { + SshAlgorithmMode::Compatible => Ok(compatible_algorithms()), + SshAlgorithmMode::Secure => Ok(secure_algorithms()), + SshAlgorithmMode::Custom => custom_algorithms(preferences), + } +} + +fn custom_algorithms(preferences: &SshAlgorithmPreferences) -> AppResult { + let mut preferred = Preferred::default(); + preferred.kex = Cow::Owned(parse_kex_names(&preferences.kex)?); + preferred.cipher = Cow::Owned(parse_cipher_names(&preferences.ciphers)?); + preferred.mac = Cow::Owned(parse_mac_names(&preferences.macs)?); + preferred.key = Cow::Owned(parse_host_key_algorithms(&preferences.host_keys)?); + Ok(preferred) +} + +fn parse_required_list(values: &[String], label: &str, mut parse: F) -> AppResult> +where + F: FnMut(&str) -> Option, +{ + if values.is_empty() { + return Err(AppError::Config(format!( + "SSH algorithm list '{}' must not be empty", + label + ))); + } + + values + .iter() + .map(|value| { + parse(value).ok_or_else(|| { + AppError::Config(format!( + "Unsupported SSH algorithm '{}' in {}", + value, label + )) + }) + }) + .collect() +} + +fn parse_kex_names(values: &[String]) -> AppResult> { + parse_required_list(values, "key exchanges", |value| { + kex::Name::try_from(value).ok() + }) +} + +fn parse_cipher_names(values: &[String]) -> AppResult> { + parse_required_list(values, "ciphers", |value| { + cipher::Name::try_from(value).ok() + }) +} + +fn parse_mac_names(values: &[String]) -> AppResult> { + parse_required_list(values, "MACs", |value| mac::Name::try_from(value).ok()) +} + +fn parse_host_key_algorithms(values: &[String]) -> AppResult> { + parse_required_list(values, "host keys", |value| Algorithm::from_str(value).ok()) +} + +fn defaults_from_preferred(preferred: Preferred) -> SshAlgorithmDefaults { + SshAlgorithmDefaults { + kex: preferred + .kex + .iter() + .map(|algorithm| algorithm.as_ref().to_string()) + .collect(), + ciphers: preferred + .cipher + .iter() + .map(|algorithm| algorithm.as_ref().to_string()) + .collect(), + macs: preferred + .mac + .iter() + .map(|algorithm| algorithm.as_ref().to_string()) + .collect(), + host_keys: preferred.key.iter().map(ToString::to_string).collect(), + } +} + +fn algorithm_option(id: impl Into, risk: AlgorithmRisk) -> AlgorithmOption { + let id = id.into(); + AlgorithmOption { + label: id.clone(), + id, + risk, + } +} + +fn kex_risk(id: &str) -> AlgorithmRisk { + match id { + "diffie-hellman-group1-sha1" + | "diffie-hellman-group14-sha1" + | "diffie-hellman-group-exchange-sha1" => AlgorithmRisk::Insecure, + value if value.starts_with("diffie-hellman-") => AlgorithmRisk::Legacy, + _ => AlgorithmRisk::Modern, + } +} + +fn cipher_risk(id: &str) -> AlgorithmRisk { + match id { + "3des-cbc" => AlgorithmRisk::Insecure, + value if value.ends_with("-cbc") => AlgorithmRisk::Legacy, + _ => AlgorithmRisk::Modern, + } +} + +fn mac_risk(id: &str) -> AlgorithmRisk { + match id { + "hmac-sha1" => AlgorithmRisk::Insecure, + "hmac-sha1-etm@openssh.com" => AlgorithmRisk::Legacy, + _ => AlgorithmRisk::Modern, + } +} + +fn host_key_risk(id: &str) -> AlgorithmRisk { + match id { + "ssh-dss" => AlgorithmRisk::Insecure, + "ssh-rsa" => AlgorithmRisk::Legacy, + _ => AlgorithmRisk::Modern, + } +} + +pub fn get_supported_ssh_algorithms() -> SupportedSshAlgorithms { + let compatible = defaults_from_preferred(compatible_algorithms()); + let secure = defaults_from_preferred(secure_algorithms()); + + let mut kex_ids = compatible.kex.clone(); + for id in &secure.kex { + if !kex_ids.contains(id) { + kex_ids.push(id.clone()); + } + } + + let mut cipher_ids = compatible.ciphers.clone(); + for id in &secure.ciphers { + if !cipher_ids.contains(id) { + cipher_ids.push(id.clone()); + } + } + + let mut mac_ids = compatible.macs.clone(); + for id in &secure.macs { + if !mac_ids.contains(id) { + mac_ids.push(id.clone()); + } + } + + let mut host_key_ids = compatible.host_keys.clone(); + for id in &secure.host_keys { + if !host_key_ids.contains(id) { + host_key_ids.push(id.clone()); + } + } + + SupportedSshAlgorithms { + kex: kex_ids + .into_iter() + .map(|id| algorithm_option(id.clone(), kex_risk(&id))) + .collect(), + ciphers: cipher_ids + .into_iter() + .map(|id| algorithm_option(id.clone(), cipher_risk(&id))) + .collect(), + macs: mac_ids + .into_iter() + .map(|id| algorithm_option(id.clone(), mac_risk(&id))) + .collect(), + host_keys: host_key_ids + .into_iter() + .map(|id| algorithm_option(id.clone(), host_key_risk(&id))) + .collect(), + compatible, + secure, + } +} diff --git a/src-tauri/crates/nyaterm-core/src/ssh/files.rs b/src-tauri/crates/nyaterm-core/src/ssh/files.rs new file mode 100644 index 00000000..f6b0c082 --- /dev/null +++ b/src-tauri/crates/nyaterm-core/src/ssh/files.rs @@ -0,0 +1,744 @@ +//! Shared helpers for remote file system backends: path quoting, permission +//! formatting, and common type definitions. + +use crate::error::{AppError, AppResult}; +use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +pub const SFTP_FILE_TYPE_MASK: u32 = 0o170000; +pub const POSIX_MODE_MASK: u32 = 0o7777; + +/// Parsed entry from a remote directory listing for the file explorer. +#[derive(Debug, Clone, Serialize)] +pub struct FileEntry { + pub name: String, + pub is_dir: bool, + pub is_symlink: bool, + pub size: u64, + pub permissions: String, + pub owner: String, + pub group: String, + pub mtime: u64, + #[serde(skip_serializing_if = "Option::is_none")] + pub raw_path_token: Option, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DirectoryChild { + pub name: String, + pub path: String, + pub is_symlink: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub raw_path_token: Option, +} + +#[derive(Debug, Clone)] +pub struct RemotePathRef { + display_path: String, + raw_path: Option>, +} + +impl RemotePathRef { + pub fn new(display_path: &str, raw_path_token: Option<&str>) -> AppResult { + let raw_path = raw_path_token + .filter(|token| !token.trim().is_empty()) + .map(decode_raw_path_token) + .transpose()?; + Ok(Self { + display_path: display_path.to_string(), + raw_path, + }) + } + + pub fn display_path(&self) -> &str { + &self.display_path + } + + pub fn raw_path(&self) -> Option<&[u8]> { + self.raw_path.as_deref() + } + + pub fn sibling(&self, file_name: &str) -> Self { + let display_path = sibling_path(self.display_path().as_bytes(), file_name.as_bytes()) + .map_or_else( + || file_name.to_string(), + |bytes| String::from_utf8_lossy(&bytes).into_owned(), + ); + let raw_path = self + .raw_path() + .and_then(|path| sibling_path(path, file_name.as_bytes())); + Self { + display_path, + raw_path, + } + } +} + +fn sibling_path(path: &[u8], file_name: &[u8]) -> Option> { + if file_name.is_empty() { + return None; + } + let parent = path + .iter() + .rposition(|byte| *byte == b'/') + .map(|index| &path[..index]) + .unwrap_or_default(); + let mut sibling = Vec::with_capacity(parent.len() + file_name.len() + 1); + if parent.is_empty() { + if path.starts_with(b"/") { + sibling.push(b'/'); + } + } else { + sibling.extend_from_slice(parent); + sibling.push(b'/'); + } + sibling.extend_from_slice(file_name); + Some(sibling) +} + +pub fn raw_path_token(raw_path: &[u8]) -> String { + URL_SAFE_NO_PAD.encode(raw_path) +} + +pub fn decode_raw_path_token(token: &str) -> AppResult> { + URL_SAFE_NO_PAD + .decode(token) + .map_err(|error| AppError::Config(format!("Invalid remote path token: {error}"))) +} + +#[derive(Debug, Clone, Serialize)] +pub struct FileProperties { + pub name: String, + pub is_dir: bool, + pub is_symlink: bool, + pub symlink_target: Option, + pub size: u64, + pub permissions: String, + pub owner: String, + pub group: String, + pub uid: String, + pub gid: String, + pub mtime: u64, + pub atime: u64, +} + +#[derive(Debug, Clone, Default, Deserialize)] +pub struct RemoteFileAttributeUpdate { + pub mode: Option, + pub owner: Option, + pub group: Option, + #[serde(default)] + pub recursive: bool, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RemoteTextFile { + pub path: String, + pub content: String, + pub size: u64, + pub mtime: u64, + #[serde(skip_serializing_if = "Option::is_none")] + pub mtime_nanos: Option, + pub content_hash: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TextFileUnsupportedReason { + Binary, + UnsupportedEncoding, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(tag = "status", rename_all = "snake_case")] +pub enum TextFileOpenResult { + Text { file: RemoteTextFile }, + Unsupported { reason: TextFileUnsupportedReason }, +} + +pub fn classify_text_file(file: RemoteBinaryFile) -> TextFileOpenResult { + if file.content_bytes.contains(&0) { + return TextFileOpenResult::Unsupported { + reason: TextFileUnsupportedReason::Binary, + }; + } + match String::from_utf8(file.content_bytes) { + Ok(content) => TextFileOpenResult::Text { + file: RemoteTextFile { + path: file.path, + size: file.size, + mtime: file.mtime, + mtime_nanos: file.mtime_nanos, + content_hash: content_hash(content.as_bytes()), + content, + }, + }, + Err(_) => TextFileOpenResult::Unsupported { + reason: TextFileUnsupportedReason::UnsupportedEncoding, + }, + } +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RemoteBinaryFile { + pub path: String, + pub content_bytes: Vec, + pub size: u64, + pub mtime: u64, + #[serde(skip_serializing_if = "Option::is_none")] + pub mtime_nanos: Option, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct WriteRemoteTextResult { + pub status: String, + pub mtime: Option, + pub size: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub mtime_nanos: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub content_hash: Option, +} + +impl WriteRemoteTextResult { + pub fn saved(mtime: u64, size: u64, mtime_nanos: Option, content_hash: String) -> Self { + Self { + status: "saved".to_string(), + mtime: Some(mtime), + size: Some(size), + mtime_nanos, + content_hash: Some(content_hash), + } + } + + pub fn conflict(mtime: u64, size: u64, mtime_nanos: Option) -> Self { + Self { + status: "conflict".to_string(), + mtime: Some(mtime), + size: Some(size), + mtime_nanos, + content_hash: None, + } + } +} + +pub fn content_hash(bytes: &[u8]) -> String { + let mut hasher = Sha256::new(); + hasher.update(bytes); + hex::encode(hasher.finalize()) +} + +pub fn ensure_text_bytes(bytes: &[u8], max_bytes: u64) -> AppResult<()> { + if bytes.len() as u64 > max_bytes { + return Err(AppError::Config(format!( + "File is too large to open as text ({} bytes > {} bytes)", + bytes.len(), + max_bytes + ))); + } + if bytes.contains(&0) { + return Err(AppError::Config( + "Binary files are not supported by the built-in editor".to_string(), + )); + } + Ok(()) +} + +/// POSIX shell-safe quoting: wraps `input` in single quotes and escapes any +/// embedded single-quote characters. An empty string returns `''`. +pub fn sh_quote(input: &str) -> String { + if input.is_empty() { + return "''".to_string(); + } + let escaped = input.replace('\'', "'\\''"); + format!("'{}'", escaped) +} + +pub fn remote_dir_listing_path(path: &str) -> String { + if path == "/" || path.ends_with('/') { + path.to_string() + } else { + format!("{path}/") + } +} + +/// Convert a POSIX permission bitmask to the classic `ls -l` string like `-rwxr-xr-x`. +pub fn permissions_to_string(mode: u32, type_char: char) -> String { + let mut s = String::with_capacity(10); + + s.push(type_char); + + s.push(if mode & 0o400 != 0 { 'r' } else { '-' }); + s.push(if mode & 0o200 != 0 { 'w' } else { '-' }); + s.push(match (mode & 0o100 != 0, mode & 0o4000 != 0) { + (true, true) => 's', + (false, true) => 'S', + (true, false) => 'x', + (false, false) => '-', + }); + + s.push(if mode & 0o040 != 0 { 'r' } else { '-' }); + s.push(if mode & 0o020 != 0 { 'w' } else { '-' }); + s.push(match (mode & 0o010 != 0, mode & 0o2000 != 0) { + (true, true) => 's', + (false, true) => 'S', + (true, false) => 'x', + (false, false) => '-', + }); + + s.push(if mode & 0o004 != 0 { 'r' } else { '-' }); + s.push(if mode & 0o002 != 0 { 'w' } else { '-' }); + s.push(match (mode & 0o001 != 0, mode & 0o1000 != 0) { + (true, true) => 't', + (false, true) => 'T', + (true, false) => 'x', + (false, false) => '-', + }); + + s +} + +pub fn type_char_from_mode(mode: u32) -> char { + match mode & SFTP_FILE_TYPE_MASK { + 0o040000 => 'd', + 0o120000 => 'l', + _ => '-', + } +} + +pub fn describe_permissions(mode: Option) -> String { + match mode { + Some(mode) => format!( + "{mode:#06o} ({})", + permissions_to_string(mode, type_char_from_mode(mode)) + ), + None => "none".to_string(), + } +} + +pub fn permissions_string_to_octal_mode(permissions: &str) -> Option { + let chars: Vec = permissions.chars().collect(); + if chars.len() < 10 { + return None; + } + + let mut mode = 0u32; + if chars[1] == 'r' { + mode |= 0o400; + } + if chars[2] == 'w' { + mode |= 0o200; + } + match chars[3] { + 'x' => mode |= 0o100, + 's' => mode |= 0o4100, + 'S' => mode |= 0o4000, + _ => {} + } + if chars[4] == 'r' { + mode |= 0o040; + } + if chars[5] == 'w' { + mode |= 0o020; + } + match chars[6] { + 'x' => mode |= 0o010, + 's' => mode |= 0o2010, + 'S' => mode |= 0o2000, + _ => {} + } + if chars[7] == 'r' { + mode |= 0o004; + } + if chars[8] == 'w' { + mode |= 0o002; + } + match chars[9] { + 'x' => mode |= 0o001, + 't' => mode |= 0o1001, + 'T' => mode |= 0o1000, + _ => {} + } + + Some(format!("{mode:04o}")) +} + +pub fn scp_finalize_replace_command( + tmp_path: &str, + target_path: &str, + original: Option<&FileProperties>, +) -> String { + let mut commands = Vec::new(); + if let Some(props) = original { + if let Some(owner_group) = scp_owner_group_spec(props) { + commands.push(format!( + "chown {} -- {}", + sh_quote(&owner_group), + sh_quote(tmp_path) + )); + } + if let Some(mode) = permissions_string_to_octal_mode(&props.permissions) { + commands.push(format!( + "chmod {} -- {}", + sh_quote(&mode), + sh_quote(tmp_path) + )); + } + } + commands.push(format!( + "mv -f -- {} {}", + sh_quote(tmp_path), + sh_quote(target_path) + )); + commands.join(" && ") +} + +fn scp_owner_group_spec(props: &FileProperties) -> Option { + let uid = props.uid.trim(); + let gid = props.gid.trim(); + if is_numeric_id(uid) && is_numeric_id(gid) { + return Some(format!("{uid}:{gid}")); + } + + let owner = props.owner.trim(); + let group = props.group.trim(); + if !owner.is_empty() && !group.is_empty() { + return Some(format!("{owner}:{group}")); + } + + None +} + +fn is_numeric_id(value: &str) -> bool { + !value.is_empty() && value.bytes().all(|byte| byte.is_ascii_digit()) +} + +pub fn owner_or_id(owner: &Option, uid: Option) -> String { + owner + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) + .or_else(|| uid.map(|value| value.to_string())) + .unwrap_or_default() +} + +pub fn group_or_id(group: &Option, gid: Option) -> String { + group + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) + .or_else(|| gid.map(|value| value.to_string())) + .unwrap_or_default() +} + +pub fn parse_octal_mode(mode: &str) -> crate::error::AppResult { + u32::from_str_radix(mode, 8) + .map_err(|_| crate::error::AppError::Channel(format!("Invalid octal mode: {}", mode))) +} + +pub fn sanitize_download_file_name(name: &str) -> String { + sanitize_download_file_name_for_platform(name, cfg!(windows)) +} + +pub fn append_safe_local_child_path(parent: &str, child_name: &str) -> String { + std::path::Path::new(parent) + .join(sanitize_download_file_name(child_name)) + .to_string_lossy() + .to_string() +} + +fn sanitize_download_file_name_for_platform(name: &str, windows: bool) -> String { + let base = name; + if base.is_empty() { + return "download".to_string(); + } + + let mut result = String::new(); + let mut chars = base.char_indices().peekable(); + while let Some((index, ch)) = chars.next() { + let is_last = chars.peek().is_none(); + if should_percent_encode_download_char(ch, is_last, windows) { + percent_encode_char(ch, &mut result); + } else { + result.push_str(&base[index..index + ch.len_utf8()]); + } + } + + if result.is_empty() { + result.push_str("download"); + } + + if windows && is_windows_reserved_device_name(&result) { + let mut chars = result.chars(); + if let Some(first) = chars.next() { + let mut escaped = String::new(); + percent_encode_char(first, &mut escaped); + escaped.push_str(chars.as_str()); + result = escaped; + } + } + + result +} + +fn should_percent_encode_download_char(ch: char, is_last: bool, windows: bool) -> bool { + ch == '%' + || ch == '/' + || ch == '\0' + || ch.is_control() + || (windows + && (matches!(ch, '<' | '>' | ':' | '"' | '\\' | '|' | '?' | '*') + || (is_last && matches!(ch, ' ' | '.')))) +} + +fn percent_encode_char(ch: char, output: &mut String) { + let mut bytes = [0u8; 4]; + for byte in ch.encode_utf8(&mut bytes).as_bytes() { + output.push('%'); + output.push_str(&format!("{byte:02X}")); + } +} + +fn is_windows_reserved_device_name(name: &str) -> bool { + let stem = name.split('.').next().unwrap_or(name); + let upper = stem.to_ascii_uppercase(); + matches!(upper.as_str(), "CON" | "PRN" | "AUX" | "NUL") + || (upper.len() == 4 + && (upper.starts_with("COM") || upper.starts_with("LPT")) + && upper.as_bytes()[3].is_ascii_digit() + && upper.as_bytes()[3] != b'0') +} + +#[cfg(test)] +mod tests { + use super::{ + FileProperties, RemoteBinaryFile, RemotePathRef, TextFileOpenResult, + TextFileUnsupportedReason, classify_text_file, content_hash, decode_raw_path_token, + permissions_string_to_octal_mode, raw_path_token, sanitize_download_file_name_for_platform, + scp_finalize_replace_command, + }; + + #[test] + fn text_open_classifies_binary_and_invalid_utf8() { + let file = |content_bytes: Vec| RemoteBinaryFile { + path: "/tmp/file".to_string(), + size: content_bytes.len() as u64, + mtime: 1, + mtime_nanos: None, + content_bytes, + }; + assert!(matches!( + classify_text_file(file(b"hello\0world".to_vec())), + TextFileOpenResult::Unsupported { + reason: TextFileUnsupportedReason::Binary + } + )); + assert!(matches!( + classify_text_file(file(vec![0xff, 0xfe])), + TextFileOpenResult::Unsupported { + reason: TextFileUnsupportedReason::UnsupportedEncoding + } + )); + let opened = classify_text_file(file(b"hello".to_vec())); + assert!(matches!( + opened, + TextFileOpenResult::Text { ref file } + if file.content == "hello" && file.content_hash == content_hash(b"hello") + )); + } + + #[test] + fn raw_path_token_round_trips_non_utf8_bytes() { + let raw_path = b"/home/user/\xce\xc4\xbc\xfe.txt"; + let token = raw_path_token(raw_path); + assert_eq!(decode_raw_path_token(&token).unwrap(), raw_path); + } + + #[test] + fn remote_path_ref_prefers_token_bytes() { + let raw_path = b"/remote/\x80name"; + let token = raw_path_token(raw_path); + let path_ref = RemotePathRef::new("/remote/display-name", Some(&token)).unwrap(); + + assert_eq!(path_ref.display_path(), "/remote/display-name"); + assert_eq!(path_ref.raw_path().unwrap(), raw_path); + } + + #[test] + fn remote_path_sibling_preserves_raw_parent_bytes() { + let raw_path = b"/remote/\x80dir/\x81link"; + let token = raw_path_token(raw_path); + let path_ref = + RemotePathRef::new("/remote/display-dir/display-link", Some(&token)).unwrap(); + let sibling = path_ref.sibling(".nyaterm-link-test"); + + assert_eq!( + sibling.raw_path().unwrap(), + b"/remote/\x80dir/.nyaterm-link-test" + ); + assert_eq!( + sibling.display_path(), + "/remote/display-dir/.nyaterm-link-test" + ); + } + + #[test] + fn percent_encodes_windows_invalid_characters() { + assert_eq!( + sanitize_download_file_name_for_platform("a:c\"d|e?f*.txt", true), + "a%3Cb%3E%3Ac%22d%7Ce%3Ff%2A.txt" + ); + assert_eq!( + sanitize_download_file_name_for_platform("foo\\bar.txt", true), + "foo%5Cbar.txt" + ); + } + + #[test] + fn encodes_windows_reserved_device_names() { + assert_eq!( + sanitize_download_file_name_for_platform("CON.txt", true), + "%43ON.txt" + ); + assert_eq!( + sanitize_download_file_name_for_platform("nul", true), + "%6Eul" + ); + assert_eq!( + sanitize_download_file_name_for_platform("LPT1.log", true), + "%4CPT1.log" + ); + } + + #[test] + fn encodes_windows_trailing_dot_and_space() { + assert_eq!( + sanitize_download_file_name_for_platform("file. ", true), + "file.%20" + ); + assert_eq!( + sanitize_download_file_name_for_platform("file.", true), + "file%2E" + ); + } + + #[test] + fn preserves_readable_safe_characters() { + assert_eq!( + sanitize_download_file_name_for_platform("中文 name-_.txt", true), + "中文 name-_.txt" + ); + } + + #[test] + fn encodes_percent_to_keep_generated_sequences_unambiguous() { + assert_eq!( + sanitize_download_file_name_for_platform("100%.txt", true), + "100%25.txt" + ); + } + + #[test] + fn unix_rules_are_minimal() { + assert_eq!( + sanitize_download_file_name_for_platform("a:b?c*.txt", false), + "a:b?c*.txt" + ); + assert_eq!( + sanitize_download_file_name_for_platform("100%.txt", false), + "100%25.txt" + ); + } + + #[test] + fn parses_ls_permissions_to_octal_mode() { + assert_eq!( + permissions_string_to_octal_mode("-rw-r--r--").as_deref(), + Some("0644") + ); + assert_eq!( + permissions_string_to_octal_mode("-rwxr-xr-x").as_deref(), + Some("0755") + ); + assert_eq!( + permissions_string_to_octal_mode("-rwsr-sr-t").as_deref(), + Some("7755") + ); + assert_eq!( + permissions_string_to_octal_mode("-rwSr-Sr-T").as_deref(), + Some("7644") + ); + assert_eq!(permissions_string_to_octal_mode("bad"), None); + } + + #[test] + fn scp_finalize_replace_prefers_numeric_uid_gid() { + let props = file_properties("-rwsr-sr-t", "kang", "kang", "1000", "1001"); + let command = scp_finalize_replace_command("/tmp/new", "/etc/app.conf", Some(&props)); + + assert!(command.contains("chown '1000:1001' -- '/tmp/new'")); + assert!(command.contains("chmod '7755' -- '/tmp/new'")); + assert!(command.ends_with("mv -f -- '/tmp/new' '/etc/app.conf'")); + } + + #[test] + fn scp_finalize_replace_falls_back_to_owner_group_names() { + let props = file_properties("-rw-r--r--", "kang", "staff", "kang", "staff"); + let command = scp_finalize_replace_command("/tmp/new", "/home/kang/file", Some(&props)); + + assert!(command.contains("chown 'kang:staff' -- '/tmp/new'")); + assert!(command.contains("chmod '0644' -- '/tmp/new'")); + } + + #[test] + fn scp_finalize_replace_orders_chown_before_chmod_before_mv() { + let props = file_properties("-rwSr-Sr-T", "kang", "staff", "1000", "1001"); + let command = scp_finalize_replace_command("/tmp/new", "/home/kang/file", Some(&props)); + + let chown = command.find("chown").unwrap(); + let chmod = command.find("chmod").unwrap(); + let mv = command.find("mv -f").unwrap(); + assert!(chown < chmod); + assert!(chmod < mv); + } + + #[test] + fn scp_finalize_replace_skips_unparseable_mode() { + let props = file_properties("bad", "kang", "staff", "1000", "1001"); + let command = scp_finalize_replace_command("/tmp/new", "/home/kang/file", Some(&props)); + + assert!(command.contains("chown '1000:1001' -- '/tmp/new'")); + assert!(!command.contains("chmod")); + assert!(command.ends_with("mv -f -- '/tmp/new' '/home/kang/file'")); + } + + fn file_properties( + permissions: &str, + owner: &str, + group: &str, + uid: &str, + gid: &str, + ) -> FileProperties { + FileProperties { + name: "file".to_string(), + is_dir: false, + is_symlink: false, + symlink_target: None, + size: 0, + permissions: permissions.to_string(), + owner: owner.to_string(), + group: group.to_string(), + uid: uid.to_string(), + gid: gid.to_string(), + mtime: 0, + atime: 0, + } + } +} diff --git a/src-tauri/crates/nyaterm-core/src/ssh/mod.rs b/src-tauri/crates/nyaterm-core/src/ssh/mod.rs new file mode 100644 index 00000000..c6a58414 --- /dev/null +++ b/src-tauri/crates/nyaterm-core/src/ssh/mod.rs @@ -0,0 +1,6 @@ +//! Shared protocol operations; runtime ownership belongs to the transport adapter. +pub mod algorithms; +pub mod protocol; +pub mod terminal; + +pub mod files; diff --git a/src-tauri/crates/nyaterm-core/src/ssh/protocol.rs b/src-tauri/crates/nyaterm-core/src/ssh/protocol.rs new file mode 100644 index 00000000..1dfebb1d --- /dev/null +++ b/src-tauri/crates/nyaterm-core/src/ssh/protocol.rs @@ -0,0 +1,122 @@ +use crate::error::{AppError, AppResult}; +use russh::{ChannelMsg, client}; +use std::{sync::Arc, time::Duration}; +use tokio::time::timeout; + +pub async fn connect( + config: Arc, + host: &str, + port: u16, + handler: H, +) -> Result, H::Error> { + client::connect(config, (host, port), handler).await +} + +pub async fn password( + handle: &mut client::Handle, + username: &str, + secret: &str, +) -> Result { + handle.authenticate_password(username, secret).await +} + +/// Preserve the existing Desktop request/reply timeout and rejection semantics. +pub async fn wait_channel_request_reply( + channel: &mut russh::Channel, + request_name: &str, +) -> AppResult<()> { + timeout(Duration::from_secs(10), async { + loop { + match channel.wait().await { + Some(ChannelMsg::Success) => return Ok(()), + Some(ChannelMsg::Failure) => { + return Err(AppError::Channel(format!( + "{request_name} request rejected by server" + ))); + } + Some(ChannelMsg::Close | ChannelMsg::Eof) | None => { + return Err(AppError::Channel(format!( + "SSH channel closed before {request_name} request completed" + ))); + } + Some(_) => {} + } + } + }) + .await + .map_err(|_| AppError::Channel(format!("{request_name} request timed out")))? +} + +pub async fn initialize_sftp( + stream: S, + config: russh_sftp::client::Config, +) -> Result +where + S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static, +{ + russh_sftp::client::SftpSession::new_with_config(stream, config).await +} + +pub async fn open_shell( + handle: &mut client::Handle, + cols: u32, + rows: u32, +) -> AppResult> { + open_shell_with_terminal(handle, cols, rows, "xterm-256color").await +} + +pub async fn open_shell_with_terminal( + handle: &mut client::Handle, + cols: u32, + rows: u32, + terminal: &str, +) -> AppResult> { + let mut channel = handle.channel_open_session().await?; + let result = async { + channel + .request_pty(true, terminal, cols, rows, 0, 0, &[]) + .await?; + wait_channel_request_reply(&mut channel, "PTY").await?; + channel.request_shell(true).await?; + wait_channel_request_reply(&mut channel, "Shell").await + } + .await; + if let Err(error) = result { + let _ = channel.close().await; + return Err(error); + } + Ok(channel) +} + +pub async fn open_sftp( + handle: &mut client::Handle, +) -> AppResult { + open_sftp_with_config(handle, Default::default()).await +} + +pub async fn open_sftp_with_config( + handle: &mut client::Handle, + config: russh_sftp::client::Config, +) -> AppResult { + let mut channel = handle.channel_open_session().await?; + channel.request_subsystem(true, "sftp").await?; + wait_channel_request_reply(&mut channel, "SFTP").await?; + Ok(initialize_sftp(channel.into_stream(), config).await?) +} + +pub async fn keyboard_start( + handle: &mut client::Handle, + username: &str, +) -> Result { + handle + .authenticate_keyboard_interactive_start(username, None) + .await +} +pub async fn keyboard_respond( + handle: &mut client::Handle, + responses: Vec, +) -> Result { + handle + .authenticate_keyboard_interactive_respond(responses) + .await +} diff --git a/src-tauri/crates/nyaterm-core/src/ssh/terminal.rs b/src-tauri/crates/nyaterm-core/src/ssh/terminal.rs new file mode 100644 index 00000000..cb97e0ed --- /dev/null +++ b/src-tauri/crates/nyaterm-core/src/ssh/terminal.rs @@ -0,0 +1,74 @@ +use russh::{Channel, ChannelMsg, client}; +use tokio::sync::mpsc; +use tokio_util::sync::CancellationToken; + +pub enum Command { + Input(Vec), + RawInput(Vec), + Resize(u32, u32), + Close, +} +pub enum Output { + Data(Vec), + Closed, + Error, + Failure(String), +} +pub const CHUNK_BYTES: usize = 64 * 1024; +pub const OUTPUT_CAPACITY: usize = 32; + +pub async fn write(channel: &Channel, bytes: &[u8]) -> Result<(), russh::Error> { + channel.data(bytes).await +} +pub async fn resize( + channel: &Channel, + cols: u32, + rows: u32, +) -> Result<(), russh::Error> { + channel.window_change(cols, rows, 0, 0).await +} + +/// Bounded output (at most 2 MiB); saturation backpressures the SSH channel. +/// Cancellation interrupts output waits and channel writes, including detached peers. +pub async fn run( + mut channel: Channel, + mut input: mpsc::Receiver, + output: mpsc::Sender, + cancel: CancellationToken, +) { + let transfer = async { + loop { + tokio::select! { + command = input.recv() => match command { + Some(Command::Input(data) | Command::RawInput(data)) => write(&channel, &data).await?, + Some(Command::Resize(cols, rows)) => resize(&channel, cols, rows).await?, + Some(Command::Close) | None => break, + }, + message = channel.wait() => match message { + Some(ChannelMsg::Data { data } | ChannelMsg::ExtendedData { data, .. }) => { + for chunk in data.chunks(CHUNK_BYTES) { + if output.send(Output::Data(chunk.to_vec())).await.is_err() { return Ok::<(), russh::Error>(()); } + } + } + Some(ChannelMsg::Close | ChannelMsg::Eof) | None => break, + _ => {}, + } + } + } + Ok::<(), russh::Error>(()) + }; + let failed = + tokio::select! { _ = cancel.cancelled() => false, result = transfer => result.is_err() }; + let _ = tokio::time::timeout(std::time::Duration::from_secs(2), channel.close()).await; + // Preserve ordering after buffered data. Sender drop also signals EOF if a + // detached consumer cannot make room before the bounded shutdown deadline. + let _ = tokio::time::timeout( + std::time::Duration::from_secs(10), + output.send(if failed { + Output::Error + } else { + Output::Closed + }), + ) + .await; +} 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/src/storage/credentials.rs b/src-tauri/crates/nyaterm-core/src/storage/credentials.rs similarity index 100% rename from src-tauri/src/storage/credentials.rs rename to src-tauri/crates/nyaterm-core/src/storage/credentials.rs diff --git a/src-tauri/src/storage/history.rs b/src-tauri/crates/nyaterm-core/src/storage/history.rs similarity index 100% rename from src-tauri/src/storage/history.rs rename to src-tauri/crates/nyaterm-core/src/storage/history.rs diff --git a/src-tauri/src/storage/known_hosts.rs b/src-tauri/crates/nyaterm-core/src/storage/known_hosts.rs similarity index 99% rename from src-tauri/src/storage/known_hosts.rs rename to src-tauri/crates/nyaterm-core/src/storage/known_hosts.rs index d512a080..2eb614f9 100644 --- a/src-tauri/src/storage/known_hosts.rs +++ b/src-tauri/crates/nyaterm-core/src/storage/known_hosts.rs @@ -14,7 +14,7 @@ use super::util::*; #[derive(Debug, Clone, Serialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] -pub(crate) struct KnownHostEntry { +pub struct KnownHostEntry { pub id: String, pub marker: Option, pub host_identifier: String, diff --git a/src-tauri/src/storage/master_key.rs b/src-tauri/crates/nyaterm-core/src/storage/master_key.rs similarity index 100% rename from src-tauri/src/storage/master_key.rs rename to src-tauri/crates/nyaterm-core/src/storage/master_key.rs diff --git a/src-tauri/src/storage/migration.rs b/src-tauri/crates/nyaterm-core/src/storage/migration.rs similarity index 100% rename from src-tauri/src/storage/migration.rs rename to src-tauri/crates/nyaterm-core/src/storage/migration.rs diff --git a/src-tauri/crates/nyaterm-core/src/storage/mod.rs b/src-tauri/crates/nyaterm-core/src/storage/mod.rs new file mode 100644 index 00000000..c87fa355 --- /dev/null +++ b/src-tauri/crates/nyaterm-core/src/storage/mod.rs @@ -0,0 +1,448 @@ +//! redb-backed repository for `NyaTerm`'s user data. + +#![allow(dead_code)] + +pub mod backup; +mod credentials; +mod history; +mod known_hosts; +mod master_key; +mod migration; +mod notes; +mod rdp_known_hosts; +mod sessions; +mod settings_impl; +mod tables; +mod util; +mod vnc_known_hosts; + +#[cfg(test)] +mod tests; + +pub use known_hosts::KnownHostEntry; +pub use rdp_known_hosts::RdpCertificateMetadata; + +#[allow(unused_imports)] +pub use tables::{ + COMMAND_HISTORY_TABLE, CONNECTIONS_TABLE, CREDENTIALS_TABLE, GROUPS_TABLE, + IDX_CONNECTIONS_BY_GROUP_TABLE, IDX_CONNECTIONS_BY_LAST_USED_TABLE, + IDX_CONNECTIONS_BY_PROTOCOL_TABLE, KNOWN_HOSTS_TABLE, META_TABLE, NOTE_FOLDERS_TABLE, + NOTES_TABLE, OTP_ACCOUNTS_TABLE, PROXIES_TABLE, RDP_KNOWN_HOSTS_TABLE, SETTINGS_TABLE, + TUNNELS_TABLE, VNC_KNOWN_HOSTS_TABLE, +}; + +use crate::error::{AppError, AppResult}; +use redb::Database; +use serde::{Serialize, de::DeserializeOwned}; +use std::fs; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, OnceLock}; + +use migration::backup_database_before_migration_if_needed; +use tables::DATABASE_FILE; +use util::storage_error; + +static STORAGE: OnceLock> = OnceLock::new(); + +#[derive(Debug)] +pub struct Storage { + pub(super) db: Database, + pub(super) db_path: PathBuf, + pub(super) pending_migration_backup: Option, +} + +#[derive(Debug, Clone)] +pub(super) struct BackupInfo { + pub(super) path: PathBuf, + pub(super) created_at_ms: u64, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SettingsDocKey { + AppSettings, + QuickCommands, + CloudSyncSettings, + CloudSyncState, + AiHistory, + AiAudit, + SftpFileBackendCache, + WindowState, + ProxyGroups, + TunnelGroups, +} +impl SettingsDocKey { + fn storage_key(self) -> &'static str { + match self { + Self::AppSettings => tables::SETTINGS_DEFAULT, + Self::QuickCommands => "settings/doc/quick-command", + Self::CloudSyncSettings => "settings/doc/cloud-sync", + Self::CloudSyncState => "settings/doc/cloud-sync-state", + Self::AiHistory => "settings/doc/ai-history", + Self::AiAudit => "settings/doc/ai-audit", + Self::SftpFileBackendCache => "settings/doc/file-backend-cache", + Self::WindowState => "settings/doc/window-state", + Self::ProxyGroups => "settings/doc/proxy-groups", + Self::TunnelGroups => "settings/doc/tunnel-groups", + } + } + + fn legacy_key(self) -> Option<&'static str> { + match self { + Self::AppSettings => Some(tables::LEGACY_JSON_SETTINGS), + Self::QuickCommands => Some(tables::LEGACY_JSON_QUICK_COMMAND), + Self::CloudSyncSettings => Some(tables::LEGACY_JSON_CLOUD_SYNC), + Self::CloudSyncState => Some(tables::LEGACY_JSON_CLOUD_SYNC_STATE), + Self::AiHistory => Some(tables::LEGACY_JSON_AI_HISTORY), + Self::AiAudit => Some(tables::LEGACY_JSON_AI_AUDIT), + Self::SftpFileBackendCache => Some("file-backend-cache"), + Self::WindowState | Self::ProxyGroups | Self::TunnelGroups => None, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum KnownHostCheck { + Match, + HostSeen, + UnknownHost, +} + +pub fn init(config_dir: &Path) -> AppResult<()> { + fs::create_dir_all(config_dir)?; + let storage = Arc::new(Storage::open(config_dir)?); + if STORAGE.set(storage).is_err() { + tracing::debug!("redb storage was already initialized"); + } + Ok(()) +} + +#[cfg(test)] +fn database_path(config_dir: &Path) -> PathBuf { + config_dir.join(DATABASE_FILE) +} + +pub fn load_settings_doc(key: SettingsDocKey) -> AppResult { + Ok(storage()?.get_settings_doc(key)?.unwrap_or_default()) +} + +pub fn save_settings_doc(key: SettingsDocKey, value: &T) -> AppResult<()> { + storage()?.save_settings_doc(key, value) +} + +pub fn update_settings_doc(key: SettingsDocKey, updater: F) -> AppResult +where + T: DeserializeOwned + Default + Serialize, + F: FnOnce(&mut T) -> AppResult, +{ + storage()?.update_settings_doc(key, updater) +} + +pub fn load_sessions() -> AppResult { + storage()?.load_sessions() +} + +pub fn replace_sessions(config: &crate::config::SessionsConfig) -> AppResult<()> { + storage()?.replace_sessions(config) +} + +pub fn get_connection(connection_id: &str) -> AppResult> { + storage()?.get_connection_with_secret(connection_id) +} + +pub fn get_connection_without_secret( + connection_id: &str, +) -> AppResult> { + storage()?.get_connection(connection_id) +} + +pub fn mark_connection_used(connection_id: &str) -> AppResult<()> { + storage()?.mark_connection_used(connection_id) +} + +pub fn list_passwords() -> AppResult> { + storage()?.list_passwords() +} + +pub fn replace_passwords(config: &crate::config::PasswordsConfig) -> AppResult<()> { + storage()?.replace_passwords(config) +} + +pub fn list_ssh_keys() -> AppResult> { + storage()?.list_ssh_keys() +} + +pub fn replace_ssh_keys(config: &crate::config::KeysConfig) -> AppResult<()> { + storage()?.replace_ssh_keys(config) +} + +pub fn list_credentials() -> AppResult> { + storage()?.list_credentials() +} + +pub fn replace_credentials(config: &crate::config::CredentialsConfig) -> AppResult<()> { + storage()?.replace_credentials(config) +} + +pub fn list_otp_accounts() -> AppResult> { + storage()?.list_otp_accounts() +} + +pub fn replace_otp_accounts(config: &crate::config::OtpConfig) -> AppResult<()> { + storage()?.replace_otp_accounts(config) +} + +pub fn list_proxies() -> AppResult> { + storage()?.list_proxies() +} + +pub fn replace_proxies(proxies: &[crate::config::ProxyConfig]) -> AppResult<()> { + storage()?.replace_proxies(proxies) +} + +pub fn list_tunnels() -> AppResult> { + storage()?.list_tunnels() +} + +pub fn replace_tunnels(tunnels: &[crate::config::TunnelConfig]) -> AppResult<()> { + storage()?.replace_tunnels(tunnels) +} + +pub fn list_command_history_entries( + limit: usize, +) -> AppResult> { + storage()?.list_recent_command_history(limit) +} + +pub fn replace_command_history_entries( + entries: &[crate::core::history::HistoryEntry], +) -> AppResult<()> { + storage()?.replace_command_history(entries) +} + +pub fn list_note_folders() -> AppResult> { + storage()?.list_note_folders() +} + +pub fn list_notes() -> AppResult> { + storage()?.list_notes() +} + +pub fn list_note_summaries() -> AppResult> { + storage()?.list_note_summaries() +} + +pub fn get_note(note_id: &str) -> AppResult> { + storage()?.get_note(note_id) +} + +pub fn create_note_folder( + parent_id: Option, + name: Option, +) -> AppResult { + storage()?.create_note_folder(parent_id, name) +} + +pub fn create_note( + parent_id: Option, + title: Option, + markdown: Option, +) -> AppResult { + storage()?.create_note(parent_id, title, markdown) +} + +pub fn update_note( + note_id: &str, + title: String, + markdown: String, + expected_revision: u64, + force: bool, +) -> AppResult { + storage()?.update_note(note_id, title, markdown, expected_revision, force) +} + +pub fn rename_note_node( + node_kind: &str, + node_id: &str, + name: String, +) -> AppResult { + storage()?.rename_note_node(node_kind, node_id, name) +} + +pub fn move_note_node( + node_kind: &str, + node_id: &str, + parent_id: Option, + sort_order: i64, +) -> AppResult { + storage()?.move_note_node(node_kind, node_id, parent_id, sort_order) +} + +pub fn delete_note_node( + node_kind: &str, + node_id: &str, +) -> AppResult { + storage()?.delete_note_node(node_kind, node_id) +} + +pub fn load_notes_snapshot() -> AppResult { + storage()?.load_notes_snapshot() +} + +pub fn replace_notes_snapshot(snapshot: &crate::config::NotesSnapshot) -> AppResult<()> { + storage()?.replace_notes_snapshot(snapshot) +} + +pub fn check_known_host( + host_identifier: &str, + key_type: &str, + key_base64: &str, +) -> AppResult { + storage()?.check_known_host(host_identifier, key_type, key_base64) +} + +pub fn upsert_known_host(line: &str) -> AppResult<()> { + storage()?.upsert_known_host(line) +} + +pub fn replace_known_host_for_host(host_identifier: &str, line: &str) -> AppResult<()> { + storage()?.replace_known_host_for_host(host_identifier, line) +} + +pub fn render_known_hosts_export() -> AppResult { + storage()?.render_known_hosts_export() +} + +pub fn replace_known_hosts_export(content: &str) -> AppResult<()> { + storage()?.replace_known_hosts_export(content) +} + +pub fn list_known_hosts() -> AppResult> { + storage()?.list_known_hosts() +} + +pub fn delete_known_host(id: &str) -> AppResult<()> { + storage()?.delete_known_host(id) +} + +pub fn clear_known_hosts() -> AppResult<()> { + storage()?.clear_known_hosts() +} + +pub fn check_rdp_known_host( + host: &str, + port: u16, + sha256_fingerprint: &str, +) -> AppResult { + storage()?.check_rdp_known_host(host, port, sha256_fingerprint) +} + +pub fn upsert_rdp_known_host( + host: &str, + port: u16, + sha256_fingerprint: &str, + certificate: RdpCertificateMetadata, +) -> AppResult<()> { + storage()?.upsert_rdp_known_host(host, port, sha256_fingerprint, certificate) +} + +pub fn check_vnc_known_host( + host: &str, + port: u16, + sha256_fingerprint: &str, +) -> AppResult { + storage()?.check_vnc_known_host(host, port, sha256_fingerprint) +} + +pub fn upsert_vnc_known_host(host: &str, port: u16, sha256_fingerprint: &str) -> AppResult<()> { + storage()?.upsert_vnc_known_host(host, port, sha256_fingerprint) +} + +pub fn load_master_key_token() -> AppResult> { + storage()?.load_master_key_token() +} + +pub fn save_master_key_token(token: &str) -> AppResult<()> { + storage()?.save_master_key_token(token) +} + +fn storage() -> AppResult> { + if let Some(storage) = STORAGE.get() { + return Ok(storage.clone()); + } + let config_dir = default_config_dir()?; + init(&config_dir)?; + STORAGE + .get() + .cloned() + .ok_or_else(|| AppError::Storage("redb storage did not initialize".to_string())) +} +fn default_config_dir() -> AppResult { + #[cfg(test)] + { + return Ok( + std::env::temp_dir().join(format!("nyaterm-test-storage-{}", std::process::id())) + ); + } + + #[cfg(not(test))] + { + let home = dirs::home_dir() + .ok_or_else(|| AppError::Config("cannot determine home directory".to_string()))?; + Ok(home.join(".nyaterm")) + } +} + +impl Storage { + pub fn open(config_dir: &Path) -> AppResult { + fs::create_dir_all(config_dir)?; + Self::open_path(&config_dir.join(DATABASE_FILE)) + } + fn open_path(db_path: &Path) -> AppResult { + let pending_migration_backup = if db_path.exists() { + backup_database_before_migration_if_needed(db_path)? + } else { + None + }; + + let db = if db_path.exists() { + Database::open(db_path).map_err(storage_error)? + } else { + Database::create(db_path).map_err(storage_error)? + }; + let storage = Self { + db, + db_path: db_path.to_path_buf(), + pending_migration_backup, + }; + storage.migrate_if_needed()?; + storage.record_successful_v3_startup_and_cleanup_backups()?; + Ok(storage) + } + pub fn get_schema_version(&self) -> AppResult { + Ok(self.get_schema_version_optional()?.unwrap_or(1)) + } + pub fn set_schema_version(&self, version: u32) -> AppResult<()> { + let txn = self.db.begin_write().map_err(storage_error)?; + util::write_meta_u32(&txn, tables::META_SCHEMA_VERSION, version)?; + txn.commit().map_err(storage_error)?; + Ok(()) + } + pub fn migrate_if_needed(&self) -> AppResult<()> { + match self.get_schema_version_optional()? { + Some(version) if version >= tables::SCHEMA_VERSION => { + self.ensure_current_v3_tables()?; + return Ok(()); + } + Some(version) => { + tracing::info!(schema_version = version, "Migrating redb storage schema"); + } + None => {} + } + if self.has_legacy_data()? || self.get_schema_version_optional()?.is_some() { + self.migrate_to_v3()?; + } else { + self.initialize_v3_schema()?; + } + Ok(()) + } +} diff --git a/src-tauri/src/storage/notes.rs b/src-tauri/crates/nyaterm-core/src/storage/notes.rs similarity index 97% rename from src-tauri/src/storage/notes.rs rename to src-tauri/crates/nyaterm-core/src/storage/notes.rs index 9da20cb1..1baeed76 100644 --- a/src-tauri/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/src/storage/rdp_known_hosts.rs b/src-tauri/crates/nyaterm-core/src/storage/rdp_known_hosts.rs similarity index 100% rename from src-tauri/src/storage/rdp_known_hosts.rs rename to src-tauri/crates/nyaterm-core/src/storage/rdp_known_hosts.rs diff --git a/src-tauri/src/storage/sessions.rs b/src-tauri/crates/nyaterm-core/src/storage/sessions.rs similarity index 100% rename from src-tauri/src/storage/sessions.rs rename to src-tauri/crates/nyaterm-core/src/storage/sessions.rs diff --git a/src-tauri/src/storage/settings_impl.rs b/src-tauri/crates/nyaterm-core/src/storage/settings_impl.rs similarity index 100% rename from src-tauri/src/storage/settings_impl.rs rename to src-tauri/crates/nyaterm-core/src/storage/settings_impl.rs diff --git a/src-tauri/src/storage/tables.rs b/src-tauri/crates/nyaterm-core/src/storage/tables.rs similarity index 100% rename from src-tauri/src/storage/tables.rs rename to src-tauri/crates/nyaterm-core/src/storage/tables.rs diff --git a/src-tauri/src/storage/tests.rs b/src-tauri/crates/nyaterm-core/src/storage/tests.rs similarity index 92% rename from src-tauri/src/storage/tests.rs rename to src-tauri/crates/nyaterm-core/src/storage/tests.rs index af8ebafc..276ac5db 100644 --- a/src-tauri/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/src/storage/util.rs b/src-tauri/crates/nyaterm-core/src/storage/util.rs similarity index 100% rename from src-tauri/src/storage/util.rs rename to src-tauri/crates/nyaterm-core/src/storage/util.rs diff --git a/src-tauri/src/storage/vnc_known_hosts.rs b/src-tauri/crates/nyaterm-core/src/storage/vnc_known_hosts.rs similarity index 100% rename from src-tauri/src/storage/vnc_known_hosts.rs rename to src-tauri/crates/nyaterm-core/src/storage/vnc_known_hosts.rs diff --git a/src-tauri/src/core/terminal_session/telnet/auto_login.rs b/src-tauri/crates/nyaterm-core/src/telnet/auto_login.rs similarity index 83% rename from src-tauri/src/core/terminal_session/telnet/auto_login.rs rename to src-tauri/crates/nyaterm-core/src/telnet/auto_login.rs index ac45f823..3f215ae7 100644 --- a/src-tauri/src/core/terminal_session/telnet/auto_login.rs +++ b/src-tauri/crates/nyaterm-core/src/telnet/auto_login.rs @@ -89,7 +89,9 @@ impl TelnetAutoLogin { if !config.enabled { return None; } - if credentials.username.trim().is_empty() && credentials.password.as_deref().is_none_or(str::is_empty) { + if credentials.username.trim().is_empty() + && credentials.password.as_deref().is_none_or(str::is_empty) + { return None; } @@ -117,9 +119,8 @@ impl TelnetAutoLogin { return Vec::new(); } - if now.duration_since(self.started_at) > Duration::from_millis(self.config.timeout_ms) { - self.disabled = true; - return vec![TelnetAutoLoginAction::Disable]; + if let Some(action) = self.tick(now) { + return vec![action]; } self.push_tail(text); @@ -161,7 +162,11 @@ impl TelnetAutoLogin { } if !self.sent_password - && self.credentials.password.as_deref().is_some_and(|value| !value.is_empty()) + && self + .credentials + .password + .as_deref() + .is_some_and(|value| !value.is_empty()) && self.matches_password_prompt(&prompts) { self.sent_password = true; @@ -187,6 +192,18 @@ impl TelnetAutoLogin { Some(TelnetAutoLoginAction::Disable) } + pub fn tick(&mut self, now: Instant) -> Option { + if !self.disabled + && !self.completed + && now.duration_since(self.started_at) > Duration::from_millis(self.config.timeout_ms) + { + self.disabled = true; + Some(TelnetAutoLoginAction::Disable) + } else { + None + } + } + fn push_tail(&mut self, text: &str) { self.tail.push_str(text); self.tail = last_chars(&self.tail, AUTO_LOGIN_TAIL_CHARS); @@ -227,13 +244,16 @@ impl TelnetAutoLogin { fn matches_failure(&self, text: &str, last_line: &str) -> bool { self.failure_regex.as_ref().map_or_else( - || default_failure_regex().is_match(text) || default_failure_regex().is_match(last_line), + || { + default_failure_regex().is_match(text) + || default_failure_regex().is_match(last_line) + }, |regex| regex.is_match(text) || regex.is_match(last_line), ) } } -fn compile_optional_regex(pattern: Option<&str>) -> Option { +pub fn compile_optional_regex(pattern: Option<&str>) -> Option { let trimmed = pattern?.trim(); if trimmed.is_empty() { return None; @@ -241,13 +261,13 @@ fn compile_optional_regex(pattern: Option<&str>) -> Option { Regex::new(trimmed).ok() } -fn line_bytes(value: &str, enter_mode: TelnetEnterMode) -> Vec { +pub fn line_bytes(value: &str, enter_mode: TelnetEnterMode) -> Vec { let mut data = value.as_bytes().to_vec(); data.push(b'\r'); normalize_enter_bytes(&data, enter_mode) } -fn last_chars(value: &str, max_chars: usize) -> String { +pub fn last_chars(value: &str, max_chars: usize) -> String { let len = value.chars().count(); if len <= max_chars { return value.to_string(); @@ -255,7 +275,7 @@ fn last_chars(value: &str, max_chars: usize) -> String { value.chars().skip(len - max_chars).collect() } -fn last_non_empty_line(value: &str) -> String { +pub fn last_non_empty_line(value: &str) -> String { value .lines() .rev() @@ -265,7 +285,7 @@ fn last_non_empty_line(value: &str) -> String { .to_string() } -fn prompt_candidates(window: &str, current_input: &str) -> Vec { +pub fn prompt_candidates(window: &str, current_input: &str) -> Vec { let mut prompts = Vec::new(); for source in [window, current_input] { for line in source.lines() { @@ -277,13 +297,13 @@ fn prompt_candidates(window: &str, current_input: &str) -> Vec { prompts } -fn push_prompt_candidate(prompts: &mut Vec, prompt: &str) { +pub fn push_prompt_candidate(prompts: &mut Vec, prompt: &str) { if !prompt.is_empty() && !prompts.iter().any(|existing| existing == prompt) { prompts.push(prompt.to_string()); } } -fn push_prompt_suffix_candidates(prompts: &mut Vec, prompt: &str) { +pub fn push_prompt_suffix_candidates(prompts: &mut Vec, prompt: &str) { const KEYWORDS: &[&str] = &[ "user name", "username", @@ -321,7 +341,7 @@ fn push_prompt_suffix_candidates(prompts: &mut Vec, prompt: &str) { } } -fn default_username_regex() -> &'static Regex { +pub fn default_username_regex() -> &'static Regex { static REGEX: OnceLock = OnceLock::new(); REGEX.get_or_init(|| { Regex::new( @@ -331,14 +351,13 @@ fn default_username_regex() -> &'static Regex { }) } -fn last_login_regex() -> &'static Regex { +pub fn last_login_regex() -> &'static Regex { static REGEX: OnceLock = OnceLock::new(); - REGEX.get_or_init(|| { - Regex::new(r"(?i)\b(?:last|previous)\s+login\b").expect("last login regex") - }) + REGEX + .get_or_init(|| Regex::new(r"(?i)\b(?:last|previous)\s+login\b").expect("last login regex")) } -fn default_password_regex() -> &'static Regex { +pub fn default_password_regex() -> &'static Regex { static REGEX: OnceLock = OnceLock::new(); REGEX.get_or_init(|| { Regex::new( @@ -348,7 +367,7 @@ fn default_password_regex() -> &'static Regex { }) } -fn default_wake_regex() -> &'static Regex { +pub fn default_wake_regex() -> &'static Regex { static REGEX: OnceLock = OnceLock::new(); REGEX.get_or_init(|| { Regex::new(r"(?i)(press\s+(?:return||\[enter\]|enter|any\s+key))") @@ -356,15 +375,17 @@ fn default_wake_regex() -> &'static Regex { }) } -fn default_success_regex() -> &'static Regex { +pub fn default_success_regex() -> &'static Regex { static REGEX: OnceLock = OnceLock::new(); REGEX.get_or_init(|| Regex::new(r"[$#>]\s*$").expect("default success prompt regex")) } -fn default_failure_regex() -> &'static Regex { +pub fn default_failure_regex() -> &'static Regex { static REGEX: OnceLock = OnceLock::new(); REGEX.get_or_init(|| { - Regex::new(r"(?i)(login\s+incorrect|authentication\s+failed|access\s+denied|密码错误|认证失败)") - .expect("default failure prompt regex") + Regex::new( + r"(?i)(login\s+incorrect|authentication\s+failed|access\s+denied|密码错误|认证失败)", + ) + .expect("default failure prompt regex") }) } diff --git a/src-tauri/src/core/terminal_session/telnet/line_editor.rs b/src-tauri/crates/nyaterm-core/src/telnet/line_editor.rs similarity index 93% rename from src-tauri/src/core/terminal_session/telnet/line_editor.rs rename to src-tauri/crates/nyaterm-core/src/telnet/line_editor.rs index 3b127d77..205bb5d4 100644 --- a/src-tauri/src/core/terminal_session/telnet/line_editor.rs +++ b/src-tauri/crates/nyaterm-core/src/telnet/line_editor.rs @@ -1,4 +1,4 @@ -fn local_echo_text(data: &[u8]) -> String { +pub fn local_echo_text(data: &[u8]) -> String { let mut visible = Vec::with_capacity(data.len()); let mut i = 0; while i < data.len() { @@ -41,23 +41,22 @@ fn local_echo_text(data: &[u8]) -> String { } #[derive(Debug, Default)] -struct TelnetLineEditor { +pub struct TelnetLineEditor { buffer: String, } #[derive(Debug, Default, PartialEq, Eq)] -struct TelnetLineEditResult { - display: String, - writes: Vec>, +pub struct TelnetLineEditResult { + pub display: String, + pub writes: Vec>, } impl TelnetLineEditor { - #[cfg(test)] - fn buffer(&self) -> &str { + pub fn buffer(&self) -> &str { &self.buffer } - fn process(&mut self, data: &[u8], enter_mode: TelnetEnterMode) -> TelnetLineEditResult { + pub fn process(&mut self, data: &[u8], enter_mode: TelnetEnterMode) -> TelnetLineEditResult { let input = String::from_utf8_lossy(data); let mut result = TelnetLineEditResult::default(); let mut chars = input.char_indices().peekable(); @@ -116,7 +115,7 @@ impl TelnetLineEditor { } } -fn consume_escape_sequence_end( +pub fn consume_escape_sequence_end( start: usize, chars: &mut std::iter::Peekable>, ) -> usize { diff --git a/src-tauri/crates/nyaterm-core/src/telnet/mod.rs b/src-tauri/crates/nyaterm-core/src/telnet/mod.rs new file mode 100644 index 00000000..51b0364a --- /dev/null +++ b/src-tauri/crates/nyaterm-core/src/telnet/mod.rs @@ -0,0 +1,17 @@ +//! Transport-independent Telnet negotiation, editing and automatic login. +pub const IAC: u8 = 255; +pub const WILL: u8 = 251; +pub const WONT: u8 = 252; +pub const DO: u8 = 253; +pub const DONT: u8 = 254; +pub const SB: u8 = 250; +pub const SE: u8 = 240; +pub const OPT_ECHO: u8 = 1; +pub const OPT_SUPPRESS_GO_AHEAD: u8 = 3; +pub const OPT_NAWS: u8 = 31; +include!("types.rs"); +include!("negotiation.rs"); +include!("line_editor.rs"); +include!("auto_login.rs"); + +include!("tests.rs"); diff --git a/src-tauri/src/core/terminal_session/telnet/negotiation.rs b/src-tauri/crates/nyaterm-core/src/telnet/negotiation.rs similarity index 59% rename from src-tauri/src/core/terminal_session/telnet/negotiation.rs rename to src-tauri/crates/nyaterm-core/src/telnet/negotiation.rs index 8915d881..344ae4fb 100644 --- a/src-tauri/src/core/terminal_session/telnet/negotiation.rs +++ b/src-tauri/crates/nyaterm-core/src/telnet/negotiation.rs @@ -1,4 +1,4 @@ -fn negotiate_response(command: u8, option: u8, send_naws: bool, send_sga: bool) -> Vec { +pub fn negotiate_response(command: u8, option: u8, send_naws: bool, send_sga: bool) -> Vec { match command { WILL => { if option == OPT_ECHO || (send_sga && option == OPT_SUPPRESS_GO_AHEAD) { @@ -8,7 +8,7 @@ fn negotiate_response(command: u8, option: u8, send_naws: bool, send_sga: bool) } } DO => { - if send_naws && option == OPT_NAWS { + if (send_naws && option == OPT_NAWS) || (send_sga && option == OPT_SUPPRESS_GO_AHEAD) { vec![IAC, WILL, option] } else { vec![IAC, WONT, option] @@ -21,21 +21,56 @@ fn negotiate_response(command: u8, option: u8, send_naws: bool, send_sga: bool) } /// Build a NAWS (Negotiate About Window Size) subnegotiation sequence. -fn build_naws(cols: u16, rows: u16) -> Vec { - vec![ - IAC, - SB, - OPT_NAWS, - (cols >> 8) as u8, - (cols & 0xff) as u8, - (rows >> 8) as u8, - (rows & 0xff) as u8, - IAC, - SE, - ] +pub fn build_naws(cols: u16, rows: u16) -> Vec { + let mut result = vec![IAC, SB, OPT_NAWS]; + result.extend(escape_telnet_application_data( + &[(cols >> 8) as u8, cols as u8, (rows >> 8) as u8, rows as u8], + false, + )); + result.extend([IAC, SE]); + result } -fn maybe_build_naws(cols: u16, rows: u16, config: &TelnetSessionConfig) -> Option> { +/// Negotiation may span arbitrary TCP reads, including escaped IAC bytes. +#[derive(Default)] +pub struct TelnetDecoder { + state: u8, + command: u8, +} +impl TelnetDecoder { + pub fn decode(&mut self, data: &[u8], on_negotiate: &mut impl FnMut(u8, u8)) -> Vec { + let mut visible = Vec::with_capacity(data.len()); + for &byte in data { + match self.state { + 0 if byte == IAC => self.state = 1, + 0 => visible.push(byte), + 1 => match byte { + IAC => { + visible.push(IAC); + self.state = 0; + } + WILL | WONT | DO | DONT => { + self.command = byte; + self.state = 2; + } + SB => self.state = 3, + _ => self.state = 0, + }, + 2 => { + on_negotiate(self.command, byte); + self.state = 0; + } + 3 if byte == IAC => self.state = 4, + 3 => {} + 4 => self.state = if byte == SE { 0 } else { 3 }, + _ => unreachable!(), + } + } + visible + } +} + +pub fn maybe_build_naws(cols: u16, rows: u16, config: &TelnetSessionConfig) -> Option> { if config.raw_tcp_cli || !config.send_naws { None } else { @@ -43,7 +78,7 @@ fn maybe_build_naws(cols: u16, rows: u16, config: &TelnetSessionConfig) -> Optio } } -fn unescape_iac_iac(data: &[u8]) -> Vec { +pub fn unescape_iac_iac(data: &[u8]) -> Vec { let mut visible = Vec::with_capacity(data.len()); let mut i = 0; while i < data.len() { @@ -58,7 +93,7 @@ fn unescape_iac_iac(data: &[u8]) -> Vec { visible } -fn escape_telnet_application_data(data: &[u8], raw_tcp_cli: bool) -> Vec { +pub fn escape_telnet_application_data(data: &[u8], raw_tcp_cli: bool) -> Vec { if raw_tcp_cli { return data.to_vec(); } @@ -75,7 +110,7 @@ fn escape_telnet_application_data(data: &[u8], raw_tcp_cli: bool) -> Vec { /// Strip IAC sequences from raw data, returning only user-visible bytes. /// Calls `on_negotiate` for each IAC command/option pair encountered. -fn strip_telnet_commands(data: &[u8], on_negotiate: &mut impl FnMut(u8, u8)) -> Vec { +pub fn strip_telnet_commands(data: &[u8], on_negotiate: &mut impl FnMut(u8, u8)) -> Vec { let mut visible = Vec::with_capacity(data.len()); let mut i = 0; while i < data.len() { @@ -117,7 +152,7 @@ fn strip_telnet_commands(data: &[u8], on_negotiate: &mut impl FnMut(u8, u8)) -> visible } -fn normalize_enter_bytes(data: &[u8], enter_mode: TelnetEnterMode) -> Vec { +pub fn normalize_enter_bytes(data: &[u8], enter_mode: TelnetEnterMode) -> Vec { let replacement: &[u8] = match enter_mode { TelnetEnterMode::Crlf => b"\r\n", TelnetEnterMode::Cr => b"\r", @@ -134,7 +169,7 @@ fn normalize_enter_bytes(data: &[u8], enter_mode: TelnetEnterMode) -> Vec { normalized } -fn enter_bytes(enter_mode: TelnetEnterMode) -> &'static [u8] { +pub fn enter_bytes(enter_mode: TelnetEnterMode) -> &'static [u8] { match enter_mode { TelnetEnterMode::Crlf => b"\r\n", TelnetEnterMode::Cr => b"\r", @@ -142,7 +177,7 @@ fn enter_bytes(enter_mode: TelnetEnterMode) -> &'static [u8] { } } -fn split_write_chunks(data: &[u8], force_character_at_a_time: bool) -> Vec> { +pub fn split_write_chunks(data: &[u8], force_character_at_a_time: bool) -> Vec> { if !force_character_at_a_time { return vec![data.to_vec()]; } diff --git a/src-tauri/crates/nyaterm-core/src/telnet/tests.rs b/src-tauri/crates/nyaterm-core/src/telnet/tests.rs new file mode 100644 index 00000000..6bd60c9f --- /dev/null +++ b/src-tauri/crates/nyaterm-core/src/telnet/tests.rs @@ -0,0 +1,405 @@ +#[cfg(test)] +mod tests { + #[test] + fn fragmented_iac_and_subnegotiation_preserve_only_application_bytes() { + use super::{SB, SE, TelnetDecoder}; + let wire = [ + b'a', + IAC, + WILL, + OPT_SUPPRESS_GO_AHEAD, + IAC, + IAC, + IAC, + SB, + 31, + 0, + IAC, + IAC, + 3, + IAC, + SE, + b'b', + ]; + for split in 0..=wire.len() { + let mut decoder = TelnetDecoder::default(); + let mut negotiations = Vec::new(); + let mut callback = |cmd, opt| negotiations.push((cmd, opt)); + let mut bytes = decoder.decode(&wire[..split], &mut callback); + bytes.extend(decoder.decode(&wire[split..], &mut callback)); + assert_eq!(bytes, [b'a', IAC, b'b'], "split={split}"); + assert_eq!(negotiations, [(WILL, OPT_SUPPRESS_GO_AHEAD)]); + } + } + + #[test] + fn naws_escapes_255_in_each_dimension() { + assert_eq!( + super::build_naws(255, 511), + [ + IAC, + super::SB, + OPT_NAWS, + 0, + IAC, + IAC, + 1, + IAC, + IAC, + IAC, + super::SE + ] + ); + } + use super::{ + DO, IAC, OPT_NAWS, OPT_SUPPRESS_GO_AHEAD, TelnetAutoLogin, TelnetAutoLoginAction, + TelnetAutoLoginConfig, TelnetAutoLoginCredentials, TelnetEnterMode, TelnetLineEditor, + TelnetSessionConfig, WILL, escape_telnet_application_data, maybe_build_naws, + negotiate_response, normalize_enter_bytes, split_write_chunks, strip_telnet_commands, + }; + use std::time::Instant; + + #[test] + fn standard_negotiation_responds_by_default() { + assert_eq!( + negotiate_response(WILL, OPT_SUPPRESS_GO_AHEAD, true, true), + vec![IAC, DO, OPT_SUPPRESS_GO_AHEAD] + ); + } + + #[test] + fn send_sga_false_rejects_sga() { + assert_ne!( + negotiate_response(WILL, OPT_SUPPRESS_GO_AHEAD, true, false), + vec![IAC, DO, OPT_SUPPRESS_GO_AHEAD] + ); + } + + #[test] + fn send_naws_false_rejects_naws_negotiation() { + assert_ne!( + negotiate_response(DO, OPT_NAWS, false, true), + vec![IAC, WILL, OPT_NAWS] + ); + } + + #[test] + fn raw_mode_can_suppress_negotiation_responses() { + let config = TelnetSessionConfig { + raw_tcp_cli: true, + ..Default::default() + }; + let mut responses = Vec::new(); + if !config.raw_tcp_cli { + let _ = strip_telnet_commands(&[IAC, WILL, OPT_SUPPRESS_GO_AHEAD], &mut |cmd, opt| { + responses.push(negotiate_response( + cmd, + opt, + config.send_naws, + config.send_sga, + )); + }); + } + assert!(responses.is_empty()); + } + + #[test] + fn binary_input_escapes_iac_only_for_telnet_transport() { + let mouse_report = b"\x1b[M \xff\x80"; + assert_eq!( + escape_telnet_application_data(mouse_report, false), + b"\x1b[M \xff\xff\x80" + ); + assert_eq!( + escape_telnet_application_data(mouse_report, true), + mouse_report + ); + } + + #[test] + fn send_naws_false_prevents_naws_resize_payload() { + let config = TelnetSessionConfig { + send_naws: false, + ..Default::default() + }; + assert!(maybe_build_naws(80, 24, &config).is_none()); + } + + #[test] + fn raw_mode_prevents_naws_resize_payload() { + let config = TelnetSessionConfig { + raw_tcp_cli: true, + ..Default::default() + }; + assert!(maybe_build_naws(80, 24, &config).is_none()); + } + + #[test] + fn enter_conversion_maps_carriage_return() { + assert_eq!( + normalize_enter_bytes(b"show\r", TelnetEnterMode::Crlf), + b"show\r\n" + ); + assert_eq!( + normalize_enter_bytes(b"show\r", TelnetEnterMode::Cr), + b"show\r" + ); + assert_eq!( + normalize_enter_bytes(b"show\r", TelnetEnterMode::Lf), + b"show\n" + ); + } + + #[test] + fn force_character_at_a_time_preserves_utf8_order() { + let chunks = split_write_chunks("a中\r".as_bytes(), true); + assert_eq!( + chunks, + vec![b"a".to_vec(), "中".as_bytes().to_vec(), b"\r".to_vec()] + ); + let joined: Vec = chunks.into_iter().flatten().collect(); + assert_eq!(joined, "a中\r".as_bytes()); + } + + #[test] + fn strip_telnet_commands_emits_naws_response_request() { + let mut seen = Vec::new(); + let visible = strip_telnet_commands(b"hi\xff\xfd\x1f", &mut |cmd, opt| { + seen.push((cmd, opt)); + }); + assert_eq!(visible, b"hi"); + assert_eq!(seen, vec![(DO, OPT_NAWS)]); + } + + #[test] + fn local_line_editor_backspace_updates_buffer() { + let mut editor = TelnetLineEditor::default(); + let result = editor.process(b"abc\x7f", TelnetEnterMode::Cr); + + assert_eq!(editor.buffer(), "ab"); + assert_eq!(result.display, "abc\x08 \x08"); + assert!(result.writes.is_empty()); + } + + #[test] + fn local_line_editor_sends_buffer_on_enter() { + let mut editor = TelnetLineEditor::default(); + let result = editor.process(b"abc\x7fd\r", TelnetEnterMode::Cr); + + assert_eq!(editor.buffer(), ""); + assert_eq!(result.writes, vec![b"abd\r".to_vec()]); + assert_eq!(result.display, "abc\x08 \x08d\r\n"); + + let mut editor = TelnetLineEditor::default(); + let result = editor.process(b"abc\x7fd\r", TelnetEnterMode::Crlf); + assert_eq!(result.writes, vec![b"abd\r\n".to_vec()]); + + let mut editor = TelnetLineEditor::default(); + let result = editor.process(b"abc\x7fd\r", TelnetEnterMode::Lf); + assert_eq!(result.writes, vec![b"abd\n".to_vec()]); + } + + #[test] + fn local_line_editor_backspace_removes_one_utf8_char() { + let mut editor = TelnetLineEditor::default(); + let result = editor.process("中a\u{7f}".as_bytes(), TelnetEnterMode::Cr); + + assert_eq!(editor.buffer(), "中"); + assert_eq!(result.display, "中a\x08 \x08"); + + let result = editor.process(b"\x7f", TelnetEnterMode::Cr); + assert_eq!(editor.buffer(), ""); + assert_eq!(result.display, "\x08 \x08"); + } + + #[test] + fn local_line_editor_passes_controls_without_buffering() { + let mut editor = TelnetLineEditor::default(); + let result = editor.process(b"a\x03\x04\x1b[A\x1b[3~", TelnetEnterMode::Cr); + + assert_eq!(editor.buffer(), ""); + assert_eq!( + result.writes, + vec![vec![0x03], vec![0x04], b"\x1b[A".to_vec()] + ); + assert_eq!(result.display, "a\x08 \x08"); + } + + fn auto_login() -> TelnetAutoLogin { + TelnetAutoLogin::new( + TelnetAutoLoginConfig::default(), + TelnetAutoLoginCredentials { + username: "admin".to_string(), + password: Some("secret".to_string()), + }, + TelnetEnterMode::Cr, + Instant::now(), + ) + .expect("auto login") + } + + fn sent_payloads(actions: Vec) -> Vec> { + actions + .into_iter() + .filter_map(|action| match action { + TelnetAutoLoginAction::Send(data) => Some(data), + _ => None, + }) + .collect() + } + + #[test] + fn auto_login_detects_split_username_and_password_prompts() { + let mut login = auto_login(); + + assert!(login.handle_text("User", Instant::now()).is_empty()); + assert_eq!( + sent_payloads(login.handle_text("name:", Instant::now())), + vec![b"admin\r".to_vec()] + ); + assert!(login.handle_text("Pass", Instant::now()).is_empty()); + assert_eq!( + sent_payloads(login.handle_text("word:", Instant::now())), + vec![b"secret\r".to_vec()] + ); + } + + #[test] + fn auto_login_detects_chinese_prompts() { + let mut login = auto_login(); + + assert_eq!( + sent_payloads(login.handle_text("用户名:", Instant::now())), + vec![b"admin\r".to_vec()] + ); + assert_eq!( + sent_payloads(login.handle_text("密码:", Instant::now())), + vec![b"secret\r".to_vec()] + ); + } + + #[test] + fn auto_login_detects_linux_getty_host_login_prompt() { + let mut login = auto_login(); + + assert_eq!( + sent_payloads(login.handle_text( + "\r\nLinux 6.8.0-107-generic (kaikai) (pts/3)\r\n\r\nkaikai login: ", + Instant::now() + )), + vec![b"admin\r".to_vec()] + ); + } + + #[test] + fn auto_login_detects_password_prompt_without_colon() { + let mut login = auto_login(); + + assert_eq!( + sent_payloads(login.handle_text("Input Password", Instant::now())), + vec![b"secret\r".to_vec()] + ); + } + + #[test] + fn auto_login_sends_wake_enter_once() { + let mut login = auto_login(); + + assert_eq!( + sent_payloads(login.handle_text("Press RETURN to get started.", Instant::now())), + vec![b"\r".to_vec()] + ); + assert!( + login + .handle_text("Press RETURN to get started.", Instant::now()) + .is_empty() + ); + } + + #[test] + fn auto_login_does_not_treat_last_login_as_username_prompt() { + let mut login = auto_login(); + + assert!( + login + .handle_text("Last login: Mon Jul 13 12:00:00", Instant::now()) + .is_empty() + ); + assert!( + login + .handle_text("Previous login: Mon Jul 13 12:00:00", Instant::now()) + .is_empty() + ); + } + + #[test] + fn auto_login_sends_username_before_password_in_same_chunk() { + let mut login = auto_login(); + + assert_eq!( + sent_payloads(login.handle_text("Username:\nPassword:", Instant::now())), + vec![b"admin\r".to_vec(), b"secret\r".to_vec()] + ); + } + + #[test] + fn auto_login_completes_on_shell_prompt_after_credentials() { + let mut login = auto_login(); + let _ = login.handle_text("Username:", Instant::now()); + let _ = login.handle_text("Password:", Instant::now()); + + assert!( + login + .handle_text("router#", Instant::now()) + .contains(&TelnetAutoLoginAction::Complete) + ); + } + + #[test] + fn auto_login_failure_retries_then_disables() { + let mut login = TelnetAutoLogin::new( + TelnetAutoLoginConfig { + max_retries: 1, + ..TelnetAutoLoginConfig::default() + }, + TelnetAutoLoginCredentials { + username: String::new(), + password: Some("secret".to_string()), + }, + TelnetEnterMode::Cr, + Instant::now(), + ) + .expect("auto login"); + + assert_eq!( + sent_payloads(login.handle_text("Password:", Instant::now())), + vec![b"secret\r".to_vec()] + ); + assert!( + login + .handle_text("Authentication failed", Instant::now()) + .is_empty() + ); + assert_eq!( + sent_payloads(login.handle_text("Password:", Instant::now())), + vec![b"secret\r".to_vec()] + ); + assert!( + login + .handle_text("Authentication failed", Instant::now()) + .contains(&TelnetAutoLoginAction::Disable) + ); + } + + #[test] + fn auto_login_user_input_disables_but_automated_input_does_not() { + let mut login = auto_login(); + + assert_eq!(login.handle_user_input(true), None); + assert_eq!( + login.handle_user_input(false), + Some(TelnetAutoLoginAction::Disable) + ); + assert!(login.handle_text("Username:", Instant::now()).is_empty()); + } +} diff --git a/src-tauri/src/core/terminal_session/telnet/types.rs b/src-tauri/crates/nyaterm-core/src/telnet/types.rs similarity index 95% rename from src-tauri/src/core/terminal_session/telnet/types.rs rename to src-tauri/crates/nyaterm-core/src/telnet/types.rs index 1cc3310e..f0522d1f 100644 --- a/src-tauri/src/core/terminal_session/telnet/types.rs +++ b/src-tauri/crates/nyaterm-core/src/telnet/types.rs @@ -23,6 +23,7 @@ impl TelnetEnterMode { #[derive(Debug, Clone)] pub struct TelnetSessionConfig { + pub network: Option, pub host: String, pub port: u16, pub name: String, @@ -43,6 +44,7 @@ pub struct TelnetSessionConfig { impl Default for TelnetSessionConfig { fn default() -> Self { Self { + network: None, host: String::new(), port: 23, name: "Telnet".to_string(), @@ -67,4 +69,3 @@ pub struct TelnetStartupCommand { pub command: String, pub delay_ms: u64, } - diff --git a/src-tauri/crates/nyaterm-core/src/terminal_encoding.rs b/src-tauri/crates/nyaterm-core/src/terminal_encoding.rs new file mode 100644 index 00000000..0213949c --- /dev/null +++ b/src-tauri/crates/nyaterm-core/src/terminal_encoding.rs @@ -0,0 +1,185 @@ +fn remap_del_to_bs(data: &mut [u8]) { + for byte in data { + if *byte == 0x7f { + *byte = 0x08; + } + } +} +use encoding_rs::{CoderResult, Decoder, Encoding, UTF_8}; + +pub fn terminal_encoding(label: &str) -> &'static Encoding { + let trimmed = label.trim(); + if trimmed.is_empty() { + return UTF_8; + } + Encoding::for_label(trimmed.as_bytes()).unwrap_or(UTF_8) +} + +pub struct TerminalOutputDecoder { + decoder: Decoder, +} + +impl TerminalOutputDecoder { + pub fn new(encoding: &str) -> Self { + Self { + decoder: terminal_encoding(encoding).new_decoder_without_bom_handling(), + } + } + + pub fn decode(&mut self, data: &[u8]) -> String { + let capacity = self + .decoder + .max_utf8_buffer_length(data.len()) + .unwrap_or_else(|| data.len().saturating_mul(4)); + let mut output = String::with_capacity(capacity); + let mut total_read = 0; + while total_read < data.len() { + let (result, read, _) = + self.decoder + .decode_to_string(&data[total_read..], &mut output, false); + total_read += read; + match result { + CoderResult::InputEmpty => break, + CoderResult::OutputFull => { + output.reserve( + self.decoder + .max_utf8_buffer_length(data.len() - total_read) + .unwrap_or_else(|| (data.len() - total_read).saturating_mul(4)) + .max(4), + ); + } + } + } + output + } +} + +pub fn encode_terminal_input(data: &[u8], encoding: &str) -> Vec { + let target = terminal_encoding(encoding); + if target == UTF_8 { + return data.to_vec(); + } + + let mut result = Vec::with_capacity(data.len()); + let mut i = 0; + while i < data.len() { + if data[i] == 0x1b { + let end = ansi_sequence_end(data, i); + result.extend_from_slice(&data[i..end]); + i = end; + continue; + } + + if data[i].is_ascii() { + result.push(data[i]); + i += 1; + continue; + } + + let start = i; + while i < data.len() && !data[i].is_ascii() && data[i] != 0x1b { + i += 1; + } + + let text = String::from_utf8_lossy(&data[start..i]); + let (encoded, _, _) = target.encode(&text); + result.extend_from_slice(&encoded); + } + + result +} + +pub fn prepare_terminal_write_input( + mut data: Vec, + encoding: &str, + raw: bool, + backspace_as_bs: bool, +) -> Vec { + if raw { + return data; + } + if backspace_as_bs { + remap_del_to_bs(&mut data); + } + encode_terminal_input(&data, encoding) +} + +fn ansi_sequence_end(data: &[u8], start: usize) -> usize { + let mut i = start + 1; + if i >= data.len() { + return i; + } + + if matches!(data[i], b'[' | b']' | b'(' | b')' | b'#') { + i += 1; + while i < data.len() { + let byte = data[i]; + i += 1; + if (0x40..=0x7e).contains(&byte) { + break; + } + } + return i; + } + + (i + 1).min(data.len()) +} + +#[cfg(test)] +mod tests { + use super::{TerminalOutputDecoder, encode_terminal_input, prepare_terminal_write_input}; + + #[test] + fn write_input_encodes_text_but_preserves_raw_bytes_and_del() { + assert_eq!( + prepare_terminal_write_input("测\u{7f}".as_bytes().to_vec(), "GBK", false, true), + vec![0xB2, 0xE2, 0x08] + ); + let raw = vec![0x1b, b'[', b'M', b' ', 0x80, 0xff, 0x7f]; + assert_eq!( + prepare_terminal_write_input(raw.clone(), "GBK", true, true), + raw + ); + } + + #[test] + fn gbk_decoder_preserves_split_multibyte_character() { + let mut decoder = TerminalOutputDecoder::new("GBK"); + assert_eq!(decoder.decode(&[0xB2]), ""); + assert_eq!(decoder.decode(&[0xE2]), "测"); + } + + #[test] + fn gb18030_decoder_preserves_split_multibyte_character() { + let mut decoder = TerminalOutputDecoder::new("GB18030"); + assert_eq!(decoder.decode(&[0xB2]), ""); + assert_eq!(decoder.decode(&[0xE2]), "测"); + } + + #[test] + fn gbk_decoder_handles_utf8_output_expansion() { + let mut decoder = TerminalOutputDecoder::new("GBK"); + let mut input = Vec::new(); + for _ in 0..1024 { + input.extend_from_slice(&[0xB2, 0xE2]); + } + + let output = decoder.decode(&input); + + assert_eq!(output.chars().count(), 1024); + assert!(output.chars().all(|ch| ch == '测')); + } + + #[test] + fn input_encoder_keeps_controls_and_ansi_sequences_raw() { + let input = b"\x1b[200~\x7f\xe6\xb5\x8b\x1b[201~"; + let encoded = encode_terminal_input(input, "GBK"); + assert_eq!( + encoded, + vec![ + 0x1b, b'[', b'2', b'0', b'0', b'~', 0x7f, 0xB2, 0xE2, 0x1b, b'[', b'2', b'0', b'1', + b'~' + ] + ); + } +} diff --git a/src-tauri/crates/nyaterm-core/src/utils/crypto.rs b/src-tauri/crates/nyaterm-core/src/utils/crypto.rs new file mode 100644 index 00000000..7b69f93a --- /dev/null +++ b/src-tauri/crates/nyaterm-core/src/utils/crypto.rs @@ -0,0 +1,507 @@ +//! AES-256-GCM encryption for SSH credentials stored in redb JSON documents. +//! +//! ## Key hierarchy +//! +//! ```text +//! wrapping_key = SHA-256("nyaterm-key-wrap-v1:" || master_password_or_home_path) +//! master.key = redb text doc containing base64( wrap_nonce[12] || AES-256-GCM(wrapping_key, master_key[32]) ) +//! sessions doc = { "password": base64( nonce[12] || AES-256-GCM(master_key, plaintext) ), … } +//! ``` +//! +//! When a master password is configured the wrapping key is derived from it; +//! otherwise installed mode uses the user's home directory path as the key +//! material, while portable mode uses `data/config/portable.key`. +//! +//! The master password itself is stored in the settings document encrypted with the +//! home-path-derived key (via [`encrypt_settings_secret`]) to avoid a circular +//! dependency during bootstrap. + +use crate::error::{AppError, AppResult}; +use aes_gcm::aead::{Aead, OsRng}; +use aes_gcm::{AeadCore, Aes256Gcm, Key, KeyInit}; +use base64::Engine; +use base64::engine::general_purpose::STANDARD as B64; +use sha2::{Digest, Sha256}; +use std::path::PathBuf; +use std::sync::RwLock; + +const WRAPPING_KEY_PREFIX: &[u8] = b"nyaterm-key-wrap-v1:"; +const LEGACY_WRAPPING_KEY_PREFIX: &[u8] = b"dragonfly-key-wrap-v1:"; + +static MASTER_PASSWORD: RwLock> = RwLock::new(None); +static PORTABLE_KEY_PATH: RwLock> = RwLock::new(None); +static SERVER_KEY_MATERIAL: RwLock>>> = RwLock::new(None); + +/// Web bootstrap supplies an external random key before accessing storage. +/// It is never persisted, and avoids Desktop's home-path / portable-key fallback. +pub fn set_server_key_material(key: [u8; 32]) { + *SERVER_KEY_MATERIAL.write().unwrap() = Some(zeroize::Zeroizing::new(key.to_vec())); +} + +/// Set the master password used for wrapping key derivation. +/// Pass `None` to revert to home-path-based derivation. +pub fn set_master_password(password: Option) { + let mut pw = MASTER_PASSWORD.write().unwrap(); + *pw = password.filter(|s| !s.is_empty()); +} + +/// Set the portable-mode key path used when no master password is configured. +pub fn set_portable_key_path(path: Option) { + let mut portable_key_path = PORTABLE_KEY_PATH.write().unwrap(); + *portable_key_path = path; +} + +/// Returns the currently configured master password, if any. +pub fn get_master_password() -> Option { + MASTER_PASSWORD.read().unwrap().clone() +} + +/// Derives a wrapping key from the given material. +/// +/// When `password` is `Some`, uses the password as key material. +/// Otherwise falls back to the runtime key material. +fn derive_wrapping_key_with_prefix( + prefix: &[u8], + password: Option<&str>, +) -> AppResult> { + let mut h = Sha256::new(); + h.update(prefix); + match password { + Some(pw) => h.update(pw.as_bytes()), + None => h.update(fallback_key_material()?.as_slice()), + } + let digest = h.finalize(); + Ok(*Key::::from_slice(&digest)) +} + +fn fallback_key_material() -> AppResult> { + if let Some(material) = SERVER_KEY_MATERIAL.read().unwrap().as_ref() { + return Ok(material.to_vec()); + } + if let Some(path) = PORTABLE_KEY_PATH.read().unwrap().clone() { + return read_or_create_portable_key_material(path); + } + + let home = dirs::home_dir() + .ok_or_else(|| AppError::Crypto("cannot determine home directory".into()))?; + Ok(home.to_string_lossy().as_bytes().to_vec()) +} + +fn read_or_create_portable_key_material(path: PathBuf) -> AppResult> { + if path.exists() { + let material = std::fs::read_to_string(&path)?; + let material = material.trim(); + if material.is_empty() { + return Err(AppError::Crypto(format!( + "portable key file is empty: {}", + path.display() + ))); + } + return Ok(material.as_bytes().to_vec()); + } + + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + + use aes_gcm::aead::rand_core::RngCore as _; + let mut bytes = [0u8; 32]; + OsRng.fill_bytes(&mut bytes); + let material = B64.encode(bytes); + std::fs::write(&path, format!("{material}\n"))?; + Ok(material.into_bytes()) +} + +fn derive_wrapping_key(password: Option<&str>) -> AppResult> { + derive_wrapping_key_with_prefix(WRAPPING_KEY_PREFIX, password) +} + +fn derive_legacy_wrapping_key(password: Option<&str>) -> AppResult> { + derive_wrapping_key_with_prefix(LEGACY_WRAPPING_KEY_PREFIX, password) +} + +/// Derives the wrapping key from the current master password (or home path). +fn get_wrapping_key() -> AppResult> { + let pw = get_master_password(); + derive_wrapping_key(pw.as_deref()) +} + +/// Wraps `master_key` with `wrapping_key` and writes it to redb `master.key`. +fn write_wrapped_master_key( + master_key: &Key, + wrapping_key: &Key, +) -> AppResult<()> { + let cipher = Aes256Gcm::new(wrapping_key); + let nonce = Aes256Gcm::generate_nonce(&mut OsRng); + let wrapped = cipher + .encrypt(&nonce, master_key.as_slice()) + .map_err(|e| AppError::Crypto(format!("wrap master.key: {e}")))?; + + let mut combined = nonce.to_vec(); + combined.extend_from_slice(&wrapped); + + crate::storage::save_master_key_token(&B64.encode(&combined))?; + Ok(()) +} + +/// Unwraps the master key from the raw bytes of `master.key`. +fn unwrap_master_key_bytes(raw: &[u8], wrapping_key: &Key) -> AppResult> { + if raw.len() < 13 { + return Err(AppError::Crypto("master.key file is malformed".into())); + } + let cipher = Aes256Gcm::new(wrapping_key); + let (nonce_bytes, ciphertext) = raw.split_at(12); + let nonce = aes_gcm::Nonce::from_slice(nonce_bytes); + let master_key_bytes = cipher + .decrypt(nonce, ciphertext) + .map_err(|e| AppError::Crypto(format!("unwrap master.key: {e}")))?; + + if master_key_bytes.len() != 32 { + return Err(AppError::Crypto("master key length mismatch".into())); + } + Ok(*Key::::from_slice(&master_key_bytes)) +} + +fn unwrap_master_key_with_compatible_wrapping( + raw: &[u8], + password: Option<&str>, +) -> AppResult<(Key, bool)> { + let wrapping_key = derive_wrapping_key(password)?; + match unwrap_master_key_bytes(raw, &wrapping_key) { + Ok(master_key) => Ok((master_key, false)), + Err(new_error) => { + let legacy_wrapping_key = derive_legacy_wrapping_key(password)?; + match unwrap_master_key_bytes(raw, &legacy_wrapping_key) { + Ok(master_key) => Ok((master_key, true)), + Err(legacy_error) => Err(AppError::Crypto(format!( + "unwrap master.key: NyaTerm key prefix failed ({new_error}); legacy Dragonfly key prefix failed ({legacy_error})" + ))), + } + } + } +} + +/// Loads the master key from redb `master.key`, creating it on first use. +pub(crate) fn get_master_key() -> AppResult> { + if let Some(encoded) = crate::storage::load_master_key_token()? { + let raw = B64 + .decode(encoded.trim()) + .map_err(|e| AppError::Crypto(format!("decode master.key: {e}")))?; + + let password = get_master_password(); + let (master_key, used_legacy_prefix) = + unwrap_master_key_with_compatible_wrapping(&raw, password.as_deref())?; + if used_legacy_prefix { + let wrapping_key = derive_wrapping_key(password.as_deref())?; + write_wrapped_master_key(&master_key, &wrapping_key)?; + } + Ok(master_key) + } else { + let master_key = Aes256Gcm::generate_key(OsRng); + let wrapping_key = get_wrapping_key()?; + write_wrapped_master_key(&master_key, &wrapping_key)?; + Ok(master_key) + } +} + +/// 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()))?; + encrypt_backup_secret(&plain, target) +} + +/// Compatibility for inline passwords in authenticated Desktop backups only. +/// A value shaped like an AES-GCM token must still pass decryption; never retry +/// failed authentication as plaintext. Other credential types remain strict. +pub(crate) fn transcode_legacy_connection_password( + token: &str, + source: &Key, + target: &Key, +) -> AppResult { + // 28 bytes (nonce + tag) require at least 38 base64 characters, even without + // padding. Malformed base64 that still looks like a token must also fail closed. + let looks_like_ciphertext = token.len() >= 38 + && token + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'+' | b'/' | b'=')); + if looks_like_ciphertext { + transcode_secret(token, source, target) + } else { + encrypt_backup_secret(token.as_bytes(), target) + } +} + +fn encrypt_backup_secret(plain: &[u8], target: &Key) -> AppResult { + let nonce = Aes256Gcm::generate_nonce(&mut OsRng); + let mut encrypted = nonce.to_vec(); + encrypted.extend( + Aes256Gcm::new(target) + .encrypt(&nonce, plain) + .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(()) +} + +/// Re-wraps the existing master key when the master password changes. +/// +/// `old_password` is the previous master password (`None` = home-path-based). +/// `new_password` is the new master password (`None` = revert to home-path-based). +pub fn rewrap_master_key(old_password: Option<&str>, new_password: Option<&str>) -> AppResult<()> { + let master_key = if let Some(encoded) = crate::storage::load_master_key_token()? { + let raw = B64 + .decode(encoded.trim()) + .map_err(|e| AppError::Crypto(format!("decode master.key: {e}")))?; + + unwrap_master_key_with_compatible_wrapping(&raw, old_password)?.0 + } else { + Aes256Gcm::generate_key(OsRng) + }; + + let new_wrapping = derive_wrapping_key(new_password)?; + write_wrapped_master_key(&master_key, &new_wrapping)?; + Ok(()) +} + +// --------------------------------------------------------------------------- +// Settings-secret helpers (for the master password field in the settings document) +// --------------------------------------------------------------------------- + +/// Encrypts a value using the home-path-derived key directly (not via master.key). +/// +/// Used exclusively for storing the master password in settings.json to avoid +/// a circular dependency: we need the master password to unwrap master.key, +/// but master.key is needed to call [`encrypt`]. +pub fn encrypt_settings_secret(plaintext: &str) -> AppResult { + let key = derive_wrapping_key(None)?; + let cipher = Aes256Gcm::new(&key); + let nonce = Aes256Gcm::generate_nonce(&mut OsRng); + let ciphertext = cipher + .encrypt(&nonce, plaintext.as_bytes()) + .map_err(|e| AppError::Crypto(format!("encryption failed: {e}")))?; + + let mut combined = nonce.to_vec(); + combined.extend_from_slice(&ciphertext); + Ok(B64.encode(&combined)) +} + +/// Decrypts a value produced by [`encrypt_settings_secret`]. +pub fn decrypt_settings_secret(token: &str) -> AppResult { + let raw = B64 + .decode(token) + .map_err(|e| AppError::Crypto(format!("invalid base64: {e}")))?; + + if raw.len() < 13 { + return Err(AppError::Crypto("ciphertext too short".into())); + } + + let key = derive_wrapping_key(None)?; + match decrypt_settings_secret_bytes(&raw, &key) { + Ok(value) => Ok(value), + Err(new_error) => { + let legacy_key = derive_legacy_wrapping_key(None)?; + decrypt_settings_secret_bytes(&raw, &legacy_key).map_err(|legacy_error| { + AppError::Crypto(format!( + "settings secret decryption failed: NyaTerm key prefix failed ({new_error}); legacy Dragonfly key prefix failed ({legacy_error})" + )) + }) + } + } +} + +fn decrypt_settings_secret_bytes(raw: &[u8], key: &Key) -> AppResult { + let cipher = Aes256Gcm::new(key); + let (nonce_bytes, ciphertext) = raw.split_at(12); + let nonce = aes_gcm::Nonce::from_slice(nonce_bytes); + let plaintext = cipher + .decrypt(nonce, ciphertext) + .map_err(|e| AppError::Crypto(format!("decryption failed: {e}")))?; + String::from_utf8(plaintext).map_err(|e| AppError::Crypto(format!("invalid UTF-8: {e}"))) +} + +// --------------------------------------------------------------------------- +// Public encrypt / decrypt (use the master key from master.key) +// --------------------------------------------------------------------------- + +/// Encrypts `plaintext` with AES-256-GCM. +/// +/// Returns `base64( nonce[12] || ciphertext+tag )`. +pub fn encrypt(plaintext: &str) -> AppResult { + let key = get_master_key()?; + let cipher = Aes256Gcm::new(&key); + let nonce = Aes256Gcm::generate_nonce(&mut OsRng); + let ciphertext = cipher + .encrypt(&nonce, plaintext.as_bytes()) + .map_err(|e| AppError::Crypto(format!("encryption failed: {e}")))?; + + let mut combined = nonce.to_vec(); + combined.extend_from_slice(&ciphertext); + Ok(B64.encode(&combined)) +} + +/// Decrypts a `base64( nonce || ciphertext )` token produced by [`encrypt`]. +pub fn decrypt(token: &str) -> AppResult { + let key = get_master_key()?; + let cipher = Aes256Gcm::new(&key); + let raw = B64 + .decode(token) + .map_err(|e| AppError::Crypto(format!("invalid base64: {e}")))?; + + if raw.len() < 13 { + return Err(AppError::Crypto("ciphertext too short".into())); + } + + let (nonce_bytes, ciphertext) = raw.split_at(12); + let nonce = aes_gcm::Nonce::from_slice(nonce_bytes); + let plaintext = cipher + .decrypt(nonce, ciphertext) + .map_err(|e| AppError::Crypto(format!("decryption failed: {e}")))?; + + String::from_utf8(plaintext).map_err(|e| AppError::Crypto(format!("invalid UTF-8: {e}"))) +} + +/// Decrypts an optional token, returning `None` when the input is `None` or empty. +pub fn decrypt_optional(token: &Option) -> AppResult> { + match token { + Some(t) if !t.is_empty() => Ok(Some(decrypt(t)?)), + _ => Ok(None), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn backup_legacy_connection_passwords_are_encrypted_for_the_target_key() { + let source = Aes256Gcm::generate_key(OsRng); + let target = Aes256Gcm::generate_key(OsRng); + for plaintext in ["test", "long-legacy-password-with-symbols!", "旧密码🔑"] { + let token = transcode_legacy_connection_password(plaintext, &source, &target).unwrap(); + let bytes = B64.decode(&token).unwrap(); + let plain = Aes256Gcm::new(&target) + .decrypt(aes_gcm::Nonce::from_slice(&bytes[..12]), &bytes[12..]) + .unwrap(); + assert_eq!(plain, plaintext.as_bytes()); + assert!( + Aes256Gcm::new(&source) + .decrypt(aes_gcm::Nonce::from_slice(&bytes[..12]), &bytes[12..]) + .is_err() + ); + assert!(transcode_secret(plaintext, &source, &target).is_err()); + } + } + + #[test] + fn backup_legacy_connection_passwords_do_not_bypass_ciphertext_integrity() { + let source = Aes256Gcm::generate_key(OsRng); + let target = Aes256Gcm::generate_key(OsRng); + let token = encrypt_backup_secret(b"encrypted password", &source).unwrap(); + let converted = transcode_legacy_connection_password(&token, &source, &target).unwrap(); + let bytes = B64.decode(converted).unwrap(); + assert_eq!( + Aes256Gcm::new(&target) + .decrypt(aes_gcm::Nonce::from_slice(&bytes[..12]), &bytes[12..]) + .unwrap(), + b"encrypted password" + ); + + let mut corrupt = B64.decode(&token).unwrap(); + corrupt[12] ^= 1; + assert!( + transcode_legacy_connection_password(&B64.encode(corrupt), &source, &target).is_err() + ); + assert!(transcode_legacy_connection_password(&token, &target, &source).is_err()); + assert!( + transcode_legacy_connection_password(token.trim_end_matches('='), &source, &target) + .is_err() + ); + } + + fn wrap_key_for_test(master_key: &Key, wrapping_key: &Key) -> Vec { + let cipher = Aes256Gcm::new(wrapping_key); + let nonce = Aes256Gcm::generate_nonce(&mut OsRng); + let wrapped = cipher + .encrypt(&nonce, master_key.as_slice()) + .expect("wrap test key"); + let mut combined = nonce.to_vec(); + combined.extend_from_slice(&wrapped); + combined + } + + fn encrypt_settings_secret_with_key_for_test(plaintext: &str, key: &Key) -> String { + let cipher = Aes256Gcm::new(key); + let nonce = Aes256Gcm::generate_nonce(&mut OsRng); + let ciphertext = cipher + .encrypt(&nonce, plaintext.as_bytes()) + .expect("encrypt settings secret"); + let mut combined = nonce.to_vec(); + combined.extend_from_slice(&ciphertext); + B64.encode(combined) + } + + #[test] + fn legacy_dragonfly_master_key_prefix_can_be_unwrapped() { + let master_key = Aes256Gcm::generate_key(OsRng); + let legacy_wrapping_key = + derive_legacy_wrapping_key(Some("secret")).expect("legacy wrapping key"); + let raw = wrap_key_for_test(&master_key, &legacy_wrapping_key); + + let (unwrapped, used_legacy_prefix) = + unwrap_master_key_with_compatible_wrapping(&raw, Some("secret")) + .expect("unwrap with compatibility"); + + assert!(used_legacy_prefix); + assert_eq!(unwrapped.as_slice(), master_key.as_slice()); + } + + #[test] + fn legacy_dragonfly_settings_secret_prefix_can_be_decrypted() { + let legacy_key = derive_legacy_wrapping_key(None).expect("legacy settings key"); + let token = encrypt_settings_secret_with_key_for_test("stored-password", &legacy_key); + + assert_eq!( + decrypt_settings_secret(&token).expect("decrypt legacy settings secret"), + "stored-password" + ); + } +} diff --git a/src-tauri/crates/nyaterm-core/src/utils/fonts.rs b/src-tauri/crates/nyaterm-core/src/utils/fonts.rs new file mode 100644 index 00000000..804bd89b --- /dev/null +++ b/src-tauri/crates/nyaterm-core/src/utils/fonts.rs @@ -0,0 +1,268 @@ +use font_kit::font::Font; +use font_kit::source::SystemSource; +use serde::Serialize; + +pub const DEFAULT_TERMINAL_FONT_FAMILY: &str = "\"JetBrainsMono Nerd Font Mono\", \"JetBrains Mono\", \"Cascadia Mono\", \"SF Mono\", Menlo, Monaco, Consolas, \"Liberation Mono\", monospace"; +const LEGACY_DEFAULT_TERMINAL_FONT_FAMILY: &str = "\"JetBrains Mono\", \"Cascadia Mono\", \"SF Mono\", Menlo, Monaco, Consolas, \"Liberation Mono\", monospace"; + +#[cfg(target_os = "macos")] +pub const DEFAULT_UI_FONT_FAMILY: &str = "system-ui, -apple-system, BlinkMacSystemFont, \"PingFang SC\", \"Helvetica Neue\", Arial, sans-serif"; + +#[cfg(target_os = "windows")] +pub const DEFAULT_UI_FONT_FAMILY: &str = + "system-ui, \"Segoe UI\", \"Microsoft YaHei\", \"Noto Sans SC\", Arial, sans-serif"; + +#[cfg(not(any(target_os = "macos", target_os = "windows")))] +pub const DEFAULT_UI_FONT_FAMILY: &str = + "system-ui, \"Noto Sans SC\", \"Noto Sans CJK SC\", \"Helvetica Neue\", Arial, sans-serif"; + +const BUILT_IN_MONOSPACE_FONTS: &[&str] = &["JetBrainsMono Nerd Font Mono", "JetBrains Mono"]; +const GENERIC_MONOSPACE_FAMILY: &str = "monospace"; + +#[derive(Debug, Clone, Serialize)] +pub struct FontInfo { + pub family: String, + pub monospace: bool, +} + +pub fn list_system_font_infos() -> Vec { + let source = SystemSource::new(); + let Ok(mut families) = source.all_families() else { + return Vec::new(); + }; + + families.sort(); + families.dedup(); + families + .into_iter() + .map(|family| FontInfo { + monospace: is_system_font_family_monospace_with_source(&source, &family), + family, + }) + .collect() +} + +pub fn list_system_font_families() -> Vec { + let Ok(mut families) = SystemSource::new().all_families() else { + return Vec::new(); + }; + + families.sort(); + families.dedup(); + families +} + +pub fn normalize_terminal_font_family(font_family: &str) -> String { + if is_default_terminal_font_family(font_family) { + return DEFAULT_TERMINAL_FONT_FAMILY.to_string(); + } + + if is_legacy_default_terminal_font_family(font_family) { + return DEFAULT_TERMINAL_FONT_FAMILY.to_string(); + } + + let source = SystemSource::new(); + let mut kept = Vec::new(); + + for raw_family in split_font_family_stack(font_family) { + let family = unquote_font_family(&raw_family); + if is_generic_monospace_family(&family) { + push_unique_font_family(&mut kept, GENERIC_MONOSPACE_FAMILY.to_string()); + continue; + } + + if is_built_in_monospace_family(&family) + || is_system_font_family_monospace_with_source(&source, &family) + { + push_unique_font_family(&mut kept, raw_family.trim().to_string()); + } + } + + if kept.is_empty() { + DEFAULT_TERMINAL_FONT_FAMILY.to_string() + } else { + kept.join(", ") + } +} + +fn is_built_in_monospace_family(family: &str) -> bool { + BUILT_IN_MONOSPACE_FONTS + .iter() + .any(|known| known.eq_ignore_ascii_case(family)) +} + +fn is_default_terminal_font_family(font_family: &str) -> bool { + font_family + .trim() + .eq_ignore_ascii_case(DEFAULT_TERMINAL_FONT_FAMILY) +} + +fn is_legacy_default_terminal_font_family(font_family: &str) -> bool { + font_family + .trim() + .eq_ignore_ascii_case(LEGACY_DEFAULT_TERMINAL_FONT_FAMILY) +} + +fn is_generic_monospace_family(family: &str) -> bool { + family.eq_ignore_ascii_case(GENERIC_MONOSPACE_FAMILY) +} + +fn is_system_font_family_monospace_with_source(source: &SystemSource, family: &str) -> bool { + let Ok(family_handle) = source.select_family_by_name(family) else { + return false; + }; + + family_handle + .fonts() + .iter() + .filter_map(|handle| Font::from_handle(handle).ok()) + .any(|font| is_font_monospace(&font)) +} + +fn is_font_monospace(font: &Font) -> bool { + font.is_monospace() || has_equal_sample_advances(&font) +} + +fn has_equal_sample_advances(font: &Font) -> bool { + let mut advances = Vec::new(); + + for ch in ['i', 'W', '0', 'm', ' '] { + let Some(glyph_id) = font.glyph_for_char(ch) else { + return false; + }; + let Ok(advance) = font.advance(glyph_id) else { + return false; + }; + advances.push(advance.x()); + } + + let Some(first) = advances.first().copied() else { + return false; + }; + advances + .iter() + .all(|advance| (advance - first).abs() <= f32::EPSILON) +} + +fn split_font_family_stack(stack: &str) -> Vec { + let mut result = Vec::new(); + let mut current = String::new(); + let mut quote: Option = None; + let mut escaping = false; + + for ch in stack.chars() { + if escaping { + current.push(ch); + escaping = false; + continue; + } + + if ch == '\\' { + current.push(ch); + escaping = true; + continue; + } + + match quote { + Some(active_quote) if ch == active_quote => { + quote = None; + current.push(ch); + } + Some(_) => current.push(ch), + None if ch == '\'' || ch == '"' => { + quote = Some(ch); + current.push(ch); + } + None if ch == ',' => { + let trimmed = current.trim(); + if !trimmed.is_empty() { + result.push(trimmed.to_string()); + } + current.clear(); + } + None => current.push(ch), + } + } + + let trimmed = current.trim(); + if !trimmed.is_empty() { + result.push(trimmed.to_string()); + } + + result +} + +fn unquote_font_family(family: &str) -> String { + let trimmed = family.trim(); + if trimmed.len() >= 2 { + let first = trimmed.chars().next(); + let last = trimmed.chars().next_back(); + if matches!( + (first, last), + (Some('\''), Some('\'')) | (Some('"'), Some('"')) + ) { + return trimmed[1..trimmed.len() - 1].to_string(); + } + } + trimmed.to_string() +} + +fn push_unique_font_family(families: &mut Vec, family: String) { + let normalized = unquote_font_family(&family); + if families + .iter() + .map(|existing| unquote_font_family(existing)) + .any(|existing| existing.eq_ignore_ascii_case(&normalized)) + { + return; + } + families.push(family); +} + +#[cfg(test)] +mod tests { + use super::{ + DEFAULT_TERMINAL_FONT_FAMILY, normalize_terminal_font_family, split_font_family_stack, + unquote_font_family, + }; + + #[test] + fn splits_font_stack_without_splitting_inside_quotes() { + assert_eq!( + split_font_family_stack("JetBrains Mono, \"Noto, Sans\", monospace"), + vec!["JetBrains Mono", "\"Noto, Sans\"", "monospace"] + ); + } + + #[test] + fn removes_matching_wrapping_quotes() { + assert_eq!(unquote_font_family("\"Cascadia Mono\""), "Cascadia Mono"); + assert_eq!(unquote_font_family("'JetBrains Mono'"), "JetBrains Mono"); + } + + #[test] + fn normalizes_legacy_default_to_nerd_font_default() { + assert_eq!( + normalize_terminal_font_family( + "\"JetBrains Mono\", \"Cascadia Mono\", \"SF Mono\", Menlo, Monaco, Consolas, \"Liberation Mono\", monospace" + ), + DEFAULT_TERMINAL_FONT_FAMILY + ); + } + + #[test] + fn keeps_current_default_terminal_font_stack() { + assert_eq!( + normalize_terminal_font_family(DEFAULT_TERMINAL_FONT_FAMILY), + DEFAULT_TERMINAL_FONT_FAMILY + ); + } + + #[test] + fn keeps_custom_jetbrains_mono_stack_without_forcing_nerd_font() { + assert_eq!( + normalize_terminal_font_family("\"JetBrains Mono\", monospace"), + "\"JetBrains Mono\", monospace" + ); + } +} diff --git a/src-tauri/crates/nyaterm-core/src/utils/fuzzy.rs b/src-tauri/crates/nyaterm-core/src/utils/fuzzy.rs new file mode 100644 index 00000000..992461cc --- /dev/null +++ b/src-tauri/crates/nyaterm-core/src/utils/fuzzy.rs @@ -0,0 +1,224 @@ +//! Fuzzy search using nucleo-matcher. + +use nucleo_matcher::pattern::{AtomKind, CaseMatching, Normalization, Pattern}; +use nucleo_matcher::{Config, Matcher, Utf32Str}; +use serde::{Deserialize, Serialize}; + +/// Single fuzzy match with score and highlighted character indices. +#[derive(Debug, Clone, Serialize)] +pub struct FuzzyResult { + pub command: String, + pub score: u32, + pub indices: Vec, + /// Provider tag: "history", "quickCommand", etc. + pub source: String, + /// Text shown in the suggestion panel (may differ from `command`). + pub display: String, +} + +/// Candidate passed from the frontend for stateless fuzzy scoring. +#[derive(Debug, Clone, Deserialize)] +pub struct FuzzySearchCandidate { + pub id: String, + pub value: String, + pub display: String, +} + +/// Generic frontend candidate match with stable id and value. +#[derive(Debug, Clone, Serialize)] +pub struct FuzzyCandidateResult { + pub id: String, + pub value: String, + pub display: String, + pub score: u32, + pub indices: Vec, +} + +/// Generic fuzzy search over `(display_text, value)` pairs. +/// +/// Matches against `display_text` (shown in the suggestion panel). +/// `value` is stored as `command` in the result (used when filling/executing). +/// `source` tags every result so the frontend can distinguish providers. +pub fn fuzzy_search_items( + items: &[(&str, &str)], + pattern_str: &str, + source: &str, + limit: usize, + min_command_length: Option, + max_command_length: Option, +) -> Vec { + let pattern_str = pattern_str.trim(); + if pattern_str.is_empty() { + return Vec::new(); + } + + let pattern = Pattern::new( + pattern_str, + CaseMatching::Smart, + Normalization::Smart, + AtomKind::Fuzzy, + ); + + if pattern.atoms.is_empty() { + return Vec::new(); + } + + let mut matcher = Matcher::new(Config::DEFAULT.match_paths()); + let mut buf = Vec::new(); + + let mut scored: Vec<(usize, u32)> = Vec::new(); + for (idx, (display, value)) in items.iter().enumerate() { + let value_length = value.chars().count(); + + if min_command_length.is_some_and(|min| value_length < min) { + continue; + } + + if max_command_length.is_some_and(|max| value_length > max) { + continue; + } + + let haystack = Utf32Str::new(display, &mut buf); + if let Some(score) = pattern.score(haystack, &mut matcher) { + scored.push((idx, score)); + } + } + + scored.sort_by(|a, b| b.1.cmp(&a.1).then(b.0.cmp(&a.0))); + scored.truncate(limit); + + let mut results = Vec::with_capacity(scored.len()); + for (idx, score) in scored { + let (display, value) = &items[idx]; + let haystack = Utf32Str::new(display, &mut buf); + let mut indices = Vec::new(); + pattern.indices(haystack, &mut matcher, &mut indices); + indices.sort_unstable(); + indices.dedup(); + + results.push(FuzzyResult { + command: (*value).to_string(), + score, + indices, + source: source.to_string(), + display: (*display).to_string(), + }); + } + + results +} + +/// Generic fuzzy search over frontend-provided candidates. +/// +/// This keeps business state on the frontend while sharing the Rust fuzzy scorer. +pub fn fuzzy_search_candidates( + items: &[FuzzySearchCandidate], + pattern_str: &str, + limit: usize, +) -> Vec { + let pattern_str = pattern_str.trim(); + if pattern_str.is_empty() { + return Vec::new(); + } + + let pattern = Pattern::new( + pattern_str, + CaseMatching::Smart, + Normalization::Smart, + AtomKind::Fuzzy, + ); + + if pattern.atoms.is_empty() { + return Vec::new(); + } + + let mut matcher = Matcher::new(Config::DEFAULT.match_paths()); + let mut buf = Vec::new(); + + let mut scored: Vec<(usize, u32)> = Vec::new(); + for (idx, item) in items.iter().enumerate() { + let haystack = Utf32Str::new(&item.display, &mut buf); + if let Some(score) = pattern.score(haystack, &mut matcher) { + scored.push((idx, score)); + } + } + + scored.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0))); + scored.truncate(limit); + + let mut results = Vec::with_capacity(scored.len()); + for (idx, score) in scored { + let item = &items[idx]; + let haystack = Utf32Str::new(&item.display, &mut buf); + let mut indices = Vec::new(); + pattern.indices(haystack, &mut matcher, &mut indices); + indices.sort_unstable(); + indices.dedup(); + + results.push(FuzzyCandidateResult { + id: item.id.clone(), + value: item.value.clone(), + display: item.display.clone(), + score, + indices, + }); + } + + results +} + +#[cfg(test)] +mod tests { + use super::{FuzzySearchCandidate, fuzzy_search_candidates}; + + fn candidate(id: &str, display: &str) -> FuzzySearchCandidate { + FuzzySearchCandidate { + id: id.to_string(), + value: id.to_string(), + display: display.to_string(), + } + } + + #[test] + fn fuzzy_candidates_return_empty_for_blank_pattern() { + let items = [candidate("session:1", "production ssh")]; + + let results = fuzzy_search_candidates(&items, " ", 10); + + assert!(results.is_empty()); + } + + #[test] + fn fuzzy_candidates_respect_limit_and_return_stable_ids() { + let items = [ + candidate("session:1", "production ssh root@10.0.0.1"), + candidate("connection:1", "production database postgres"), + candidate("session:2", "local shell"), + ]; + + let results = fuzzy_search_candidates(&items, "prod", 1); + + assert_eq!(results.len(), 1); + assert!(matches!( + results[0].id.as_str(), + "session:1" | "connection:1" + )); + assert_eq!(results[0].value, results[0].id); + } + + #[test] + fn fuzzy_candidates_sort_by_score_then_original_order() { + let items = [ + candidate("first", "docker image"), + candidate("second", "docker image"), + candidate("third", "documentation index"), + ]; + + let results = fuzzy_search_candidates(&items, "docker image", 10); + + assert!(results.len() >= 2); + assert_eq!(results[0].id, "first"); + assert_eq!(results[1].id, "second"); + assert!(results[0].score >= results[1].score); + } +} diff --git a/src-tauri/crates/nyaterm-core/src/utils/mod.rs b/src-tauri/crates/nyaterm-core/src/utils/mod.rs new file mode 100644 index 00000000..72888210 --- /dev/null +++ b/src-tauri/crates/nyaterm-core/src/utils/mod.rs @@ -0,0 +1,4 @@ +pub mod crypto; +pub mod fonts; +pub mod fuzzy; +pub mod url; diff --git a/src-tauri/crates/nyaterm-core/src/utils/url.rs b/src-tauri/crates/nyaterm-core/src/utils/url.rs new file mode 100644 index 00000000..387a870e --- /dev/null +++ b/src-tauri/crates/nyaterm-core/src/utils/url.rs @@ -0,0 +1,81 @@ +use reqwest::Url; + +use crate::error::{AppError, AppResult}; + +pub fn normalize_api_base_url(value: &str) -> AppResult { + let trimmed = value.trim(); + if trimmed.is_empty() { + return Ok(String::new()); + } + + let mut url = Url::parse(trimmed) + .map_err(|err| AppError::Config(format!("Invalid API base URL '{trimmed}': {err}")))?; + if !url.path().ends_with('/') { + url.set_path(&format!("{}/", url.path())); + } + Ok(url.to_string()) +} + +pub fn join_api_base_url(base_url: &str, suffix: &str) -> AppResult { + let normalized = normalize_api_base_url(base_url)?; + let url = Url::parse(&normalized).map_err(|err| { + AppError::Config(format!( + "Invalid API base URL '{}': {err}", + normalized.as_str() + )) + })?; + let original_query = url.query().map(str::to_string); + let mut full_url = url.join(suffix.trim_start_matches('/')).map_err(|err| { + AppError::Config(format!( + "Failed to join API path '{}' to '{}': {err}", + suffix, normalized + )) + })?; + full_url.set_query(original_query.as_deref()); + Ok(full_url.to_string()) +} + +pub fn normalize_storage_endpoint(value: &str) -> String { + value.trim().trim_end_matches('/').to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn api_base_url_is_directory_url() { + assert_eq!( + normalize_api_base_url("https://api.example.com/v1").unwrap(), + "https://api.example.com/v1/" + ); + assert_eq!( + normalize_api_base_url("https://api.example.com/v1/").unwrap(), + "https://api.example.com/v1/" + ); + } + + #[test] + fn api_base_url_preserves_query() { + assert_eq!( + normalize_api_base_url(" https://api.example.com/v1?api-version=1 ").unwrap(), + "https://api.example.com/v1/?api-version=1" + ); + } + + #[test] + fn api_base_url_join_preserves_query() { + assert_eq!( + join_api_base_url("https://api.example.com/v1?api-version=1", "models").unwrap(), + "https://api.example.com/v1/models?api-version=1" + ); + } + + #[test] + fn storage_endpoint_trims_whitespace_and_trailing_slashes() { + assert_eq!( + normalize_storage_endpoint(" https://dav.example.com/remote.php/webdav// "), + "https://dav.example.com/remote.php/webdav" + ); + } +} diff --git a/src-tauri/crates/nyaterm-core/src/vnc/manager.rs b/src-tauri/crates/nyaterm-core/src/vnc/manager.rs new file mode 100644 index 00000000..acb4fe33 --- /dev/null +++ b/src-tauri/crates/nyaterm-core/src/vnc/manager.rs @@ -0,0 +1,1795 @@ +use super::runtime::{FrameChannel, VncContext as AppHandle, open_tcp_transport}; +use crate::config::{self, ConnectionAuth, ConnectionNetwork, ConnectionType}; +use crate::error::{AppError, AppResult}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::collections::{HashMap, VecDeque}; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use tokio::sync::{Mutex, mpsc, oneshot, watch}; +use tokio::task::JoinHandle; +use tokio::time::{Duration, timeout}; +use vnc::{ + ClientKeyEvent, ClientMouseEvent, PixelFormat, Rect, Screen, VncClient, VncConnector, + VncEncoding, VncError, VncEvent, VncLimits, VncSecurityPolicy, VncServerKey, X11Event, +}; +use zeroize::Zeroizing; + +use crate::remote_desktop_frame::{ + RemoteDesktopFramePatch, RemoteDesktopPixelFormat, encode_frame_patch, +}; + +const MAX_FRAMEBUFFER_WIDTH: u16 = 7680; +const MAX_FRAMEBUFFER_HEIGHT: u16 = 4320; +const MAX_VNC_CLIPBOARD_BYTES: usize = 1024 * 1024; +const MAX_INPUT_BATCH: usize = 256; +const WORKER_COMMAND_CHANNEL_CAPACITY: usize = 256; +const MAX_PENDING_FRAMES: usize = 2; +// A routed connection can require interactive SSH jump authentication. +const CONNECT_TIMEOUT: Duration = Duration::from_secs(120); +const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(30); +const RA2_HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(75); +const SERVER_KEY_PROMPT_TIMEOUT: Duration = Duration::from_mins(1); +const WORKER_SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(3); +const EVENT_POLL_INTERVAL: Duration = Duration::from_millis(8); +const UPDATE_REQUEST_INTERVAL: Duration = Duration::from_millis(16); + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum VncSessionState { + Connecting, + Authenticating, + Negotiating, + Active, + Reconnecting, + Disconnected, + Failed, +} + +#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum VncErrorKind { + Transport, + Authentication, + Protocol, + Encoding, + Clipboard, + Internal, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct VncStateEvent { + pub session_id: String, + pub state: VncSessionState, + pub message: Option, + pub error_kind: Option, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct VncServerKeyVerifyEvent { + pub request_id: String, + pub session_id: String, + pub host: String, + pub port: u16, + pub fingerprint: String, + pub key_bits: u32, + pub known_host_status: String, + pub target_window_label: String, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct VncClipboardEvent { + pub session_id: String, + pub text: String, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase", tag = "type")] +pub enum VncInputEvent { + #[serde(rename = "key")] + Key { keysym: u32, pressed: bool }, + #[serde(rename = "pointer")] + Pointer { + x: u16, + y: u16, + #[serde(alias = "buttonMask")] + button_mask: u8, + }, + #[serde(rename = "release-all-keys")] + ReleaseAllKeys, +} + +#[derive(Debug)] +enum VncWorkerCommand { + Input(VncInputEvent), + Clipboard(String), + FullRefresh, +} + +struct VncServerKeyPending { + payload: VncServerKeyVerifyEvent, + session_id: String, + generation: u64, + responder: oneshot::Sender, +} + +#[derive(Debug, Clone)] +pub struct VncConnectConfig { + pub owner_window_label: String, + pub session_id: String, + pub connection_id: String, + pub name: String, + pub host: String, + pub port: u16, + pub username: String, + pub password: Option, + pub security_mode: String, + pub scale_mode: String, + pub clipboard_enabled: bool, + pub reconnect_enabled: bool, + pub reconnect_max_attempts: u32, + pub shared: bool, + pub view_only: bool, + pub network: Option, +} + +struct VncFramebuffer { + width: u16, + height: u16, + rgba: Vec, +} + +impl VncFramebuffer { + fn new(width: u16, height: u16) -> AppResult { + validate_framebuffer_dimensions(width, height)?; + let len = usize::from(width) + .checked_mul(usize::from(height)) + .and_then(|pixels| pixels.checked_mul(4)) + .ok_or_else(|| AppError::Config("VNC framebuffer size overflows".to_string()))?; + Ok(Self { + width, + height, + rgba: vec![0; len], + }) + } + + fn apply_rgba(&mut self, rect: Rect, pixels: &[u8]) -> AppResult<()> { + validate_rectangle(rect, self.width, self.height)?; + let row_bytes = usize::from(rect.width) + .checked_mul(4) + .ok_or_else(|| AppError::Config("VNC rectangle row size overflows".to_string()))?; + let required = row_bytes + .checked_mul(usize::from(rect.height)) + .ok_or_else(|| AppError::Config("VNC rectangle payload size overflows".to_string()))?; + if pixels.len() != required { + return Err(AppError::Config( + "VNC rectangle payload length is invalid".to_string(), + )); + } + let framebuffer_stride = usize::from(self.width) * 4; + for row in 0..usize::from(rect.height) { + let src_start = row * row_bytes; + let dst_start = + (usize::from(rect.y) + row) * framebuffer_stride + usize::from(rect.x) * 4; + self.rgba[dst_start..dst_start + row_bytes] + .copy_from_slice(&pixels[src_start..src_start + row_bytes]); + } + Ok(()) + } + + fn patch_bytes(&self, sequence: u64, rect: Rect, pixels: &[u8]) -> AppResult> { + encode_frame_patch(&RemoteDesktopFramePatch { + sequence, + desktop_width: u32::from(self.width), + desktop_height: u32::from(self.height), + x: u32::from(rect.x), + y: u32::from(rect.y), + width: u32::from(rect.width), + height: u32::from(rect.height), + stride: u32::from(rect.width) * 4, + pixel_format: RemoteDesktopPixelFormat::Rgba8888, + payload: pixels, + }) + } + + fn full_frame_bytes(&self, sequence: u64) -> AppResult> { + self.patch_bytes( + sequence, + Rect { + x: 0, + y: 0, + width: self.width, + height: self.height, + }, + &self.rgba, + ) + } +} + +pub struct VncSession { + pub config: VncConnectConfig, + state: Mutex, + message: Mutex>, + error_kind: Mutex>, + generation: AtomicU64, + frame_sequence: AtomicU64, + frame_attach_id: AtomicU64, + frame_channel: Mutex>, + pending_frames: Mutex>>, + framebuffer: Mutex>, + command_sender: Mutex>>, + cancel_sender: Mutex>>, + worker: Mutex>>, + close_requested: AtomicBool, + trust_commit_guard: std::sync::Mutex<()>, + pending_server_keys: Arc>>, +} + +pub struct VncSessionManager { + sessions: Mutex>>, + pending_server_keys: Arc>>, +} + +impl VncSessionManager { + pub fn new() -> Self { + Self { + sessions: Mutex::new(HashMap::new()), + pending_server_keys: Arc::new(Mutex::new(HashMap::new())), + } + } + + pub async fn create_session( + self: &Arc, + app: AppHandle, + config: VncConnectConfig, + ) -> AppResult { + let session_id = config.session_id.clone(); + let session = Arc::new(VncSession { + config, + state: Mutex::new(VncSessionState::Connecting), + message: Mutex::new(None), + error_kind: Mutex::new(None), + generation: AtomicU64::new(0), + frame_sequence: AtomicU64::new(0), + frame_attach_id: AtomicU64::new(0), + frame_channel: Mutex::new(None), + pending_frames: Mutex::new(VecDeque::with_capacity(MAX_PENDING_FRAMES)), + framebuffer: Mutex::new(None), + command_sender: Mutex::new(None), + cancel_sender: Mutex::new(None), + worker: Mutex::new(None), + close_requested: AtomicBool::new(false), + trust_commit_guard: std::sync::Mutex::new(()), + pending_server_keys: self.pending_server_keys.clone(), + }); + self.sessions + .lock() + .await + .insert(session_id.clone(), session.clone()); + emit_state(&app, &session, VncSessionState::Connecting, None, None); + tracing::info!( + session_id = %session_id, + connection_id = %session.config.connection_id, + name = %session.config.name, + scale_mode = %session.config.scale_mode, + "Created VNC session" + ); + spawn_session_worker(app.clone(), session).await; + let _ = app.emit("sessions-changed", ()); + Ok(session_id) + } + + pub async fn attach_frame_channel( + &self, + app: &AppHandle, + session_id: &str, + channel: FrameChannel, + ) -> AppResult<()> { + let session = self.get(session_id).await?; + let attach_id = session.frame_attach_id.fetch_add(1, Ordering::AcqRel) + 1; + *session.frame_channel.lock().await = Some((attach_id, channel)); + // The framebuffer is authoritative; replaying partial history before it + // fills bounded Web queues and can perpetuate refresh overflows. + if session.framebuffer.lock().await.is_some() { + session.pending_frames.lock().await.clear(); + } else { + flush_pending_frames(&session, attach_id).await; + } + send_full_frame(&session, Some(attach_id)).await?; + let _ = send_worker_command(&session, VncWorkerCommand::FullRefresh).await; + replay_state(app, &session).await; + for pending in session + .pending_server_keys + .lock() + .await + .values() + .filter(|p| { + p.session_id == session_id + && p.generation == session.generation.load(Ordering::Acquire) + }) + { + let _ = app.emit_to( + &session.config.owner_window_label, + "vnc-server-key-verify", + pending.payload.clone(), + ); + } + Ok(()) + } + + pub async fn detach_frame_channel(&self, session_id: &str) -> AppResult<()> { + let session = self.get(session_id).await?; + session.frame_attach_id.fetch_add(1, Ordering::AcqRel); + *session.frame_channel.lock().await = None; + Ok(()) + } + + pub async fn send_input(&self, session_id: &str, events: Vec) -> AppResult<()> { + let session = self.get(session_id).await?; + if session.config.view_only { + return Err(AppError::Config( + "VNC input is disabled for a view-only session".to_string(), + )); + } + if events.len() > MAX_INPUT_BATCH { + return Err(AppError::Config(format!( + "VNC input batch exceeds {MAX_INPUT_BATCH} events" + ))); + } + let sender = session + .command_sender + .lock() + .await + .clone() + .ok_or_else(|| AppError::Channel("VNC worker channel is not active".to_string()))?; + for event in events { + sender + .send(VncWorkerCommand::Input(event)) + .await + .map_err(|_| AppError::Channel("VNC worker channel closed".to_string()))?; + } + Ok(()) + } + + pub async fn set_clipboard_text(&self, session_id: &str, text: String) -> AppResult<()> { + let session = self.get(session_id).await?; + if session.config.view_only || !session.config.clipboard_enabled { + return Err(AppError::Config( + "VNC clipboard sending is disabled".to_string(), + )); + } + if text.len() > MAX_VNC_CLIPBOARD_BYTES || !text.chars().all(|ch| u32::from(ch) <= 0xff) { + return Err(AppError::Config( + "VNC clipboard text must be Latin-1 and no larger than 1 MiB".to_string(), + )); + } + send_worker_command(&session, VncWorkerCommand::Clipboard(text)).await + } + + pub async fn reconnect(self: &Arc, app: AppHandle, session_id: &str) -> AppResult<()> { + let session = self.get(session_id).await?; + invalidate_vnc_generation(&session, false); + stop_worker(&session).await; + self.cancel_pending_server_keys_for_session(app.clone(), session_id) + .await; + session.close_requested.store(false, Ordering::Release); + *session.framebuffer.lock().await = None; + session.pending_frames.lock().await.clear(); + session.frame_sequence.store(0, Ordering::Release); + emit_state(&app, &session, VncSessionState::Reconnecting, None, None); + spawn_session_worker(app, session).await; + Ok(()) + } + + pub async fn close(&self, app: &AppHandle, session_id: &str) -> AppResult<()> { + let Some(session) = self.sessions.lock().await.remove(session_id) else { + return Ok(()); + }; + invalidate_vnc_generation(&session, true); + stop_worker(&session).await; + self.cancel_pending_server_keys_for_session(app.clone(), session_id) + .await; + session.frame_attach_id.fetch_add(1, Ordering::AcqRel); + *session.frame_channel.lock().await = None; + session.pending_frames.lock().await.clear(); + *session.framebuffer.lock().await = None; + set_state(&session, VncSessionState::Disconnected, None, None).await; + emit_state(app, &session, VncSessionState::Disconnected, None, None); + let _ = app.emit("sessions-changed", ()); + Ok(()) + } + + pub async fn respond_server_key(&self, request_id: &str, accepted: bool) -> AppResult<()> { + let Some(pending) = self.pending_server_keys.lock().await.remove(request_id) else { + return Err(AppError::Cancelled(format!( + "No pending VNC server key request with id '{request_id}'" + ))); + }; + let session = self.get(&pending.session_id).await?; + if session.generation.load(Ordering::Acquire) != pending.generation + || session.close_requested.load(Ordering::Acquire) + { + return Ok(()); + } + let _ = pending.responder.send(accepted); + Ok(()) + } + + async fn cancel_pending_server_keys_for_session(&self, app: AppHandle, session_id: &str) { + let mut pending = self.pending_server_keys.lock().await; + let request_ids = pending + .iter() + .filter(|(_, request)| request.session_id == session_id) + .map(|(request_id, _)| request_id.clone()) + .collect::>(); + for request_id in request_ids { + if let Some(request) = pending.remove(&request_id) { + let _ = request.responder.send(false); + let _ = app.emit( + "vnc-server-key-verify-resolved", + serde_json::json!({"requestId": request_id}), + ); + } + } + } + + pub async fn close_all(&self, app: &AppHandle) { + let ids = self + .sessions + .lock() + .await + .keys() + .cloned() + .collect::>(); + for id in ids { + if let Err(error) = self.close(app, &id).await { + tracing::warn!(session_id = %id, "Failed to close VNC session: {error}"); + } + } + } + + async fn get(&self, session_id: &str) -> AppResult> { + self.sessions + .lock() + .await + .get(session_id) + .cloned() + .ok_or_else(|| { + AppError::SessionNotFound(format!("VNC session '{session_id}' not found")) + }) + } +} + +impl Default for VncSessionManager { + fn default() -> Self { + Self::new() + } +} + +async fn spawn_session_worker(app: AppHandle, session: Arc) { + let (cancel_tx, cancel_rx) = watch::channel(false); + *session.cancel_sender.lock().await = Some(cancel_tx); + let worker_session = session.clone(); + let handle = tokio::spawn(async move { + run_session_worker(app, worker_session, cancel_rx).await; + }); + *session.worker.lock().await = Some(handle); +} + +async fn run_session_worker( + app: AppHandle, + session: Arc, + mut cancel_rx: watch::Receiver, +) { + let mut attempt = 0_u32; + loop { + if session.close_requested.load(Ordering::Acquire) || *cancel_rx.borrow() { + return; + } + let generation = session.generation.fetch_add(1, Ordering::AcqRel) + 1; + let state = if attempt == 0 { + VncSessionState::Connecting + } else { + VncSessionState::Reconnecting + }; + set_state(&session, state.clone(), None, None).await; + emit_state(&app, &session, state, None, None); + + match run_protocol_generation(&app, &session, generation, &mut cancel_rx).await { + Ok(()) => return, + Err((kind, message, retryable)) => { + if session.close_requested.load(Ordering::Acquire) || *cancel_rx.borrow() { + return; + } + if !retryable + || !session.config.reconnect_enabled + || attempt >= session.config.reconnect_max_attempts + { + set_state( + &session, + VncSessionState::Failed, + Some(message.clone()), + Some(kind), + ) + .await; + emit_state( + &app, + &session, + VncSessionState::Failed, + Some(message), + Some(kind), + ); + let _ = app.emit("sessions-changed", ()); + return; + } + attempt += 1; + let delay = reconnect_delay(attempt); + let sleep = tokio::time::sleep(delay); + tokio::pin!(sleep); + tokio::select! { + _ = &mut sleep => {}, + changed = cancel_rx.changed() => { + if changed.is_err() || *cancel_rx.borrow() { return; } + } + } + } + } + } +} + +async fn run_protocol_generation( + app: &AppHandle, + session: &Arc, + generation: u64, + cancel_rx: &mut watch::Receiver, +) -> Result<(), (VncErrorKind, String, bool)> { + let (host, port) = vnc_connect_target(&session.config); + let transport = tokio::select! { + result = timeout(CONNECT_TIMEOUT, open_tcp_transport( + app, + host, + port, + session.config.network.as_ref(), + Some(session.config.owner_window_label.clone()), + )) => { + result.map_err(|_| (VncErrorKind::Transport, "VNC connection timed out".to_string(), true))? + .map_err(|error| (VncErrorKind::Transport, format!("Unable to connect to the VNC server: {error}"), true))? + } + changed = cancel_rx.changed() => { + let _ = changed; + return Ok(()); + } + }; + let stream = transport.stream; + + set_state(session, VncSessionState::Authenticating, None, None).await; + emit_state(app, session, VncSessionState::Authenticating, None, None); + let password = Zeroizing::new(session.config.password.clone().unwrap_or_default()); + if session.config.security_mode == "vnc-auth" && password.len() > 8 { + return Err(( + VncErrorKind::Authentication, + "Classic VNC authentication passwords must be 8 bytes or fewer".to_string(), + false, + )); + } + + let limits = VncLimits { + max_framebuffer_width: MAX_FRAMEBUFFER_WIDTH, + max_framebuffer_height: MAX_FRAMEBUFFER_HEIGHT, + max_framebuffer_pixels: usize::from(MAX_FRAMEBUFFER_WIDTH) + * usize::from(MAX_FRAMEBUFFER_HEIGHT), + max_clipboard_bytes: MAX_VNC_CLIPBOARD_BYTES, + max_rectangles_per_update: 1024, + max_encoded_payload_bytes: 64 * 1024 * 1024, + max_decoded_payload_bytes: usize::from(MAX_FRAMEBUFFER_WIDTH) + * usize::from(MAX_FRAMEBUFFER_HEIGHT) + * 4, + channel_capacity: 32, + ..VncLimits::default() + }; + let auth_password = password.to_string(); + let has_credentials = session.config.password.is_some(); + let handshake_timeout = if session.config.security_mode == "auto" && has_credentials { + RA2_HANDSHAKE_TIMEOUT + } else { + HANDSHAKE_TIMEOUT + }; + let trust_candidate = Arc::new(std::sync::Mutex::new(None::)); + let verifier_trust_candidate = trust_candidate.clone(); + let authenticated_key = Arc::new(std::sync::Mutex::new(None::)); + let notified_key = authenticated_key.clone(); + let verifier_app = app.clone(); + let verifier_session = session.clone(); + let state = VncConnector::new(stream) + .set_auth_method(async move { Ok(auth_password) }) + .set_credentials_available(has_credentials) + .set_username(session.config.username.clone()) + .set_server_key_verifier(move |server_key| { + let app = verifier_app.clone(); + let session = verifier_session.clone(); + let trust_candidate = verifier_trust_candidate.clone(); + async move { + if let Some(fingerprint) = + verify_vnc_server_key(&app, &session, generation, &server_key).await? + { + *trust_candidate.lock().expect("VNC trust candidate lock") = Some(fingerprint); + } + Ok(()) + } + }) + .set_server_key_authenticated(move |key| { + *notified_key.lock().expect("VNC authenticated key lock") = + Some(vnc_key_fingerprint(&key)); + }) + .set_security_policy(security_policy(&session.config.security_mode)) + .set_pixel_format(PixelFormat::rgba()) + .set_limits(limits) + .add_encoding(VncEncoding::DesktopSizePseudo) + .add_encoding(VncEncoding::Zrle) + .add_encoding(VncEncoding::Tight) + .add_encoding(VncEncoding::Raw) + .allow_shared(session.config.shared) + .build() + .map_err(classify_vnc_error)?; + let handshake_result = tokio::select! { + biased; + changed = cancel_rx.changed() => { let _ = changed; None }, + result = timeout(handshake_timeout, state.try_start()) => { Some(result) }, + }; + // Also clean up a prompt if the outer handshake timeout dropped its future. + resolve_vnc_prompts(app, session, generation).await; + let Some(handshake_result) = handshake_result else { + return Ok(()); + }; + let client = handshake_result + .map_err(|_| { + ( + VncErrorKind::Transport, + "VNC protocol negotiation timed out".to_string(), + true, + ) + })? + .and_then(|state| state.finish()) + .map_err(classify_vnc_error)?; + { + // Serialize the trust write with close/reconnect invalidation. No await + // separates the final generation/cancellation check from the storage write. + let _guard = session + .trust_commit_guard + .lock() + .expect("VNC trust commit lock"); + let trust_candidate = trust_candidate.lock().expect("VNC trust candidate lock"); + let authenticated_key = authenticated_key + .lock() + .expect("VNC authenticated key lock"); + commit_vnc_trust( + trust_candidate.as_deref(), + authenticated_key.as_deref(), + generation, + session.generation.load(Ordering::Acquire), + session.close_requested.load(Ordering::Acquire) || *cancel_rx.borrow(), + |fingerprint| { + crate::storage::upsert_vnc_known_host( + &session.config.host, + session.config.port, + fingerprint, + ) + }, + ) + .map_err(|error| (VncErrorKind::Internal, error.to_string(), false))?; + } + + set_state(session, VncSessionState::Negotiating, None, None).await; + emit_state(app, session, VncSessionState::Negotiating, None, None); + let (command_tx, mut command_rx) = mpsc::channel(WORKER_COMMAND_CHANNEL_CAPACITY); + *session.command_sender.lock().await = Some(command_tx); + let mut pressed_keys = Vec::::new(); + let mut poll = tokio::time::interval(EVENT_POLL_INTERVAL); + poll.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + let mut refresh_due = false; + let refresh_delay = tokio::time::sleep(Duration::from_secs(86_400)); + tokio::pin!(refresh_delay); + + loop { + if session.generation.load(Ordering::Acquire) != generation { + let _ = client.close().await; + return Ok(()); + } + tokio::select! { + changed = cancel_rx.changed() => { + let _ = changed; + release_pressed_keys(&client, &mut pressed_keys).await; + let _ = client.close().await; + return Ok(()); + } + _ = poll.tick() => { + loop { + match client.poll_event().await { + Ok(Some(event)) => { + handle_vnc_event(app, session, generation, event).await?; + refresh_due = true; + refresh_delay.as_mut().reset(tokio::time::Instant::now() + UPDATE_REQUEST_INTERVAL); + } + Ok(None) => break, + Err(error) => return Err(classify_vnc_error(error)), + } + } + } + _ = &mut refresh_delay, if refresh_due => { + client.input(X11Event::Refresh).await.map_err(classify_vnc_error)?; + refresh_due = false; + } + command = command_rx.recv() => { + let Some(command) = command else { + let _ = client.close().await; + return Ok(()); + }; + handle_worker_command(&client, command, &mut pressed_keys).await?; + } + } + } +} + +fn vnc_connect_target(config: &VncConnectConfig) -> (&str, u16) { + (config.host.as_str(), config.port) +} + +fn security_policy(mode: &str) -> VncSecurityPolicy { + match mode { + "none" => VncSecurityPolicy::NoneOnly, + "vnc-auth" => VncSecurityPolicy::VncAuthOnly, + _ => VncSecurityPolicy::Auto, + } +} + +fn vnc_key_fingerprint(key: &VncServerKey) -> String { + format!("SHA256:{}", hex::encode(Sha256::digest(key.encoded()))) +} + +fn invalidate_vnc_generation(session: &VncSession, closing: bool) { + let _guard = session + .trust_commit_guard + .lock() + .expect("VNC trust commit lock"); + session.generation.fetch_add(1, Ordering::AcqRel); + if closing { + session.close_requested.store(true, Ordering::Release); + } +} + +fn commit_vnc_trust( + trust_candidate: Option<&str>, + authenticated_key: Option<&str>, + generation: u64, + current_generation: u64, + cancelled: bool, + save: impl FnOnce(&str) -> AppResult<()>, +) -> AppResult<()> { + if generation == current_generation + && !cancelled + && let (Some(trust_candidate), Some(authenticated_key)) = + (trust_candidate, authenticated_key) + && trust_candidate == authenticated_key + { + save(trust_candidate)?; + } + Ok(()) +} + +async fn resolve_vnc_prompts(app: &AppHandle, session: &VncSession, generation: u64) { + let mut pending = session.pending_server_keys.lock().await; + let ids: Vec<_> = pending + .iter() + .filter(|(_, request)| { + request.session_id == session.config.session_id && request.generation == generation + }) + .map(|(id, _)| id.clone()) + .collect(); + for id in ids { + pending.remove(&id); + let _ = app.emit( + "vnc-server-key-verify-resolved", + serde_json::json!({"requestId": id}), + ); + } +} + +async fn verify_vnc_server_key( + app: &AppHandle, + session: &Arc, + generation: u64, + server_key: &VncServerKey, +) -> Result, VncError> { + if session.generation.load(Ordering::Acquire) != generation + || session.close_requested.load(Ordering::Acquire) + { + return Err(VncError::Ra2ServerKeyRejected( + "stale VNC connection generation".to_string(), + )); + } + + let fingerprint = vnc_key_fingerprint(server_key); + let known_host_status = crate::storage::check_vnc_known_host( + &session.config.host, + session.config.port, + &fingerprint, + ) + .map_err(|error| VncError::Ra2ServerKeyRejected(error.to_string()))?; + let confirmed_new_trust = confirm_vnc_trust( + known_host_status, + prompt_vnc_server_key( + app, + session, + generation, + server_key, + fingerprint.clone(), + known_host_status, + ), + ) + .await?; + Ok(confirmed_new_trust.then_some(fingerprint)) +} + +async fn confirm_vnc_trust( + known_host_status: crate::storage::KnownHostCheck, + prompt: impl std::future::Future>, +) -> Result { + if known_host_status == crate::storage::KnownHostCheck::Match { + return Ok(false); + } + prompt.await?; + Ok(true) +} + +async fn prompt_vnc_server_key( + app: &AppHandle, + session: &Arc, + generation: u64, + server_key: &VncServerKey, + fingerprint: String, + known_host_status: crate::storage::KnownHostCheck, +) -> Result<(), VncError> { + let request_id = uuid::Uuid::new_v4().to_string(); + let (tx, rx) = oneshot::channel(); + let payload = VncServerKeyVerifyEvent { + request_id: request_id.clone(), + session_id: session.config.session_id.clone(), + host: session.config.host.clone(), + port: session.config.port, + fingerprint, + key_bits: server_key.bits(), + target_window_label: session.config.owner_window_label.clone(), + known_host_status: match known_host_status { + crate::storage::KnownHostCheck::Match => "match", + crate::storage::KnownHostCheck::HostSeen => "changed", + crate::storage::KnownHostCheck::UnknownHost => "unknown", + } + .to_string(), + }; + session.pending_server_keys.lock().await.insert( + request_id.clone(), + VncServerKeyPending { + payload: payload.clone(), + session_id: session.config.session_id.clone(), + generation, + responder: tx, + }, + ); + app.emit_to( + &session.config.owner_window_label, + "vnc-server-key-verify", + payload, + ) + .map_err(|error| VncError::Ra2ServerKeyRejected(error.to_string()))?; + + let response = timeout(SERVER_KEY_PROMPT_TIMEOUT, rx).await; + session.pending_server_keys.lock().await.remove(&request_id); + let _ = app.emit( + "vnc-server-key-verify-resolved", + serde_json::json!({"requestId": request_id}), + ); + let Ok(Ok(accepted)) = response else { + return Err(VncError::Ra2ServerKeyRejected( + "verification timed out".to_string(), + )); + }; + if session.generation.load(Ordering::Acquire) != generation + || session.close_requested.load(Ordering::Acquire) + { + return Err(VncError::Ra2ServerKeyRejected( + "stale VNC connection generation".to_string(), + )); + } + if !accepted { + return Err(VncError::Ra2ServerKeyRejected( + "user rejected the server key".to_string(), + )); + } + Ok(()) +} + +async fn handle_vnc_event( + app: &AppHandle, + session: &Arc, + generation: u64, + event: VncEvent, +) -> Result<(), (VncErrorKind, String, bool)> { + if session.generation.load(Ordering::Acquire) != generation { + return Ok(()); + } + match event { + VncEvent::SetResolution(Screen { width, height }) => { + let framebuffer = VncFramebuffer::new(width, height) + .map_err(|error| (VncErrorKind::Protocol, error.to_string(), false))?; + *session.framebuffer.lock().await = Some(framebuffer); + } + VncEvent::RawImage(rect, pixels) => { + let mut framebuffer_guard = session.framebuffer.lock().await; + if framebuffer_guard.is_none() { + let width = rect.x.checked_add(rect.width).ok_or_else(|| { + ( + VncErrorKind::Protocol, + "VNC rectangle bounds overflow".to_string(), + false, + ) + })?; + let height = rect.y.checked_add(rect.height).ok_or_else(|| { + ( + VncErrorKind::Protocol, + "VNC rectangle bounds overflow".to_string(), + false, + ) + })?; + *framebuffer_guard = Some( + VncFramebuffer::new(width, height) + .map_err(|error| (VncErrorKind::Protocol, error.to_string(), false))?, + ); + } + let framebuffer = framebuffer_guard.as_mut().expect("framebuffer initialized"); + framebuffer + .apply_rgba(rect, &pixels) + .map_err(|error| (VncErrorKind::Protocol, error.to_string(), false))?; + let sequence = session.frame_sequence.fetch_add(1, Ordering::AcqRel); + let frame = framebuffer + .patch_bytes(sequence, rect, &pixels) + .map_err(|error| (VncErrorKind::Internal, error.to_string(), false))?; + queue_or_send_frame(session, frame).await; + drop(framebuffer_guard); + let was_active = matches!(*session.state.lock().await, VncSessionState::Active); + if !was_active { + set_state(session, VncSessionState::Active, None, None).await; + emit_state(app, session, VncSessionState::Active, None, None); + let _ = app.emit("sessions-changed", ()); + } + } + VncEvent::Text(text) => { + if session.config.clipboard_enabled && text.len() <= MAX_VNC_CLIPBOARD_BYTES { + let _ = app.emit( + format!("vnc-clipboard-{}", session.config.session_id).as_str(), + VncClipboardEvent { + session_id: session.config.session_id.clone(), + text, + }, + ); + } + } + VncEvent::Error(message) => { + return Err(classify_vnc_event_error(message)); + } + VncEvent::JpegImage(_, _) => { + return Err(( + VncErrorKind::Encoding, + "The server sent a Tight JPEG event instead of decoded RGBA pixels".to_string(), + false, + )); + } + VncEvent::Copy(_, _) | VncEvent::SetCursor(_, _) => { + return Err(( + VncErrorKind::Encoding, + "The server sent an unrequested VNC encoding".to_string(), + false, + )); + } + VncEvent::SetPixelFormat(_) | VncEvent::Bell => {} + _ => {} + } + Ok(()) +} + +fn classify_vnc_event_error(message: String) -> (VncErrorKind, String, bool) { + let lower = message.to_ascii_lowercase(); + let kind = if lower.contains("image") + || lower.contains("encoding") + || lower.contains("zrle") + || lower.contains("tight") + || lower.contains("jpeg") + { + VncErrorKind::Encoding + } else { + VncErrorKind::Protocol + }; + (kind, message, false) +} + +async fn handle_worker_command( + client: &VncClient, + command: VncWorkerCommand, + pressed_keys: &mut Vec, +) -> Result<(), (VncErrorKind, String, bool)> { + match command { + VncWorkerCommand::Input(input) => send_vnc_input(client, input, pressed_keys).await, + VncWorkerCommand::Clipboard(text) => { + client + .input(X11Event::CopyText(text)) + .await + .map_err(|error| { + let (_, message, retryable) = classify_vnc_error(error); + (VncErrorKind::Clipboard, message, retryable) + }) + } + VncWorkerCommand::FullRefresh => client + .input(X11Event::FullRefresh) + .await + .map_err(classify_vnc_error), + } +} + +async fn send_vnc_input( + client: &VncClient, + input: VncInputEvent, + pressed_keys: &mut Vec, +) -> Result<(), (VncErrorKind, String, bool)> { + match input { + VncInputEvent::Key { keysym, pressed } => { + if pressed { + if !pressed_keys.contains(&keysym) { + pressed_keys.push(keysym); + } + } else { + pressed_keys.retain(|key| *key != keysym); + } + client + .input(X11Event::KeyEvent(ClientKeyEvent { + keycode: keysym, + down: pressed, + })) + .await + .map_err(classify_vnc_error)?; + } + VncInputEvent::Pointer { x, y, button_mask } => { + client + .input(X11Event::PointerEvent(ClientMouseEvent { + position_x: x, + position_y: y, + bottons: button_mask, + })) + .await + .map_err(classify_vnc_error)?; + } + VncInputEvent::ReleaseAllKeys => release_pressed_keys(client, pressed_keys).await, + } + Ok(()) +} + +async fn release_pressed_keys(client: &VncClient, pressed_keys: &mut Vec) { + for keysym in pressed_keys.drain(..) { + let _ = client + .input(X11Event::KeyEvent(ClientKeyEvent { + keycode: keysym, + down: false, + })) + .await; + } +} + +async fn send_worker_command( + session: &Arc, + command: VncWorkerCommand, +) -> AppResult<()> { + let sender = session + .command_sender + .lock() + .await + .clone() + .ok_or_else(|| AppError::Channel("VNC worker channel is not active".to_string()))?; + sender + .send(command) + .await + .map_err(|_| AppError::Channel("VNC worker channel closed".to_string())) +} + +async fn stop_worker(session: &Arc) { + if let Some(cancel) = session.cancel_sender.lock().await.take() { + let _ = cancel.send(true); + } + *session.command_sender.lock().await = None; + if let Some(mut worker) = session.worker.lock().await.take() { + if timeout(WORKER_SHUTDOWN_TIMEOUT, &mut worker).await.is_err() { + worker.abort(); + let _ = worker.await; + } + } +} + +async fn queue_or_send_frame(session: &Arc, frame: Vec) { + let attach_id = session.frame_attach_id.load(Ordering::Acquire); + let sent = { + let mut channel = session.frame_channel.lock().await; + match channel.as_ref() { + Some((current_attach_id, sender)) if *current_attach_id == attach_id => { + if sender.send(frame.clone()).is_ok() { + true + } else { + if channel.as_ref().is_some_and(|(id, _)| *id == attach_id) { + *channel = None; + } + false + } + } + _ => false, + } + }; + if sent { + return; + } + + let mut pending = session.pending_frames.lock().await; + while pending.len() >= MAX_PENDING_FRAMES { + pending.pop_front(); + } + pending.push_back(frame); +} + +async fn flush_pending_frames(session: &Arc, attach_id: u64) { + let channel = session.frame_channel.lock().await; + let Some((current_attach_id, channel)) = channel.as_ref() else { + return; + }; + if *current_attach_id != attach_id { + return; + } + let mut pending = session.pending_frames.lock().await; + while let Some(frame) = pending.pop_front() { + if channel.send(frame).is_err() { + break; + } + } +} + +async fn send_full_frame(session: &Arc, attach_id: Option) -> AppResult<()> { + // Keep the snapshot and dispatch ordered with incremental updates. + let framebuffer_guard = session.framebuffer.lock().await; + let frame = { + let Some(framebuffer) = framebuffer_guard.as_ref() else { + return Ok(()); + }; + let sequence = session.frame_sequence.fetch_add(1, Ordering::AcqRel); + framebuffer.full_frame_bytes(sequence)? + }; + if let Some(expected_attach_id) = attach_id { + let mut channel = session.frame_channel.lock().await; + let failed = channel + .as_ref() + .filter(|(current_attach_id, _)| *current_attach_id == expected_attach_id) + .is_some_and(|(_, channel)| channel.send(frame.clone()).is_err()); + if failed + && channel + .as_ref() + .is_some_and(|(current_attach_id, _)| *current_attach_id == expected_attach_id) + { + *channel = None; + } + } else { + queue_or_send_frame(session, frame).await; + } + Ok(()) +} + +async fn set_state( + session: &VncSession, + state: VncSessionState, + message: Option, + error_kind: Option, +) { + *session.state.lock().await = state; + *session.message.lock().await = message; + *session.error_kind.lock().await = error_kind; +} + +fn emit_state( + app: &AppHandle, + session: &VncSession, + state: VncSessionState, + message: Option, + error_kind: Option, +) { + let _ = app.emit( + format!("vnc-state-{}", session.config.session_id).as_str(), + VncStateEvent { + session_id: session.config.session_id.clone(), + state, + message, + error_kind, + }, + ); +} + +async fn replay_state(app: &AppHandle, session: &VncSession) { + emit_state( + app, + session, + session.state.lock().await.clone(), + session.message.lock().await.clone(), + *session.error_kind.lock().await, + ); +} + +fn classify_vnc_error(error: VncError) -> (VncErrorKind, String, bool) { + match error { + VncError::NoPassword | VncError::WrongPassword => ( + VncErrorKind::Authentication, + "VNC authentication failed".to_string(), + false, + ), + VncError::CredentialTooLong { .. } | VncError::Ra2ServerKeyRejected(_) => { + (VncErrorKind::Authentication, error.to_string(), false) + } + VncError::UnsupportedSecurityType + | VncError::RequiredSecurityTypeUnavailable(_) + | VncError::InvalidSecurityType(_) => ( + VncErrorKind::Authentication, + format!( + "The VNC server requires an unsupported security type. Currently supported: None, VNC Authentication, and RA2_256 in Auto mode. Details: {error}" + ), + false, + ), + VncError::InvalidEncoding(_) | VncError::InvalidImageData => { + (VncErrorKind::Encoding, error.to_string(), false) + } + VncError::IoError(ref source) if source.kind() == std::io::ErrorKind::InvalidData => { + (VncErrorKind::Protocol, error.to_string(), false) + } + VncError::IoError(_) => (VncErrorKind::Transport, error.to_string(), true), + VncError::LimitExceeded { .. } + | VncError::InvalidDimensions + | VncError::IntegerOverflow(_) + | VncError::WrongPixelFormat + | VncError::WrongServerMessage + | VncError::InvalidSecurityResult(_) + | VncError::SecurityFailure(_) + | VncError::InvalidRa2KeyLength { .. } + | VncError::InvalidRa2PublicKey + | VncError::InvalidRa2EncryptedRandomLength { .. } + | VncError::InvalidRa2RandomLength(_) + | VncError::Ra2ServerHashMismatch + | VncError::InvalidRa2Subtype(_) + | VncError::Ra2ServerKeyVerifierRequired + | VncError::InvalidRa2RecordLimit(_) + | VncError::Ra2Crypto(_) => (VncErrorKind::Protocol, error.to_string(), false), + _ => (VncErrorKind::Internal, error.to_string(), true), + } +} + +fn reconnect_delay(attempt: u32) -> Duration { + Duration::from_secs(match attempt { + 0 | 1 => 1, + 2 => 2, + 3 => 4, + 4 => 8, + 5 => 15, + _ => 30, + }) +} + +fn validate_framebuffer_dimensions(width: u16, height: u16) -> AppResult<()> { + if width == 0 || height == 0 || width > MAX_FRAMEBUFFER_WIDTH || height > MAX_FRAMEBUFFER_HEIGHT + { + return Err(AppError::Config(format!( + "VNC framebuffer {width}x{height} is outside the supported range" + ))); + } + Ok(()) +} + +fn validate_rectangle(rect: Rect, desktop_width: u16, desktop_height: u16) -> AppResult<()> { + let right = rect + .x + .checked_add(rect.width) + .ok_or_else(|| AppError::Config("VNC rectangle horizontal bounds overflow".to_string()))?; + let bottom = rect + .y + .checked_add(rect.height) + .ok_or_else(|| AppError::Config("VNC rectangle vertical bounds overflow".to_string()))?; + if rect.width == 0 || rect.height == 0 || right > desktop_width || bottom > desktop_height { + return Err(AppError::Config( + "VNC rectangle exceeds framebuffer bounds".to_string(), + )); + } + Ok(()) +} + +pub fn load_saved_vnc_config( + app: &AppHandle, + connection_id: &str, + owner_window_label: String, +) -> AppResult { + let connection = config::load_connection_by_id(app, connection_id)?; + config_from_connection(app, connection, owner_window_label) +} +pub fn config_from_connection( + app: &impl Sized, + connection: config::SavedConnection, + owner_window_label: String, +) -> AppResult { + let connection_id = connection.id.clone(); + let network = connection.network.clone(); + let password = resolve_vnc_password(app, connection.auth.as_ref())?; + let ConnectionType::Vnc { + host, + port, + username, + security, + display, + clipboard, + reconnect, + shared, + view_only, + } = connection.config + else { + return Err(AppError::Config( + "Connection is not a VNC connection".to_string(), + )); + }; + if matches!(security.mode.as_str(), "vnc-auth") && password.is_none() { + return Err(AppError::Config( + "VNC Authentication requires a password".to_string(), + )); + } + if security.mode == "vnc-auth" + && let Some(password) = password.as_ref() + && password.len() > 8 + { + return Err(AppError::Config( + "Classic VNC authentication passwords must be 8 bytes or fewer".to_string(), + )); + } + + Ok(VncConnectConfig { + owner_window_label, + session_id: uuid::Uuid::new_v4().to_string(), + connection_id: connection_id.to_string(), + name: connection.name, + host, + port, + username, + password, + security_mode: security.mode, + scale_mode: display.scale_mode, + clipboard_enabled: clipboard.enabled, + reconnect_enabled: reconnect.enabled, + reconnect_max_attempts: reconnect.max_attempts, + shared, + view_only, + network, + }) +} + +fn resolve_vnc_password( + app: &impl Sized, + auth: Option<&ConnectionAuth>, +) -> AppResult> { + let Some(auth) = auth else { + return Ok(None); + }; + if auth.mode != "password" { + return Ok(None); + } + if let Some(password_id) = auth.password_id.as_deref().filter(|id| !id.is_empty()) { + let account = config::load_saved_account(app, None, Some(password_id))?; + return config::decrypt_account_password(account.as_ref()); + } + crate::utils::crypto::decrypt_optional(&auth.password) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn framebuffer_rejects_oversized_and_out_of_bounds_data() { + assert!(VncFramebuffer::new(7681, 1).is_err()); + let mut framebuffer = VncFramebuffer::new(10, 10).expect("framebuffer"); + assert!( + framebuffer + .apply_rgba( + Rect { + x: 10, + y: 0, + width: 1, + height: 1, + }, + &[0; 4] + ) + .is_err() + ); + } + + #[test] + fn framebuffer_applies_patches_and_builds_full_refreshes() { + let mut framebuffer = VncFramebuffer::new(2, 2).expect("framebuffer"); + framebuffer + .apply_rgba( + Rect { + x: 1, + y: 1, + width: 1, + height: 1, + }, + &[1, 2, 3, 255], + ) + .expect("patch"); + let frame = framebuffer.full_frame_bytes(7).expect("full frame"); + assert_eq!(&frame[0..8], &7_u64.to_le_bytes()); + assert_eq!(&frame[40..44], &16_u32.to_le_bytes()); + assert_eq!(&frame[56..60], &[1, 2, 3, 255]); + } + + #[test] + fn reconnect_delay_is_bounded() { + assert_eq!(reconnect_delay(1), Duration::from_secs(1)); + assert_eq!(reconnect_delay(100), Duration::from_secs(30)); + } + + #[tokio::test] + async fn vnc_tofu_commits_only_confirmed_successful_current_handshakes() { + use crate::storage::{KnownHostCheck, Storage}; + for (name, old_key, accepted, authenticated, current_generation, cancelled, expected) in [ + ( + "unknown-success", + None, + true, + true, + 1, + false, + KnownHostCheck::Match, + ), + ( + "unknown-reject", + None, + false, + false, + 1, + false, + KnownHostCheck::UnknownHost, + ), + ( + "hash-mismatch", + None, + true, + false, + 1, + false, + KnownHostCheck::UnknownHost, + ), + ( + "transport-error", + None, + true, + false, + 1, + false, + KnownHostCheck::UnknownHost, + ), + ( + "timeout", + None, + true, + false, + 1, + false, + KnownHostCheck::UnknownHost, + ), + ( + "changed-success", + Some("SHA256:old"), + true, + true, + 1, + false, + KnownHostCheck::Match, + ), + ( + "changed-reject", + Some("SHA256:old"), + false, + false, + 1, + false, + KnownHostCheck::HostSeen, + ), + ( + "stale-generation", + None, + true, + true, + 2, + false, + KnownHostCheck::UnknownHost, + ), + ( + "cancelled", + None, + true, + true, + 1, + true, + KnownHostCheck::UnknownHost, + ), + ] { + let dir = + std::env::temp_dir().join(format!("vnc-tofu-{name}-{}", uuid::Uuid::new_v4())); + let storage = Storage::open(&dir).unwrap(); + if let Some(old_key) = old_key { + storage + .upsert_vnc_known_host("pi.local", 5900, old_key) + .unwrap(); + } + let status = storage + .check_vnc_known_host("pi.local", 5900, "SHA256:new") + .unwrap(); + let trust_candidate = match confirm_vnc_trust(status, async { + if accepted { + Ok(()) + } else { + Err(VncError::Ra2ServerKeyRejected("rejected".into())) + } + }) + .await + { + Ok(true) => Some("SHA256:new"), + Ok(false) | Err(_) => None, + }; + let authenticated_key = authenticated.then_some("SHA256:new"); + commit_vnc_trust( + trust_candidate, + authenticated_key, + 1, + current_generation, + cancelled, + |fingerprint| storage.upsert_vnc_known_host("pi.local", 5900, fingerprint), + ) + .unwrap(); + assert_eq!( + storage + .check_vnc_known_host("pi.local", 5900, "SHA256:new") + .unwrap(), + expected, + "{name}" + ); + if old_key.is_some() && expected != KnownHostCheck::Match { + assert_eq!( + storage + .check_vnc_known_host("pi.local", 5900, "SHA256:old") + .unwrap(), + KnownHostCheck::Match + ); + } + drop(storage); + std::fs::remove_dir_all(dir).unwrap(); + } + } + + #[tokio::test] + async fn matching_vnc_key_does_not_poll_user_confirmation() { + assert!( + !confirm_vnc_trust(crate::storage::KnownHostCheck::Match, async { + panic!("matching key must never prompt"); + }) + .await + .unwrap() + ); + } + + #[tokio::test] + async fn matching_vnc_key_cannot_overwrite_newer_explicit_trust() { + use crate::storage::{KnownHostCheck, Storage}; + + let dir = std::env::temp_dir().join(format!("vnc-tofu-race-{}", uuid::Uuid::new_v4())); + let storage = Storage::open(&dir).unwrap(); + storage + .upsert_vnc_known_host("pi.local", 5900, "SHA256:k1") + .unwrap(); + + let status = storage + .check_vnc_known_host("pi.local", 5900, "SHA256:k1") + .unwrap(); + let trust_candidate = confirm_vnc_trust(status, async { + panic!("matching key must never prompt"); + }) + .await + .unwrap() + .then_some("SHA256:k1"); + assert!(trust_candidate.is_none()); + + storage + .upsert_vnc_known_host("pi.local", 5900, "SHA256:k2") + .unwrap(); + + commit_vnc_trust( + trust_candidate, + Some("SHA256:k1"), + 1, + 1, + false, + |fingerprint| storage.upsert_vnc_known_host("pi.local", 5900, fingerprint), + ) + .unwrap(); + + assert_eq!( + storage + .check_vnc_known_host("pi.local", 5900, "SHA256:k2") + .unwrap(), + KnownHostCheck::Match + ); + drop(storage); + std::fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn vnc_verification_payload_routes_to_its_session_owner() { + let payload = VncServerKeyVerifyEvent { + request_id: "request".into(), + session_id: "session".into(), + host: "pi.local".into(), + port: 5900, + fingerprint: "key".into(), + key_bits: 1024, + known_host_status: "unknown".into(), + target_window_label: "main-second".into(), + }; + assert_eq!( + serde_json::to_value(payload).unwrap()["targetWindowLabel"], + "main-second" + ); + } + + #[test] + fn security_mode_maps_to_fail_closed_policy() { + assert_eq!(security_policy("none"), VncSecurityPolicy::NoneOnly); + assert_eq!(security_policy("vnc-auth"), VncSecurityPolicy::VncAuthOnly); + assert_eq!(security_policy("auto"), VncSecurityPolicy::Auto); + } + + #[test] + fn connect_target_preserves_ipv6_literals_without_host_port_concatenation() { + let config = VncConnectConfig { + owner_window_label: "main-test".into(), + session_id: "vnc-test".to_string(), + connection_id: "connection-test".to_string(), + name: "IPv6 VNC".to_string(), + host: "::1".to_string(), + port: 5900, + username: String::new(), + password: None, + security_mode: "none".to_string(), + scale_mode: "fit".to_string(), + clipboard_enabled: true, + reconnect_enabled: false, + reconnect_max_attempts: 0, + shared: true, + view_only: false, + network: None, + }; + + assert_eq!(vnc_connect_target(&config), ("::1", 5900)); + } + + #[tokio::test] + async fn pending_frame_queue_keeps_latest_frames_under_pressure() { + let session = test_session(); + + queue_or_send_frame(&session, vec![1]).await; + queue_or_send_frame(&session, vec![2]).await; + queue_or_send_frame(&session, vec![3]).await; + + let pending = session.pending_frames.lock().await; + assert_eq!(pending.len(), MAX_PENDING_FRAMES); + assert_eq!(pending[0], vec![2]); + assert_eq!(pending[1], vec![3]); + } + fn test_session() -> Arc { + Arc::new(VncSession { + config: VncConnectConfig { + owner_window_label: "main-test".into(), + session_id: "vnc-test".to_string(), + connection_id: "connection-test".to_string(), + name: "Test VNC".to_string(), + host: "127.0.0.1".to_string(), + port: 5900, + username: String::new(), + password: None, + security_mode: "none".to_string(), + scale_mode: "fit".to_string(), + clipboard_enabled: true, + reconnect_enabled: false, + reconnect_max_attempts: 0, + shared: true, + view_only: false, + network: None, + }, + state: Mutex::new(VncSessionState::Connecting), + message: Mutex::new(None), + error_kind: Mutex::new(None), + generation: AtomicU64::new(0), + frame_sequence: AtomicU64::new(0), + frame_attach_id: AtomicU64::new(0), + frame_channel: Mutex::new(None), + pending_frames: Mutex::new(VecDeque::with_capacity(MAX_PENDING_FRAMES)), + framebuffer: Mutex::new(None), + command_sender: Mutex::new(None), + cancel_sender: Mutex::new(None), + worker: Mutex::new(None), + close_requested: AtomicBool::new(false), + trust_commit_guard: std::sync::Mutex::new(()), + pending_server_keys: Arc::new(Mutex::new(HashMap::new())), + }) + } + #[tokio::test] + async fn bounded_subscriber_recovers_dropped_patches_with_a_complete_frame() { + let manager = VncSessionManager::new(); + let session = test_session(); + *session.framebuffer.lock().await = Some(VncFramebuffer::new(2, 1).unwrap()); + manager + .sessions + .lock() + .await + .insert(session.config.session_id.clone(), session.clone()); + let context = AppHandle { + events: Arc::new(|_, _, _| Ok(())), + transport: Arc::new(|_, _, _, _| Box::pin(async { panic!("no network needed") })), + }; + let (tx, mut rx) = mpsc::channel(2); + let channel = FrameChannel(Arc::new(move |bytes| { + tx.try_send(bytes) + .map_err(|_| AppError::Channel("slow client".into())) + })); + manager + .attach_frame_channel(&context, "vnc-test", channel.clone()) + .await + .unwrap(); + for value in 1..=10 { + handle_vnc_event( + &context, + &session, + 0, + VncEvent::RawImage( + Rect { + x: 1, + y: 0, + width: 1, + height: 1, + }, + vec![value, 20, 30, 255], + ), + ) + .await + .unwrap(); + } + assert!(session.frame_channel.lock().await.is_none()); + assert!(session.pending_frames.lock().await.len() <= 2); + while rx.try_recv().is_ok() {} + manager + .attach_frame_channel(&context, "vnc-test", channel) + .await + .unwrap(); + let full = rx.recv().await.unwrap(); + assert_eq!(full.len(), 52); + assert_eq!(u32::from_le_bytes(full[16..20].try_into().unwrap()), 0); + assert_eq!(u32::from_le_bytes(full[24..28].try_into().unwrap()), 2); + assert_eq!(&full[48..52], &[10, 20, 30, 255]); + assert!(rx.try_recv().is_err()); + } +} diff --git a/src-tauri/crates/nyaterm-core/src/vnc/mod.rs b/src-tauri/crates/nyaterm-core/src/vnc/mod.rs new file mode 100644 index 00000000..befc0e77 --- /dev/null +++ b/src-tauri/crates/nyaterm-core/src/vnc/mod.rs @@ -0,0 +1,3 @@ +mod manager; +pub mod runtime; +pub use manager::*; diff --git a/src-tauri/crates/nyaterm-core/src/vnc/runtime.rs b/src-tauri/crates/nyaterm-core/src/vnc/runtime.rs new file mode 100644 index 00000000..d85d87a0 --- /dev/null +++ b/src-tauri/crates/nyaterm-core/src/vnc/runtime.rs @@ -0,0 +1,46 @@ +use crate::{config::ConnectionNetwork, error::AppResult, network::OpenedTransport}; +use futures_util::future::BoxFuture; +use serde::Serialize; +use serde_json::Value; +use std::sync::Arc; + +type EventSink = dyn Fn(Option<&str>, &str, Value) -> AppResult<()> + Send + Sync; +type Transport = dyn Fn( + String, + u16, + Option, + Option, + ) -> BoxFuture<'static, AppResult> + + Send + + Sync; + +/// The protocol engine owns no native application handles or IPC channels. +#[derive(Clone)] +pub struct VncContext { + pub events: Arc, + pub transport: Arc, +} +impl VncContext { + pub fn emit(&self, event: &str, payload: T) -> AppResult<()> { + (self.events)(None, event, serde_json::to_value(payload)?) + } + pub fn emit_to(&self, target: &str, event: &str, payload: T) -> AppResult<()> { + (self.events)(Some(target), event, serde_json::to_value(payload)?) + } +} +#[derive(Clone)] +pub struct FrameChannel(pub Arc) -> AppResult<()> + Send + Sync>); +impl FrameChannel { + pub fn send(&self, frame: Vec) -> AppResult<()> { + (self.0)(frame) + } +} +pub async fn open_tcp_transport( + context: &VncContext, + host: &str, + port: u16, + network: Option<&ConnectionNetwork>, + owner: Option, +) -> AppResult { + (context.transport)(host.to_owned(), port, network.cloned(), owner).await +} diff --git a/src-tauri/crates/nyaterm-plugin-runtime/src/marketplace.rs b/src-tauri/crates/nyaterm-plugin-runtime/src/marketplace.rs index a46406eb..9b30369e 100644 --- a/src-tauri/crates/nyaterm-plugin-runtime/src/marketplace.rs +++ b/src-tauri/crates/nyaterm-plugin-runtime/src/marketplace.rs @@ -9,7 +9,7 @@ use std::collections::HashSet; pub const REPOSITORY_ID: &str = "nyaterm-official"; pub const CATALOG_URL: &str = - "https://raw.githubusercontent.com/nyakang/nyaterm-plugins/main/catalog/index.json"; + "https://raw.githubusercontent.com/nyakang/nyaterm-plugins/main/public/v1/plugins.json"; pub const MAX_CATALOG_BYTES: usize = 4 * 1024 * 1024; pub const TARGETS: &[&str] = &[ "universal", diff --git a/src-tauri/crates/nyaterm-plugin-runtime/tests/store_catalog.rs b/src-tauri/crates/nyaterm-plugin-runtime/tests/store_catalog.rs new file mode 100644 index 00000000..b8807d3d --- /dev/null +++ b/src-tauri/crates/nyaterm-plugin-runtime/tests/store_catalog.rs @@ -0,0 +1,18 @@ +//! Interoperability check for a generated Store catalog supplied by CI or maintainers. +use nyaterm_plugin_runtime::marketplace::Catalog; + +#[test] +fn parses_generated_store_catalogs() { + let Some(paths) = std::env::var_os("NYATERM_STORE_CATALOGS") else { + return; + }; + let paths = std::env::split_paths(&paths).collect::>(); + assert!(!paths.is_empty(), "Supply at least one generated catalog"); + for path in paths { + let bytes = std::fs::read(&path).expect("Read the generated Store catalog"); + let catalog = + Catalog::parse(&bytes).unwrap_or_else(|error| panic!("{}: {error}", path.display())); + let serialized = serde_json::to_vec(&catalog).unwrap(); + Catalog::parse(&serialized).expect("Catalog survives client serialization"); + } +} diff --git a/src-tauri/crates/nyaterm-web/Cargo.lock b/src-tauri/crates/nyaterm-web/Cargo.lock new file mode 100644 index 00000000..b049ca89 --- /dev/null +++ b/src-tauri/crates/nyaterm-web/Cargo.lock @@ -0,0 +1,5266 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common 0.1.7", + "generic-array 0.14.7", +] + +[[package]] +name = "aead" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1973cfbc1a2daf9cf550e74e1f088c28e7f7d8c1e1418fb6c9dc5184b7e84c99" +dependencies = [ + "crypto-common 0.2.2", + "inout 0.2.2", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher 0.4.4", + "cpufeatures 0.2.17", +] + +[[package]] +name = "aes" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35f0f96ce78e38c3dc6d8948aa8163d06385be74000f3c7a95bf1eef35d3ea32" +dependencies = [ + "cipher 0.5.2", + "cpubits", + "cpufeatures 0.3.1", + "zeroize", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead 0.5.2", + "aes 0.8.4", + "cipher 0.4.4", + "ctr 0.9.2", + "ghash 0.5.1", + "subtle", +] + +[[package]] +name = "aes-gcm" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f2b8006a0c83f52b62ba44a97b58bf76fe2f70a329e588f67f89691d93d498f" +dependencies = [ + "aead 0.6.1", + "aes 0.9.3", + "cipher 0.5.2", + "ctr 0.10.1", + "ctutils", + "ghash 0.6.0", + "zeroize", +] + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + +[[package]] +name = "argon2" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "134c52ddac6d63c576bef8168db10c83c49c26444ecbc68060fef078925a901c" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures 0.3.1", + "password-hash", +] + +[[package]] +name = "async-compression" +version = "0.4.50" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee19bd99b43e3691acbad4e840420a4881cea6c0b66a208125a824f8fd53f5a1" +dependencies = [ + "compression-codecs", + "compression-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "async-http-proxy" +version = "1.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29faa5d4d308266048bd7505ba55484315a890102f9345b9ff4b87de64201592" +dependencies = [ + "base64 0.13.1", + "httparse", + "thiserror 1.0.69", + "tokio", +] + +[[package]] +name = "async-stream" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" +dependencies = [ + "async-stream-impl", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "async_io_stream" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d7b9decdf35d8908a7e3ef02f64c5e9b1695e230154c0e8de3969142d9b94c" +dependencies = [ + "futures", + "rustc_version", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "base64 0.22.1", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "multer", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sha1 0.10.7", + "sync_wrapper", + "tokio", + "tokio-tungstenite", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + +[[package]] +name = "base16ct" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd307490d624467aa6f74b0eabb77633d1f758a7b25f12bceb0b22e08d9726f6" + +[[package]] +name = "base64" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bcrypt-pbkdf" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "144e573728da132683b9488acd528274c790e07fc06ff81ee29f9d8f8b1041e0" +dependencies = [ + "blowfish", + "pbkdf2 0.13.0", + "sha2 0.11.0", +] + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" +dependencies = [ + "serde_core", +] + +[[package]] +name = "blake2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b5d4d889834ee8ecfc0f8426ad30faf7cdcb10f741a8e6d7224d95325479f6f" +dependencies = [ + "digest 0.11.3", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array 0.14.7", +] + +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "710f1dd022ef4e93f8a438b4ba958de7f64308434fa6a87104481645cc30068b" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "blowfish" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62ce3946557b35e71d1bbe07ec385073ce9eda05043f95de134eb578fcf1a298" +dependencies = [ + "byteorder", + "cipher 0.5.2", +] + +[[package]] +name = "bs58" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "bzip2" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3a53fac24f34a81bc9954b5d6cfce0c21e18ec6959f44f56e8e90e4bb7c346c" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce2dc9ee5f88d11e0beb842c88b33c8a5cf0d1329c4b19494af42b07dbfe8896" +dependencies = [ + "cipher 0.5.2", +] + +[[package]] +name = "cc" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f74872d07caf508b30a21f6836e7d7016a2eaf7d9ff4f48deaa58cd8a0407630" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cipher 0.5.2", + "cpufeatures 0.3.1", + "rand_core 0.10.1", + "zeroize", +] + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common 0.1.7", + "inout 0.1.4", +] + +[[package]] +name = "cipher" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c" +dependencies = [ + "block-buffer 0.12.1", + "crypto-common 0.2.2", + "inout 0.2.2", + "zeroize", +] + +[[package]] +name = "cmac" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8543454e3c3f5126effff9cd44d562af4e31fb8ce1cc0d3dcd8f084515dbc1aa" +dependencies = [ + "cipher 0.4.4", + "dbl", + "digest 0.10.7", +] + +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + +[[package]] +name = "compression-codecs" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98fc98460ba0ad5317075d3632b8dfc45d0be8c4a49347c2a38272019717614a" +dependencies = [ + "compression-core", + "flate2", + "memchr", +] + +[[package]] +name = "compression-core" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e8ccc4ea9f6acc32d102c0f6d471d11d913ad15f20c04de743374861fa1d414" + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + +[[package]] +name = "constant_time_eq" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" + +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core-graphics" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081" +dependencies = [ + "bitflags 1.3.2", + "core-foundation 0.9.4", + "core-graphics-types", + "foreign-types 0.5.0", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf" +dependencies = [ + "bitflags 1.3.2", + "core-foundation 0.9.4", + "libc", +] + +[[package]] +name = "core-text" +version = "20.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9d2790b5c08465d49f8dc05c8bcae9fea467855947db39b0f8145c091aaced5" +dependencies = [ + "core-foundation 0.9.4", + "core-graphics", + "foreign-types 0.5.0", + "libc", +] + +[[package]] +name = "core_detect" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48" + +[[package]] +name = "cpubits" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" + +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array 0.14.7", + "rand_core 0.6.4", + "subtle", + "zeroize", +] + +[[package]] +name = "crypto-bigint" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a52aa3fcda4e6302a9f48734f234d35d4721b96f8fe07d073f07ce9df4f0271" +dependencies = [ + "cpubits", + "ctutils", + "getrandom 0.4.3", + "hybrid-array", + "num-traits", + "rand_core 0.10.1", + "serdect", + "subtle", + "zeroize", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array 0.14.7", + "rand_core 0.6.4", + "typenum", +] + +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "getrandom 0.4.3", + "hybrid-array", + "rand_core 0.10.1", +] + +[[package]] +name = "crypto-primes" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3633a51a39c69ebbaa4feaa694bd83d241e4093901c84a0963b19d9bb3f0cf8f" +dependencies = [ + "crypto-bigint 0.7.5", + "rand_core 0.10.1", +] + +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher 0.4.4", +] + +[[package]] +name = "ctr" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baaca1c4b237092596f64d571e9db6ce4109c4ef9742e27590f1709594461f21" +dependencies = [ + "cipher 0.5.2", +] + +[[package]] +name = "ctutils" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov", + "subtle", +] + +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "curve25519-dalek-derive", + "digest 0.10.7", + "fiat-crypto 0.2.9", + "rustc_version", + "subtle", +] + +[[package]] +name = "curve25519-dalek" +version = "5.0.0-rc.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c906a87e53a36ff795d72e06e8162a83c5436e3ea89e942a9cb9fc083f0a384f" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "curve25519-dalek-derive", + "digest 0.11.3", + "fiat-crypto 0.3.0", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "darling" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed17f5901b6630b993ca003def43f2f8ef4014fc13b047b57aad617ff32bc2ec" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6837e2cf7485aaae18f86181d2f0e9a7ed297a025e220aeabf63fdebd3a2ddff" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 3.0.6", +] + +[[package]] +name = "darling_macro" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785" +dependencies = [ + "darling_core", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "dashmap" +version = "6.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c" +dependencies = [ + "cfg-if", + "crossbeam-utils", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core", +] + +[[package]] +name = "data-encoding" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" + +[[package]] +name = "dbl" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd2735a791158376708f9347fe8faba9667589d82427ef3aed6794a8981de3d9" +dependencies = [ + "generic-array 0.14.7", +] + +[[package]] +name = "deflate64" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac6b926516df9c60bfa16e107b21086399f8285a44ca9711344b9e553c5146e2" + +[[package]] +name = "defmt" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1" +dependencies = [ + "bitflags 1.3.2", + "defmt-macros", +] + +[[package]] +name = "defmt-macros" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8" +dependencies = [ + "defmt-parser", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "defmt-parser" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +dependencies = [ + "thiserror 2.0.21", +] + +[[package]] +name = "delegate" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "780eb241654bf097afb00fc5f054a09b687dad862e485fdcf8399bb056565370" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid 0.9.6", + "pem-rfc7468 0.7.0", + "zeroize", +] + +[[package]] +name = "der" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a878c850e9e421b20262e9b41f9c860e4785fa07541c266b62ff9d1ef998a80a" +dependencies = [ + "const-oid 0.10.2", + "pem-rfc7468 1.0.0", + "zeroize", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "serde_core", +] + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.119", + "unicode-xid", +] + +[[package]] +name = "des" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "916a94e407b54f9034d71dd748234cd1e516ced6284009906ae246f177eafe5a" +dependencies = [ + "cipher 0.5.2", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "const-oid 0.9.6", + "crypto-common 0.1.7", + "subtle", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "const-oid 0.10.2", + "crypto-common 0.2.2", + "ctutils", + "zeroize", +] + +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.61.2", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "dlib" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a" +dependencies = [ + "libloading", +] + +[[package]] +name = "dsa" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c8573b60d07b5b5cecadc1f978c9b71d5504151e703cbb0439ee4ad301338f3" +dependencies = [ + "crypto-bigint 0.7.5", + "crypto-common 0.2.2", + "crypto-primes", + "der 0.8.2", + "digest 0.11.3", + "rfc6979 0.6.0", + "sha2 0.11.0", + "signature 3.0.0", + "zeroize", +] + +[[package]] +name = "dwrote" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1b35532432acc8b19ceed096e35dfa088d3ea037fe4f3c085f1f97f33b4d02" +dependencies = [ + "lazy_static", + "libc", + "winapi", + "wio", +] + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "eax" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9954fabd903b82b9d7a68f65f97dc96dd9ad368e40ccc907a7c19d53e6bfac28" +dependencies = [ + "aead 0.5.2", + "cipher 0.4.4", + "cmac", + "ctr 0.9.2", + "subtle", +] + +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der 0.7.10", + "digest 0.10.7", + "elliptic-curve 0.13.8", + "rfc6979 0.4.0", + "signature 2.2.0", + "spki 0.7.3", +] + +[[package]] +name = "ecdsa" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0681a4fc24c767085329728d8dfba959af91228aa4610cca4f8ce317ba46ae0" +dependencies = [ + "der 0.8.2", + "digest 0.11.3", + "elliptic-curve 0.14.1", + "rfc6979 0.6.0", + "signature 3.0.0", + "spki 0.8.0", + "zeroize", +] + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "signature 2.2.0", +] + +[[package]] +name = "ed25519" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29fcf32e6c73d1079f83ab4d782de2d81620346a5f38c6237a86a22f8368980a" +dependencies = [ + "pkcs8 0.11.0", + "signature 3.0.0", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek 4.1.3", + "ed25519 2.2.3", + "sha2 0.10.9", + "subtle", +] + +[[package]] +name = "ed25519-dalek" +version = "3.0.0-rc.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1685663e23882cd8517dcbcb1c23a6ebff4433c22dfb681d760219b62cd1b849" +dependencies = [ + "curve25519-dalek 5.0.0-rc.1", + "ed25519 3.0.0", + "rand_core 0.10.1", + "serde", + "sha2 0.11.0", + "signature 3.0.0", + "subtle", + "zeroize", +] + +[[package]] +name = "either" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" + +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct 0.2.0", + "crypto-bigint 0.5.5", + "digest 0.10.7", + "ff 0.13.1", + "generic-array 0.14.7", + "group 0.13.0", + "pkcs8 0.10.2", + "rand_core 0.6.4", + "sec1 0.7.3", + "subtle", + "zeroize", +] + +[[package]] +name = "elliptic-curve" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d65aa39b3a5c1c9c1b745c9a019234bb7a21b77abcb4f4d266d706e2d577d65" +dependencies = [ + "base16ct 1.0.0", + "crypto-bigint 0.7.5", + "crypto-common 0.2.2", + "digest 0.11.3", + "ff 0.14.0", + "group 0.14.0", + "hkdf", + "hybrid-array", + "pem-rfc7468 1.0.0", + "pkcs8 0.11.0", + "rand_core 0.10.1", + "sec1 0.8.1", + "subtle", + "zeroize", +] + +[[package]] +name = "encoding_rs" +version = "0.8.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e985e0451871ad22fb8d2b6b076e2028a502a0d3950998c2c5c0a4f9b5d9679" +dependencies = [ + "cfg-if", + "core_detect", + "multiversion_no_op", + "rustversion", + "scopeguard", + "simdutf8", +] + +[[package]] +name = "enum_dispatch" +version = "0.3.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa18ce2bc66555b3218614519ac839ddb759a7d6720732f979ef8d13be147ecd" +dependencies = [ + "once_cell", + "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.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "eventsource-stream" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74fef4569247a5f429d9156b9d0a2599914385dd189c539334c625d8099d90ab" +dependencies = [ + "futures-core", + "nom", + "pin-project-lite", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "ff" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1f686ab92a9fb0eaf188f6c6c87b89490baa6fdb0db4544ba4dc47f7942489f" +dependencies = [ + "rand_core 0.10.1", + "subtle", +] + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + +[[package]] +name = "fiat-crypto" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64cd1e32ddd350061ae6edb1b082d7c54915b5c672c389143b9a63403a109f24" + +[[package]] +name = "find-msvc-tools" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" + +[[package]] +name = "flate2" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +dependencies = [ + "crc32fast", + "miniz_oxide", + "zlib-rs", +] + +[[package]] +name = "float-ord" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ce81f49ae8a0482e4c55ea62ebbd7e5a686af544c00b9d090bba3ff9be97b3d" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "font-kit" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c7e611d49285d4c4b2e1727b72cf05353558885cc5252f93707b845dfcaf3d3" +dependencies = [ + "bitflags 2.13.2", + "byteorder", + "core-foundation 0.9.4", + "core-graphics", + "core-text", + "dirs", + "dwrote", + "float-ord", + "freetype-sys", + "lazy_static", + "libc", + "log", + "pathfinder_geometry", + "pathfinder_simd", + "walkdir", + "winapi", + "yeslogic-fontconfig-sys", +] + +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared 0.1.1", +] + +[[package]] +name = "foreign-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +dependencies = [ + "foreign-types-macros", + "foreign-types-shared 0.3.1", +] + +[[package]] +name = "foreign-types-macros" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea5190182e6915eb873ddbc16e23b711b6eb1f9c00a0d0a3a91b5f6228475225" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "freetype-sys" +version = "0.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e7edc5b9669349acfda99533e9e0bcf26a51862ab43b08ee7745c55d28eb134" +dependencies = [ + "cc", + "libc", + "pkg-config", +] + +[[package]] +name = "futures" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-executor" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "genai" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d12aba7e9dc2c4d54654566dc3dc8383b5cb52e0cfc5754989afe0480d933e3" +dependencies = [ + "base64 0.22.1", + "bytes", + "derive_more", + "eventsource-stream", + "futures", + "mime_guess", + "paste", + "regex", + "reqwest", + "serde", + "serde_json", + "serde_with", + "strum", + "tokio", + "tokio-stream", + "tracing", + "uuid", + "value-ext", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", + "zeroize", +] + +[[package]] +name = "generic-array" +version = "1.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "337d46834ee672ab3e48caca2cb0c78cc174fb12b3a68d0d88f99a0519a5e36e" +dependencies = [ + "generic-array 0.14.7", + "rustversion", + "typenum", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 6.0.0", + "rand_core 0.10.1", + "wasm-bindgen", +] + +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval 0.6.2", +] + +[[package]] +name = "ghash" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2eecf2d5dc9b66b732b97707a0210906b1d30523eb773193ab777c0c84b3e8d5" +dependencies = [ + "polyval 0.7.3", + "zeroize", +] + +[[package]] +name = "gloo-timers" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "482ce8a491a501da4cd806bd190275363d674f2845005c6ddbd5d3e1dd54495d" +dependencies = [ + "futures-channel", + "futures-core", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff 0.13.1", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "group" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fd1a1c7a5206c5b7a3f5a0d7ccd3ff85d0c8f5133d62a02680255b0004af5f4" +dependencies = [ + "ff 0.14.0", + "rand_core 0.10.1", + "subtle", +] + +[[package]] +name = "h2" +version = "0.4.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef8e5e5a340588f4452631496976cf8636d4a7ecf600239fdc27615d2530bc16" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap 2.14.2", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hex-literal" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e712f64ec3850b98572bffac52e2c6f282b29fe6c5fa6d42334b30be438d95c1" + +[[package]] +name = "hkdf" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4aaa26c720c68b866f2c96ef5c1264b3e6f473fe5d4ce61cd44bbe913e553018" +dependencies = [ + "hmac 0.13.0", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest 0.10.7", +] + +[[package]] +name = "hmac" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" +dependencies = [ + "digest 0.11.3", +] + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "http-range-header" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c" + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hybrid-array" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17" +dependencies = [ + "ctutils", + "subtle", + "typenum", + "zeroize", +] + +[[package]] +name = "hyper" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b501faa50e7a26c3d3560ca625132f4078a17771f4810baf70475ae48cbe43" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfa8e654703247911e29c23fbeaa261834bd9bb74efba2f9acddc37bfb127f53" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-tls" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" +dependencies = [ + "bytes", + "http-body-util", + "hyper", + "hyper-util", + "native-tls", + "tokio", + "tokio-native-tls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff" +dependencies = [ + "base64 0.23.1", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "httparse", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "system-configuration", + "tokio", + "tower-service", + "tracing", + "windows-registry", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "moxcms", + "num-traits", + "zune-core", + "zune-jpeg", +] + +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "inout" +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", +] + +[[package]] +name = "inout" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7" +dependencies = [ + "block-padding 0.4.2", + "hybrid-array", +] + +[[package]] +name = "internal-russh-num-bigint" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae8e22120c32fb4d19ec55fba35015f57095cd95a2e3b732e44457f5915b2ee8" +dependencies = [ + "num-integer", + "num-traits", + "rand 0.10.3", + "rand_core 0.10.1", +] + +[[package]] +name = "ipnet" +version = "2.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jiff" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ab1baf72f08796de0260609515130699b890ac25f30e610ad894bc5856cafdb" +dependencies = [ + "defmt", + "jiff-core", + "jiff-static", + "jiff-tzdb-platform", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", + "windows-link", +] + +[[package]] +name = "jiff-core" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e52fe76043ccecc9005d2305ebaadf7d7fc0cc89ca6baa10a94d6bc68c7128c" +dependencies = [ + "defmt", + "log", +] + +[[package]] +name = "jiff-static" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "378268a1116ad67ae6228701118ac9f491d78fda38a40a1f1a9e1348de6f7212" +dependencies = [ + "jiff-core", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jiff-tzdb" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "142bd39932ad231f10513df9ab62661fead8719872150b7ad02a2df79f4e141e" + +[[package]] +name = "jiff-tzdb-platform" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8" +dependencies = [ + "jiff-tzdb", +] + +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.3", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" +dependencies = [ + "cfg-if", + "futures-util", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8f198d1db720e4940b5a493201d199d9f24f568f8f746bd13706243a2f71598" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", +] + +[[package]] +name = "kem" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01737161ba802849cfd486b5bd209d38ba4943494c249a8126005170c7621edd" +dependencies = [ + "crypto-common 0.2.2", + "rand_core 0.10.1", +] + +[[package]] +name = "lazy_static" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb" +dependencies = [ + "spin", +] + +[[package]] +name = "libbz2-rs-sys" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34b357333733e8260735ba5894eb928c02ecc69c78715f01a8019e7fa7f2db4c" + +[[package]] +name = "libc" +version = "0.2.190" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78" + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libredox" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61ff90caf6077a803a240f62fdbe88645a890bbca49ef8174c3cb0404362171d" +dependencies = [ + "libc", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "lzma-rust2" +version = "0.16.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca93e534d1142d1d0dcca6d25fe302508a5dfb40b302802904577725ea0b695b" +dependencies = [ + "sha2 0.11.0", +] + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest 0.10.7", +] + +[[package]] +name = "md5" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ebb8d8732c6a6df3d8f032a82911cfc747e00efb95cc46e8d0acd5b5b88570c" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "ml-kem" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e15f3e5b957493873e396a66914e83e616b6afe335cdef7efe5c6e1216aba66" +dependencies = [ + "hybrid-array", + "kem", + "module-lattice", + "pkcs8 0.11.0", + "rand_core 0.10.1", + "sha3 0.11.0", +] + +[[package]] +name = "module-lattice" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c61b87c9683ab7cb1c6871d261ad5479b6b10ceb52c4352aaca3b5d35a8febe" +dependencies = [ + "ctutils", + "hybrid-array", + "num-traits", +] + +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + +[[package]] +name = "multer" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b" +dependencies = [ + "bytes", + "encoding_rs", + "futures-util", + "http", + "httparse", + "memchr", + "mime", + "spin", + "version_check", +] + +[[package]] +name = "multiversion_no_op" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d" + +[[package]] +name = "native-tls" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + +[[package]] +name = "nix" +version = "0.31.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" +dependencies = [ + "bitflags 2.13.2", + "cfg-if", + "cfg_aliases", + "libc", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf33f538733d1a5a3494b836ba913207f14d9d4a1d3cd67030c5061bdd2cac85" +dependencies = [ + "memchr", + "unicode-segmentation", +] + +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-bigint-dig" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +dependencies = [ + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand 0.8.8", + "smallvec", + "zeroize", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +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", + "futures-util", + "genai", + "hex", + "hmac 0.12.1", + "md-5", + "nucleo-matcher", + "nyaterm-gpu", + "pbkdf2 0.12.2", + "quick-xml", + "rand 0.8.8", + "redb", + "regex", + "reqwest", + "russh", + "russh-sftp", + "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.21", + "time", + "tokio", + "tokio-socks", + "tokio-util", + "tracing", + "urlencoding", + "uuid", + "vnc-rs", + "zeroize", + "zip", +] + +[[package]] +name = "nyaterm-gpu" +version = "0.1.0" +dependencies = [ + "serde", +] + +[[package]] +name = "nyaterm-otp" +version = "0.1.0" + +[[package]] +name = "nyaterm-web" +version = "0.1.0" +dependencies = [ + "async-stream", + "axum", + "base64 0.22.1", + "futures-util", + "nyaterm-core", + "nyaterm-otp", + "rand 0.10.3", + "rand 0.8.8", + "reqwest", + "russh", + "russh-sftp", + "serde", + "serde_json", + "sha2 0.10.9", + "subtle", + "tempfile", + "time", + "tokio", + "tokio-tungstenite", + "tokio-util", + "tower", + "tower-http", + "tracing", + "tracing-subscriber", + "url", + "uuid", + "zeroize", + "zip", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + +[[package]] +name = "openssl" +version = "0.10.81" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45" +dependencies = [ + "bitflags 2.13.2", + "cfg-if", + "foreign-types 0.3.2", + "libc", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "openssl-sys" +version = "0.9.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "ecdsa 0.16.9", + "elliptic-curve 0.13.8", + "primeorder 0.13.6", + "sha2 0.10.9", +] + +[[package]] +name = "p256" +version = "0.14.0-rc.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6bb40a5099e2c38a09dd29321a7a7f045f165a54317679c7cdfb0cbaf8f6b1e" +dependencies = [ + "ecdsa 0.17.0", + "elliptic-curve 0.14.1", + "primefield", + "primeorder 0.14.0", + "sha2 0.11.0", +] + +[[package]] +name = "p384" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6" +dependencies = [ + "ecdsa 0.16.9", + "elliptic-curve 0.13.8", + "primeorder 0.13.6", + "sha2 0.10.9", +] + +[[package]] +name = "p384" +version = "0.14.0-rc.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "492f329d7eb11d22dadc988626b9ea1f503b4ab043a8b1e4e2cc4ae45dabdd70" +dependencies = [ + "ecdsa 0.17.0", + "elliptic-curve 0.14.1", + "fiat-crypto 0.3.0", + "primefield", + "primeorder 0.14.0", + "sha2 0.11.0", +] + +[[package]] +name = "p521" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fc9e2161f1f215afdfce23677034ae137bbd45016a880c2eb3ba8eb95f085b2" +dependencies = [ + "base16ct 0.2.0", + "ecdsa 0.16.9", + "elliptic-curve 0.13.8", + "primeorder 0.13.6", + "rand_core 0.6.4", + "sha2 0.10.9", +] + +[[package]] +name = "p521" +version = "0.14.0-rc.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff42e4ace5424e3b6d7cb82514be89866b85af87015f80341e37dcc21a66ce6e" +dependencies = [ + "base16ct 1.0.0", + "ecdsa 0.17.0", + "elliptic-curve 0.14.1", + "primefield", + "primeorder 0.14.0", + "sha2 0.11.0", +] + +[[package]] +name = "pageant" +version = "0.2.1" +dependencies = [ + "base16ct 1.0.0", + "byteorder", + "bytes", + "delegate", + "futures", + "log", + "rand 0.10.3", + "sha2 0.11.0", + "thiserror 2.0.21", + "tokio", + "windows", + "windows-strings", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "password-hash" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aab41826031698d6ffcd9cff78ef56ef998e39dc7e5067cdfebe373842d4723b" +dependencies = [ + "phc", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pathfinder_geometry" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b7e7b4ea703700ce73ebf128e1450eb69c3a8329199ffbfb9b2a0418e5ad3" +dependencies = [ + "log", + "pathfinder_simd", +] + +[[package]] +name = "pathfinder_simd" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4500030c302e4af1d423f36f3b958d1aecb6c04184356ed5a833bf6b60435777" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112d82ceb8c5bf524d9af484d4e4970c9fd5a0cc15ba14ad93dccd28873b0629" +dependencies = [ + "digest 0.11.3", + "hmac 0.13.0", +] + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + +[[package]] +name = "pem-rfc7468" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6305423e0e7738146434843d1694d621cce767262b2a86910beab705e4493d9" +dependencies = [ + "base64ct", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "phc" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44dc769b75f93afdddd8c7fa12d685292ddeff1e66f7f0f3a234cf1818afe892" +dependencies = [ + "base64ct", + "ctutils", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der 0.7.10", + "pkcs8 0.10.2", + "spki 0.7.3", +] + +[[package]] +name = "pkcs1" +version = "0.8.0-rc.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "986d2e952779af96ea048f160fd9194e1751b4faea78bcf3ceb456efe008088e" +dependencies = [ + "der 0.8.2", + "spki 0.8.0", +] + +[[package]] +name = "pkcs5" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63d440a804ec8d6fafbb6b84471e013286658d373248927692ab3366686220ca" +dependencies = [ + "aes 0.9.3", + "aes-gcm 0.11.1", + "cbc 0.2.1", + "der 0.8.2", + "pbkdf2 0.13.0", + "rand_core 0.10.1", + "scrypt", + "sha2 0.11.0", + "spki 0.8.0", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der 0.7.10", + "spki 0.7.3", +] + +[[package]] +name = "pkcs8" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "451913da69c775a56034ea8d9003d27ee8948e12443eae7c038ba100a4f21cb7" +dependencies = [ + "der 0.8.2", + "pkcs5", + "rand_core 0.10.1", + "spki 0.8.0", +] + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "poly1305" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2d0073b297041425c7c3df6eb4792d598a15323fe63346852b092eca02904c" +dependencies = [ + "cpufeatures 0.3.1", + "universal-hash 0.6.1", + "zeroize", +] + +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "opaque-debug", + "universal-hash 0.5.1", +] + +[[package]] +name = "polyval" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0fa31d631f2b2cb2a544d0aa321ce847a94764d701ca2becc411138b93d49cd" +dependencies = [ + "cpubits", + "cpufeatures 0.3.1", + "universal-hash 0.6.1", + "zeroize", +] + +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + +[[package]] +name = "portable-atomic-util" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10ab3eb7f3becc3a1cbc4f2c6f20267996cfc1a6467a873763411b136a122715" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppmd-rust" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "196a7c80b9a7652aba7cc070827516c2abe4ccdf53d128e1944003cf5726cff1" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "primefield" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c555a6e4eb7d4e158fcb028c835c3b8642206ddc279b5c6b202ef9a8bdb592f4" +dependencies = [ + "crypto-bigint 0.7.5", + "crypto-common 0.2.2", + "ff 0.14.0", + "rand_core 0.10.1", + "subtle", + "zeroize", +] + +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve 0.13.8", +] + +[[package]] +name = "primeorder" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c9f42978c78a00e3d68f69fc03e57a234debae69da4020a4fb588fcdcd07b06" +dependencies = [ + "elliptic-curve 0.14.1", + "once_cell", + "primefield", + "serdect", + "wnaf", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "pxfm" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "redb" +version = "4.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb338a6c67830a61bed824b78c2bb034ab1ff401a20616bd7957524f4fdc2f22" +dependencies = [ + "libc", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.13.2", +] + +[[package]] +name = "redox_users" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60dc65c0ff1a7ae1294b0c67b9f14baf70b644404010370171787bfac1038fc0" +dependencies = [ + "libredox", + "thiserror 2.0.21", +] + +[[package]] +name = "ref-cast" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e440fb4e4b4147295338efb76001ab9e4efc0e5839df2c47fc5ac2381d365c3" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "reqwest" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16a1cfa75cc186dd73d5818e510e042e40927bccc9c236b061cea97e1eb08029" +dependencies = [ + "base64 0.23.1", + "bytes", + "encoding_rs", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-tls", + "hyper-util", + "js-sys", + "log", + "mime", + "native-tls", + "percent-encoding", + "pin-project-lite", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-native-tls", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", +] + +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac 0.12.1", + "subtle", +] + +[[package]] +name = "rfc6979" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4a459cddafb3fe76b31fd8f1108007566c40301feb64dc7b54656eb7388172b" +dependencies = [ + "crypto-bigint 0.7.5", + "hmac 0.13.0", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rsa" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +dependencies = [ + "const-oid 0.9.6", + "digest 0.10.7", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1 0.7.5", + "pkcs8 0.10.2", + "rand_core 0.6.4", + "sha2 0.10.9", + "signature 2.2.0", + "spki 0.7.3", + "subtle", + "zeroize", +] + +[[package]] +name = "rsa" +version = "0.10.0-rc.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30b2aa4ba0d89f73d1e332df05be0eeab8840351c36ca5654341dfdb57bb3caf" +dependencies = [ + "const-oid 0.10.2", + "crypto-bigint 0.7.5", + "crypto-primes", + "digest 0.11.3", + "pkcs1 0.8.0-rc.4", + "pkcs8 0.11.0", + "rand_core 0.10.1", + "sha2 0.11.0", + "signature 3.0.0", + "spki 0.8.0", + "zeroize", +] + +[[package]] +name = "russh" +version = "0.62.1" +dependencies = [ + "aes 0.9.3", + "bitflags 2.13.2", + "block-padding 0.4.2", + "byteorder", + "bytes", + "cbc 0.2.1", + "cipher 0.5.2", + "crypto-bigint 0.7.5", + "ctr 0.10.1", + "curve25519-dalek 5.0.0-rc.1", + "data-encoding", + "delegate", + "der 0.8.2", + "des", + "digest 0.11.3", + "ecdsa 0.17.0", + "ed25519-dalek 3.0.0-rc.1", + "elliptic-curve 0.14.1", + "enum_dispatch", + "flate2", + "futures", + "generic-array 1.4.5", + "getrandom 0.4.3", + "ghash 0.6.0", + "hex-literal", + "hmac 0.13.0", + "inout 0.2.2", + "internal-russh-num-bigint", + "keccak 0.2.2", + "log", + "md5", + "ml-kem", + "module-lattice", + "num-bigint", + "p256 0.14.0-rc.15", + "p384 0.14.0-rc.15", + "p521 0.14.0-rc.15", + "pageant", + "pbkdf2 0.13.0", + "pkcs1 0.8.0-rc.4", + "pkcs5", + "pkcs8 0.11.0", + "polyval 0.7.3", + "rand 0.10.3", + "rand_core 0.10.1", + "ring", + "rsa 0.10.0-rc.18", + "russh-cryptovec", + "russh-util", + "salsa20", + "scrypt", + "sec1 0.8.1", + "sha1 0.11.0", + "sha2 0.11.0", + "sha3 0.11.0", + "signature 3.0.0", + "spki 0.8.0", + "ssh-encoding 0.3.0", + "ssh-key 0.7.0-rc.11", + "subtle", + "thiserror 2.0.21", + "tokio", + "typenum", + "universal-hash 0.6.1", + "zeroize", +] + +[[package]] +name = "russh-cryptovec" +version = "0.62.0" +dependencies = [ + "log", + "nix", + "ssh-encoding 0.3.0", + "windows-sys 0.61.2", +] + +[[package]] +name = "russh-sftp" +version = "2.3.0" +dependencies = [ + "bitflags 2.13.2", + "bytes", + "chrono", + "dashmap", + "encoding_rs", + "gloo-timers", + "log", + "serde", + "serde_bytes", + "thiserror 2.0.21", + "tokio", + "tokio-util", + "wasm-bindgen-futures", +] + +[[package]] +name = "russh-util" +version = "0.52.0" +dependencies = [ + "chrono", + "tokio", + "wasm-bindgen", + "wasm-bindgen-futures", +] + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" +dependencies = [ + "bitflags 2.13.2", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" +dependencies = [ + "once_cell", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "salsa20" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f874456e72520ff1375a06c588eaf074b0f01f9e9e1aada45bd9b7954a6e42c" +dependencies = [ + "cfg-if", + "cipher 0.5.2", +] + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "scrypt" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d87af57419b594aa23fa95f09f0e06d80d84ba01c26148c43844cad6ff4485f0" +dependencies = [ + "cfg-if", + "pbkdf2 0.13.0", + "salsa20", + "sha2 0.11.0", +] + +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct 0.2.0", + "der 0.7.10", + "generic-array 0.14.7", + "pkcs8 0.10.2", + "subtle", + "zeroize", +] + +[[package]] +name = "sec1" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56d437c2f19203ce5f7122e507831de96f3d2d4d3be5af44a0b0a09d8a80e4d" +dependencies = [ + "base16ct 1.0.0", + "ctutils", + "der 0.8.2", + "hybrid-array", + "subtle", + "zeroize", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.13.2", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_bytes" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serde_with" +version = "3.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9adc193c780ef8f159aee8b61e2d5801aaa555e6eb0947fe45530ec506296f" +dependencies = [ + "base64 0.23.1", + "bs58", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.14.2", + "jiff", + "schemars 0.9.0", + "schemars 1.2.2", + "serde_core", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e17bbc68e28663bbbb90df47e058aa7eda4fb445b89fe70457bb94fbccf6e49" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "serdect" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66cf8fedced2fcf12406bcb34223dffb92eaf34908ede12fed414c82b7f00b3e" +dependencies = [ + "base16ct 1.0.0", + "serde", +] + +[[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha1" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "digest 0.11.3", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be176f1a57ce4e3d31c1a166222d9768de5954f811601fb7ca06fc8203905ce1" +dependencies = [ + "digest 0.11.3", + "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]] +name = "shlex" +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", + "libc", +] + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest 0.10.7", + "rand_core 0.6.4", +] + +[[package]] +name = "signature" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d567dcbaf0049cb8ac2608a76cd95ff9e4412e1899d389ee400918ca7537f5" +dependencies = [ + "digest 0.11.3", + "rand_core 0.10.1", +] + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e" + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "spin" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der 0.7.10", +] + +[[package]] +name = "spki" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d9efca8738c78ee9484207732f728b1ef517bbb1833d6fc0879ca898a522f6f" +dependencies = [ + "base64ct", + "der 0.8.2", +] + +[[package]] +name = "ssh-cipher" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caac132742f0d33c3af65bfcde7f6aa8f62f0e991d80db99149eb9d44708784f" +dependencies = [ + "cipher 0.4.4", + "ssh-encoding 0.2.0", +] + +[[package]] +name = "ssh-cipher" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d801accda99469cde6d73da741422610fdf6508a72d9a69d1b55cb241c720597" +dependencies = [ + "aead 0.6.1", + "aes 0.9.3", + "aes-gcm 0.11.1", + "chacha20", + "cipher 0.5.2", + "ctutils", + "des", + "poly1305", + "ssh-encoding 0.3.0", + "zeroize", +] + +[[package]] +name = "ssh-encoding" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb9242b9ef4108a78e8cd1a2c98e193ef372437f8c22be363075233321dd4a15" +dependencies = [ + "base64ct", + "pem-rfc7468 0.7.0", + "sha2 0.10.9", +] + +[[package]] +name = "ssh-encoding" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b54d0ed0498daf3f78d82e00e28c8eec9d75a067c4cfbcc7a0f7d0f4077749e" +dependencies = [ + "base64ct", + "bytes", + "crypto-bigint 0.7.5", + "ctutils", + "digest 0.11.3", + "pem-rfc7468 1.0.0", + "zeroize", +] + +[[package]] +name = "ssh-key" +version = "0.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b86f5297f0f04d08cabaa0f6bff7cb6aec4d9c3b49d87990d63da9d9156a8c3" +dependencies = [ + "ed25519-dalek 2.2.0", + "p256 0.13.2", + "p384 0.13.1", + "p521 0.13.3", + "rand_core 0.6.4", + "rsa 0.9.10", + "sec1 0.7.3", + "sha2 0.10.9", + "signature 2.2.0", + "ssh-cipher 0.2.0", + "ssh-encoding 0.2.0", + "subtle", + "zeroize", +] + +[[package]] +name = "ssh-key" +version = "0.7.0-rc.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9a32fae177b74a22aa9c5b01bf7e68b33545be32d9e381e248058d2adc15ce3" +dependencies = [ + "argon2", + "bcrypt-pbkdf", + "ctutils", + "dsa", + "ed25519-dalek 3.0.0-rc.1", + "hex", + "hmac 0.13.0", + "p256 0.14.0-rc.15", + "p384 0.14.0-rc.15", + "p521 0.14.0-rc.15", + "rand_core 0.10.1", + "rsa 0.10.0-rc.18", + "sec1 0.8.1", + "sha1 0.11.0", + "sha2 0.11.0", + "signature 3.0.0", + "ssh-cipher 0.3.0", + "ssh-encoding 0.3.0", + "zeroize", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "strip-ansi-escapes" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a8f8038e7e7969abb3f1b7c2a811225e9296da208539e0f79c5251d6cac0025" +dependencies = [ + "vte", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags 2.13.2", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl 2.0.21", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "js-sys", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" + +[[package]] +name = "tokio" +version = "1.53.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e95f91fcc7a621e8b030f6aa23c71fe9838ae2fb4d8118b75602a328f5144044" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9cc2678c2cdd569ef8215e2afd7954ada2ae20b4fdd2c5fe6139a3b02d105db" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-socks" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7e2948f60dbe26b35f2c7fb74ac2854c1fddded0fe9d7548fcc674a246f7615" +dependencies = [ + "either", + "futures-util", + "thiserror 1.0.69", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f72a05e828585856dacd553fba484c242c46e391fb0e58917c942ee9202915c" +dependencies = [ + "futures-util", + "log", + "tokio", + "tungstenite", +] + +[[package]] +name = "tokio-util" +version = "0.7.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" +dependencies = [ + "bytes", + "futures-core", + "futures-io", + "futures-sink", + "futures-util", + "libc", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "async-compression", + "bitflags 2.13.2", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "http-range-header", + "httpdate", + "mime", + "mime_guess", + "percent-encoding", + "pin-project-lite", + "tokio", + "tokio-util", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +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]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "tungstenite" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c01152af293afb9c7c2a57e4b559c5620b421f6d133261c60dd2d0cdb38e6b8" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand 0.9.5", + "sha1 0.10.7", + "thiserror 2.0.21", +] + +[[package]] +name = "typed-path" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + +[[package]] +name = "unicode-ident" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common 0.1.7", + "subtle", +] + +[[package]] +name = "universal-hash" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4987bdc12753382e0bec4a65c50738ffaabc998b9cdd1f952fb5f39b0048a96" +dependencies = [ + "crypto-common 0.2.2", + "ctutils", +] + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97277d36b9c3ace13e58fa6e753f8b0bbbf302a18dd193240d70f9e29681059a" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca945a9c7463ad3085da59b5dc4f8faf4dff6f3e7d835fa7ef08a3b36926512d" +dependencies = [ + "derive_more", + "serde", + "serde_json", +] + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "vnc-rs" +version = "0.5.3" +dependencies = [ + "aes 0.8.4", + "async_io_stream", + "eax", + "flate2", + "futures", + "image", + "rand 0.8.8", + "rsa 0.9.10", + "sha2 0.10.9", + "thiserror 1.0.69", + "tokio", + "tokio-stream", + "tokio-util", + "tracing", + "wasm-bindgen-futures", +] + +[[package]] +name = "vte" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "231fdcd7ef3037e8330d8e17e61011a2c244126acc0a982f4040ac3f9f0bc077" +dependencies = [ + "memchr", +] + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.79" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3cbab34de2d982e9b48e18d216d04c4a6f641066ff19ffb699980f591ee3610e" +dependencies = [ + "js-sys", + "tokio", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.6", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-streams" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "web-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88261b9deccee56594c11a3460c462c41f58d148598fe70ad77070126a68aba4" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections", + "windows-core", + "windows-future", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core", + "windows-link", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core", + "windows-link", +] + +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +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", +] + +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[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_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[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_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "wio" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d129932f4644ac2396cb456385cbf9e63b5b30c6e8dc4820bdca4eb082037a5" +dependencies = [ + "winapi", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wnaf" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "795ca18b3fdb5e62bf982199278341ddcf7ebf7d32e25e212ad05d496e95f6fa" +dependencies = [ + "ff 0.14.0", + "group 0.14.0", + "hybrid-array", + "primefield", +] + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "yeslogic-fontconfig-sys" +version = "6.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d8b8abf912b9a29ff112e1671c97c33636903d13a69712037190e6805af4f76" +dependencies = [ + "dlib", + "once_cell", + "pkg-config", +] + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.59" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6df92bf3d9227be3d53173901ddbffac2babc27ae50f397776ffd6dc33f800cb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.59" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac4f328cf2f05d084e496c3e9c3f33ed0a183656a16e1fcec4d464d8373aec82" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "zip" +version = "8.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d04a6b5381502aa6087c94c669499eb1602eb9c5e8198e534de571f7154809b" +dependencies = [ + "aes 0.9.3", + "bzip2", + "constant_time_eq", + "crc32fast", + "deflate64", + "flate2", + "getrandom 0.4.3", + "hmac 0.13.0", + "indexmap 2.14.2", + "lzma-rust2", + "memchr", + "pbkdf2 0.13.0", + "ppmd-rust", + "sha1 0.11.0", + "time", + "typed-path", + "zeroize", + "zopfli", + "zstd", +] + +[[package]] +name = "zlib-rs" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zopfli" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] + +[[package]] +name = "zstd" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "7.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64d80649ab6db9d9f6f9c80a40becd948eda4714a0a5ac8c4d157a32231c7882" +dependencies = [ + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.1.0+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ef0a8027ec3ee71300ab3bcbcd0393f434aa72b91ca6d635a39941deae8eea0" +dependencies = [ + "cc", + "pkg-config", +] + +[[package]] +name = "zune-core" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56377fd46368984a170bc5aac5567e52ca5da874caa60bea39fcbca78fb658b" + +[[package]] +name = "zune-jpeg" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core", +] diff --git a/src-tauri/crates/nyaterm-web/Cargo.toml b/src-tauri/crates/nyaterm-web/Cargo.toml new file mode 100644 index 00000000..15cc30f4 --- /dev/null +++ b/src-tauri/crates/nyaterm-web/Cargo.toml @@ -0,0 +1,40 @@ +[package] +name = "nyaterm-web" +version = "0.1.0" +edition = "2024" +rust-version = "1.94" + +[dependencies] +nyaterm-core = { path = "../nyaterm-core" } +nyaterm-otp = { path = "../otp" } +tokio = { version = "1", features = ["full"] } +axum = { version = "0.8", features = ["ws", "multipart"] } +tower-http = { version = "0.6", features = ["fs", "set-header"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +futures-util = "0.3" +tokio-util = { version = "0.7", features = ["rt", "io"] } +uuid = { version = "1", features = ["v4"] } +base64 = "0.22" +sha2 = "0.10" +rand = "0.8" +zeroize = "1" +url = "2" +async-stream = "0.3" +subtle = "2" +russh = { path = "../../vendor/russh/russh", default-features = false, features = ["des", "dsa", "flate2", "ring", "rsa"] } +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" + + +tokio-tungstenite = "0.29" +rand-new = { package = "rand", version = "0.10" } diff --git a/src-tauri/crates/nyaterm-web/src/ai.rs b/src-tauri/crates/nyaterm-web/src/ai.rs new file mode 100644 index 00000000..44abf4e7 --- /dev/null +++ b/src-tauri/crates/nyaterm-web/src/ai.rs @@ -0,0 +1,300 @@ +use crate::{ + error::{Result, WebError}, + state::{Event, State}, +}; +use nyaterm_core::{ + config::{self, AiAgentKind, AiMode}, + core::ai::{catalog, history, model, parser, redaction, stream, types::*}, +}; +use serde_json::{Value, json}; +use std::{sync::Arc, time::Duration}; + +pub async fn command( + state: &Arc, + owner: &str, + command: &str, + args: &Value, +) -> Result { + match command { + "append_ai_audit" => { + let mut request: AppendAiAuditRequest = + serde_json::from_value(args["request"].clone())?; + request.user_input = request + .user_input + .map(|v| redaction::redact_sensitive_text(&v)); + request.generated_command = request + .generated_command + .map(|v| redaction::redact_sensitive_text(&v)); + request.error = request.error.map(|v| redaction::redact_sensitive_text(&v)); + Ok(json!(history::append_ai_audit(&(), request)?)) + } + "reveal_ai_provider_api_key" => { + let settings = config::load_app_settings(&())?.ai; + let id = crate::commands::text(args, "credentialId")?; + let key = settings + .provider_credentials + .iter() + .find(|c| c.id == id) + .and_then(|c| c.api_key.as_ref()) + .or_else(|| { + settings + .provider_profiles + .iter() + .find(|c| c.id == id) + .and_then(|c| c.api_key.as_ref()) + }) + .ok_or(WebError::bad("Provider key not configured"))?; + Ok(json!(key)) + } + "clear_ai_history" => { + history::clear_ai_history_storage(&())?; + Ok(Value::Null) + } + "rebind_ai_session" => { + let scope: AiSessionScope = serde_json::from_value(args["ownerScope"].clone())?; + validate_scope(state, owner, &scope).await?; + Ok(json!(history::rebind_ai_session( + &(), + args["sessionId"] + .as_str() + .ok_or(WebError::bad("AI session required"))? + .into(), + scope + )?)) + } + "get_ai_sessions" => Ok(json!(history::get_ai_sessions(&())?)), + "get_ai_messages" => Ok(json!(history::get_ai_messages( + &(), + args["sessionId"] + .as_str() + .ok_or(WebError::bad("AI session required"))? + .into() + )?)), + "delete_ai_session" => { + history::delete_ai_session( + &(), + args["sessionId"] + .as_str() + .ok_or(WebError::bad("AI session required"))? + .into(), + )?; + Ok(Value::Null) + } + "list_ai_model_names" => Ok(json!(model::list_model_names(&()).await?)), + "test_ai_model_connection" => { + let settings = merged_settings(args)?; + model::test_model_connection( + &settings, + args["modelId"] + .as_str() + .ok_or(WebError::bad("AI model required"))?, + ) + .await?; + Ok(Value::Null) + } + "test_ai_provider_connection" => { + let settings = merged_settings(args)?; + Ok(json!( + model::test_provider_connection( + &settings, + args["credentialId"] + .as_str() + .ok_or(WebError::bad("Provider required"))? + ) + .await? + )) + } + "refresh_ai_model_settings" => { + let _guard = state.mutation.lock().await; + let mut existing = config::load_app_settings(&())?; + let mut settings = merged_settings(args)?; + let discoveries = model::list_model_names_for_settings(&settings).await?; + settings.models = catalog::merge_model_discoveries(&settings, discoveries); + settings.default_model_id = + catalog::update_default_model_id(&settings, &settings.models); + config::normalize_ai_settings(&mut settings); + existing.ai = config::encrypt_ai_settings(settings.clone())?; + existing.cloud_sync = config::encrypt_cloud_sync_settings(existing.cloud_sync)?; + config::save_app_settings(&(), &existing)?; + state.broadcast("settings-changed", Value::Null).await; + Ok(json!(config::mask_ai_settings(settings))) + } + "cancel_ai_chat_stream" => { + let id = args["streamId"] + .as_str() + .ok_or(WebError::bad("AI stream required"))?; + if let Some(token) = state + .ai_streams + .lock() + .await + .remove(&(owner.into(), id.into())) + { + token.cancel(); + } + Ok(Value::Null) + } + "start_ai_chat_stream" => start(state, owner, args).await, + _ => Err(WebError::unsupported()), + } +} +pub fn supports(command: &str) -> bool { + matches!( + command, + "clear_ai_history" + | "append_ai_audit" + | "reveal_ai_provider_api_key" + | "rebind_ai_session" + | "test_ai_provider_connection" + | "refresh_ai_model_settings" + | "get_ai_sessions" + | "get_ai_messages" + | "delete_ai_session" + | "list_ai_model_names" + | "test_ai_model_connection" + | "cancel_ai_chat_stream" + | "start_ai_chat_stream" + ) +} +fn merged_settings(args: &Value) -> Result { + let existing = config::load_app_settings(&())?.ai; + if args["aiSettings"].is_null() { + return Ok(existing); + } + Ok(config::merge_masked_ai_settings( + &existing, + serde_json::from_value(args["aiSettings"].clone())?, + )) +} +async fn validate_scope(state: &Arc, owner: &str, scope: &AiSessionScope) -> Result<()> { + if scope.r#type == AiSessionScopeType::Terminal { + state + .session( + owner, + scope + .target_id + .as_deref() + .ok_or(WebError::bad("Terminal scope requires session"))?, + ) + .await?; + } + Ok(()) +} +async fn start(state: &Arc, owner: &str, args: &Value) -> Result { + let mut request: AiChatRequest = serde_json::from_value(args["request"].clone())?; + if request.mode != AiMode::Ask + || request.agent_kind != AiAgentKind::Nyaterm + || !request.attachments.is_empty() + { + return Err(WebError::unsupported()); + } + for id in request + .terminal_session_id + .iter() + .chain(request.default_target_session_id.iter()) + .chain(request.targets.iter().map(|t| &t.terminal_session_id)) + .chain( + request + .target_contexts + .iter() + .filter_map(|t| t.target.as_ref().map(|t| &t.terminal_session_id)), + ) + { + state.session(owner, id).await?; + } + validate_scope(state, owner, &request.owner_scope).await?; + let mut settings = config::load_app_settings(&())?.ai; + if !settings.enabled { + return Err(WebError::bad("AI assistant is disabled")); + } + settings.redaction_enabled = true; + model::resolve_request_model(&settings, &request)?; + let stream_id = request + .stream_id + .clone() + .filter(|s| s.len() <= 128 && !s.is_empty()) + .unwrap_or_else(|| format!("ai-stream-{}", uuid())); + let session_id = request + .session_id + .clone() + .filter(|s| s.len() <= 128 && !s.is_empty()) + .unwrap_or_else(|| format!("ai-session-{}", uuid())); + request.session_id = Some(session_id.clone()); + history::validate_session_scope(&(), &session_id, &request)?; + redaction::redact_context(&mut request.context); + for target in &mut request.target_contexts { + redaction::redact_context(&mut target.context); + } + request.user_input = redaction::redact_sensitive_text(&request.user_input); + let login = state.login(owner).await?; + let cancel = login.cancel.child_token(); + let key = (owner.to_owned(), stream_id.clone()); + let mut streams = state.ai_streams.lock().await; + if streams.contains_key(&key) || streams.keys().filter(|(id, _)| id == owner).count() >= 4 { + return Err(WebError::bad("Too many AI streams")); + } + streams.insert(key.clone(), cancel.clone()); + drop(streams); + let state = state.clone(); + let events = login.events.clone(); + let task_stream = stream_id.clone(); + let task_session = session_id.clone(); + crate::observability::spawn(async move { + let sink = stream::EventSink(move |id: &str, payload: AiStreamEventPayload| { + let _ = events.send(Event { + event: format!("ai-stream-{id}"), + payload: json!(payload), + }); + }); + let make_event = |kind: &str| AiStreamEventPayload { + event_type: kind.into(), + stream_id: task_stream.clone(), + session_id: Some(task_session.clone()), + text_delta: None, + reasoning_delta: None, + message: None, + command_cards: vec![], + usage: None, + error: None, + }; + stream::emit_stream_event(&sink, &task_stream, make_event("start")); + if settings.record_history { + let _ = history::save_user_message(&(), &task_session, &request); + } + let (_cancel_tx, mut cancel_rx) = tokio::sync::oneshot::channel(); + let result = tokio::select! { + _ = cancel.cancelled()=>Err(WebError::bad("AI stream cancelled")), + result=tokio::time::timeout(Duration::from_secs(600),stream::run_model_stream(&sink,&task_stream,&request,&settings,&mut cancel_rx))=>match result{Ok(result)=>result.map_err(Into::into),Err(_)=>Err(WebError::bad("AI stream timed out"))}, + }; + let event = match result { + Ok(output) => { + let (text, reasoning, mut cards) = + parser::parse_model_output(&output.text, output.reasoning_content); + parser::bind_command_card_targets(&mut cards, &request); + let message = AiMessage { + id: format!("msg-{}", uuid()), + session_id: task_session.clone(), + role: AiMessageRole::Assistant, + content: text, + created_at: now_rfc3339(), + reasoning_content: reasoning, + command_cards: cards.clone(), + }; + if settings.record_history { + let _ = history::append_message(&(), message.clone()); + } + let mut event = make_event("done"); + event.message = Some(message); + event.command_cards = cards; + event + } + Err(_) => { + let mut event = make_event("error"); + event.error = Some("AI request failed or was cancelled".into()); + event + } + }; + stream::emit_stream_event(&sink, &task_stream, event); + state.ai_streams.lock().await.remove(&key); + }); + Ok(json!({"streamId":stream_id,"sessionId":session_id})) +} diff --git a/src-tauri/crates/nyaterm-web/src/auth.rs b/src-tauri/crates/nyaterm-web/src/auth.rs new file mode 100644 index 00000000..bee70e7a --- /dev/null +++ b/src-tauri/crates/nyaterm-web/src/auth.rs @@ -0,0 +1,240 @@ +use crate::{ + error::{Result, WebError}, + state::{Login, State}, +}; +use axum::{ + Json, + body::Body, + extract::{Request, State as ExtractState}, + http::{HeaderMap, StatusCode, header}, + middleware::Next, + response::{IntoResponse, Response}, +}; +use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; +use rand::RngCore; +use serde::Deserialize; +use serde_json::json; +use sha2::{Digest, Sha256}; +use std::{ + sync::Arc, + time::{Duration, Instant}, +}; + +pub const COOKIE: &str = "nyaterm_session"; +#[derive(Clone)] +pub struct Owner(pub String); +pub fn random_secret() -> String { + let mut bytes = [0u8; 32]; + rand::rngs::OsRng.fill_bytes(&mut bytes); + URL_SAFE_NO_PAD.encode(bytes) +} +pub fn digest(value: &str) -> [u8; 32] { + Sha256::digest(value.as_bytes()).into() +} +fn equal(a: &[u8], b: &[u8]) -> bool { + use subtle::ConstantTimeEq; + bool::from(a.ct_eq(b)) +} +/// UI secret reveal uses real verification without replacing the login or +/// changing the server's encryption key hierarchy. +pub async fn verify_unlock(state: &State, args: &serde_json::Value) -> Result { + let password = zeroize::Zeroizing::new(crate::commands::text(args, "password")?.to_owned()); + 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(), + )); + } + attempts.push(Instant::now()); + drop(attempts); + let settings = nyaterm_core::config::load_app_settings(&())?; + let expected = match settings.security.master_password { + Some(ciphertext) => { + let stored = zeroize::Zeroizing::new( + nyaterm_core::utils::crypto::decrypt_settings_secret(&ciphertext)?, + ); + digest(&stored) + } + None => state.password_hash, + }; + Ok(equal(&digest(&password), &expected)) +} +pub fn cookie_owner(headers: &HeaderMap) -> Option { + headers + .get(header::COOKIE)? + .to_str() + .ok()? + .split(';') + .find_map(|part| { + let (name, value) = part.trim().split_once('=')?; + (name == COOKIE && value.len() == 43).then(|| value.to_owned()) + }) +} +pub fn validate_boundary(state: &State, headers: &HeaderMap, require_origin: bool) -> Result<()> { + let host = headers + .get(header::HOST) + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + if host != state.host { + return Err(WebError::forbidden()); + } + match headers.get(header::ORIGIN).and_then(|v| v.to_str().ok()) { + Some(origin) if origin != state.origin => Err(WebError::forbidden()), + None if require_origin => Err(WebError::forbidden()), + _ => Ok(()), + } +} +pub async fn guard( + ExtractState(state): ExtractState>, + mut request: Request, + next: Next, +) -> Response { + let unsafe_method = !matches!( + *request.method(), + axum::http::Method::GET | axum::http::Method::HEAD + ); + let websocket = request.headers().contains_key(header::UPGRADE); + let result = async { + validate_boundary(&state, request.headers(), unsafe_method || websocket)?; + let token = cookie_owner(request.headers()).ok_or(WebError( + StatusCode::UNAUTHORIZED, + "Sign in required".into(), + ))?; + let login = state.login(&token).await?; + if unsafe_method { + let csrf = request + .headers() + .get("x-nyaterm-csrf") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + if !equal(csrf.as_bytes(), login.csrf.as_bytes()) { + return Err(WebError::forbidden()); + } + } + request.extensions_mut().insert(Owner(token)); + Ok(()) + } + .await; + match result { + Ok(()) => next.run(request).await, + Err(error) => error.into_response(), + } +} +pub async fn site_guard( + ExtractState(state): ExtractState>, + request: Request, + next: Next, +) -> Response { + match validate_boundary(&state, request.headers(), false) { + Ok(()) => next.run(request).await, + Err(error) => error.into_response(), + } +} +#[derive(Deserialize)] +pub struct LoginRequest { + password: String, +} +pub async fn sign_in( + ExtractState(state): ExtractState>, + headers: HeaderMap, + Json(mut payload): Json, +) -> Result { + use zeroize::Zeroize; + validate_boundary(&state, &headers, true)?; + if headers + .get("x-nyaterm-request") + .and_then(|v| v.to_str().ok()) + != Some("1") + { + return Err(WebError::forbidden()); + } + 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(), + )); + } + attempts.push(Instant::now()); + drop(attempts); + 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(), + )); + } + // Re-authentication revokes the old owner's sessions, avoiding abandoned leases. + if let Some(old) = cookie_owner(&headers) { + state.close_owner(&old).await; + } + let owner = random_secret(); + let csrf = random_secret(); + let (events, _) = tokio::sync::broadcast::channel(256); + let mut logins = state.logins.lock().await; + if logins.len() >= 128 { + return Err(WebError( + StatusCode::TOO_MANY_REQUESTS, + "Too many active logins".into(), + )); + } + logins.insert( + owner.clone(), + Arc::new(Login { + csrf: csrf.clone(), + expires: Instant::now() + Duration::from_secs(8 * 3600), + events, + 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})), + ) + .into_response()) +} +fn cookie(state: &State, token: &str, max_age: u32) -> String { + format!( + "{COOKIE}={token}; Path={}/; HttpOnly; SameSite=Strict; Max-Age={max_age}{}", + state.base_path, + if state.secure_cookie { "; Secure" } else { "" } + ) +} +pub async fn current( + ExtractState(state): ExtractState>, + axum::Extension(owner): axum::Extension, +) -> Result> { + Ok(Json(json!({"csrf":state.login(&owner.0).await?.csrf}))) +} +pub async fn sign_out( + ExtractState(state): ExtractState>, + 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!({})), + ) + .into_response() +} 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 new file mode 100644 index 00000000..b1b2855a --- /dev/null +++ b/src-tauri/crates/nyaterm-web/src/commands.rs @@ -0,0 +1,284 @@ +use crate::{ + auth::Owner, + error::{Result, WebError}, + state::State, +}; +use axum::{ + Extension, Json, + extract::{Path, State as ExtractState}, +}; +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 { + id: String, + sort_order: i32, +} + +pub(crate) fn argument(args: &Value, name: &str) -> Result { + Ok(serde_json::from_value(args[name].clone())?) +} +pub(crate) fn text<'a>(args: &'a Value, name: &str) -> Result<&'a str> { + args[name] + .as_str() + .ok_or(WebError::bad("Missing command argument")) +} +pub async fn route( + ExtractState(state): ExtractState>, + Extension(owner): Extension, + 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" => { + let mut settings = config::load_app_settings(&())?; + // Web unlocks with the stored master password, or re-authenticates + // with the Web login password when no master password is configured. + settings.security.master_password=Some("__SET__".into()); + settings.ai=config::mask_ai_settings(settings.ai); + settings.cloud_sync=config::mask_cloud_sync_settings(settings.cloud_sync); + json!(settings) + }, + "save_app_settings" => { + let _guard=state.mutation.lock().await; + let mut next:config::AppSettings=argument(&args,"settings")?; + let current=config::load_app_settings(&())?; + // Master-password rotation remains a Desktop-only flow. Web cannot + // disable or replace an existing master password through a settings save. + if next.security.master_password.as_deref().is_some_and(|v| v!="__SET__") || next.security.master_password.is_none() && current.security.master_password.is_some() { return Err(WebError::unsupported()); } + next.security.master_password=current.security.master_password; + next.appearance.normalize_window_transparency(); + next.terminal.normalize_scrollback_lines();next.terminal.normalize_timestamp_format();next.recording.normalize(); + 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 + }, + "save_app_ui_settings" => { + let _guard=state.mutation.lock().await; + let ui:config::UiConfig=argument(&args,"ui")?; + storage::update_settings_doc::(storage::SettingsDocKey::AppSettings,|settings| { settings.ui=ui;Ok(()) })?; + state.broadcast("settings-changed",Value::Null).await;Value::Null + }, + "save_app_language" => { + let _guard=state.mutation.lock().await; + let language:String=argument(&args,"language")?; + storage::update_settings_doc::(storage::SettingsDocKey::AppSettings,|settings| { settings.ui.language=Some(language);Ok(()) })?; + state.broadcast("settings-changed",Value::Null).await;Value::Null + }, + "verify_master_password" => json!(crate::auth::verify_unlock(&state,&args).await?), + "get_saved_connections" => { + let mut connections=config::load_config(&())?.connections; + let values=connections.iter_mut().map(|conn|{let exists=conn.auth.as_ref().is_some_and(|a|a.password.is_some());if let Some(auth)=&mut conn.auth {auth.password=None;}let mut value=json!(conn);if !value["auth"].is_null(){value["auth"]["has_password"]=json!(exists);}value}).collect::>(); + json!(values) + }, + "save_connection" => { + let _guard=state.mutation.lock().await; + let connection:config::SavedConnection=argument(&args,"connection")?; + if !matches!(connection.config,config::ConnectionType::Ssh { .. } | config::ConnectionType::Telnet { .. } | config::ConnectionType::Vnc { .. }) { return Err(WebError::unsupported()); } + let id=services::save_connection(&(),connection)?; + state.broadcast("connections-changed",Value::Null).await;json!(id) + }, + "delete_connection" => { + let _guard=state.mutation.lock().await; + let mut config=config::load_config(&())?;config.connections.retain(|c|Some(c.id.as_str())!=args["id"].as_str());config::save_config(&(),&config)?; + state.broadcast("connections-changed",Value::Null).await;Value::Null + }, + "update_connection_icon" | "update_connection_asset_from_monitoring" => { + let _guard=state.mutation.lock().await;let mut cfg=config::load_config(&())?; + let id=text(&args,"connectionId")?; + let connection=cfg.connections.iter_mut().find(|c|c.id==id).ok_or(WebError::bad("Connection not found"))?; + if command=="update_connection_icon" {connection.icon=args["icon"].as_str().map(str::trim).filter(|v|!v.is_empty()).map(String::from);connection.icon_auto_detect=Some(argument(&args,"iconAutoDetect")?);} + else { + let patch:config::AssetMetadata=argument(&args,"assetPatch")?; + let asset=connection.asset.get_or_insert_with(Default::default); + if patch.hostname.is_some(){asset.hostname=patch.hostname;} + if patch.os_name.is_some(){asset.os_name=patch.os_name;} + if patch.architecture.is_some(){asset.architecture=patch.architecture;} + if patch.cpu_model.is_some(){asset.cpu_model=patch.cpu_model;} + if patch.cpu_cores.is_some(){asset.cpu_cores=patch.cpu_cores;} + if patch.memory_bytes.is_some(){asset.memory_bytes=patch.memory_bytes;} + if patch.disks.is_some(){asset.disks=patch.disks;} + if patch.updated_at.is_some(){asset.updated_at=patch.updated_at;} + if let Some(accelerators)=patch.accelerators {if !accelerators.is_empty(){let types=accelerators.iter().map(|a|a.r#type.clone()).collect::>();let current=asset.accelerators.get_or_insert_with(Vec::new);current.retain(|a|!types.contains(&a.r#type));current.extend(accelerators);}} + } + config::save_config(&(),&cfg)?;state.broadcast("connections-changed",Value::Null).await;Value::Null + }, + "get_groups" => json!(config::load_config(&())?.groups), + "delete_group" | "clear_all_connections" | "reorder_items" => { + let _guard=state.mutation.lock().await; + let mut cfg=config::load_config(&())?; + match command.as_str() { + "delete_group" => { + let mut ids=std::collections::HashSet::from([text(&args,"id")?.to_owned()]); + loop { let before=ids.len(); for group in &cfg.groups { if group.parent_id.as_ref().is_some_and(|id|ids.contains(id)) { ids.insert(group.id.clone()); } } if before==ids.len() { break; } } + cfg.groups.retain(|g|!ids.contains(&g.id)); + cfg.connections.retain(|c|c.group_id.as_ref().is_none_or(|id|!ids.contains(id))); + }, + "clear_all_connections" => { cfg.connections.clear();cfg.groups.clear(); }, + _ => { + let connections:Vec=argument(&args,"connections")?; + let groups:Vec=argument(&args,"groups")?; + for update in connections { if let Some(c)=cfg.connections.iter_mut().find(|c|c.id==update.id) { c.sort_order=update.sort_order; } } + for update in groups { if let Some(g)=cfg.groups.iter_mut().find(|g|g.id==update.id) { g.sort_order=update.sort_order; } } + } + } + config::save_config(&(),&cfg)?;state.broadcast("connections-changed",Value::Null).await;Value::Null + }, + "get_proxies" => json!(config::load_proxies(&())?.into_iter().map(|mut proxy| { proxy.password=None; proxy }).collect::>()), + "get_proxy_groups" => json!(config::load_proxy_groups(&())?), + "get_proxy_password" => { + let proxy=config::load_proxy_by_id(&(), text(&args,"proxyId")?)?.ok_or(WebError::bad("Proxy not found"))?; + json!(crypto::decrypt_optional(&proxy.password)?) + }, + "save_proxy" => { + let _guard=state.mutation.lock().await; + let mut proxy:config::ProxyConfig=argument(&args,"proxy")?; + if !matches!(proxy.protocol.as_str(), "socks5" | "http") { return Err(WebError::bad("Web supports SOCKS5 and HTTP CONNECT proxies only")); } + crate::network::validate_target(&proxy.host, proxy.port)?; + if proxy.id.is_empty() { proxy.id=uuid::Uuid::new_v4().to_string(); } + let mut proxies=config::load_proxies(&())?; + proxy.password=match proxy.password.as_deref() { Some("")=>None,Some(secret)=>Some(crypto::encrypt(secret)?),None=>proxies.iter().find(|p|p.id==proxy.id).and_then(|p|p.password.clone()) }; + let id=proxy.id.clone();proxies.retain(|p|p.id!=id);proxies.push(proxy);config::save_proxies(&(),&proxies)?; + state.broadcast("proxy-saved",Value::Null).await;json!(id) + }, + "delete_proxy" => { + let _guard=state.mutation.lock().await;let mut proxies=config::load_proxies(&())?;proxies.retain(|p|Some(p.id.as_str())!=args["proxyId"].as_str());config::save_proxies(&(),&proxies)?; + state.broadcast("proxy-saved",Value::Null).await;Value::Null + }, + "set_proxy_group" => { + let _guard=state.mutation.lock().await;let mut proxies=config::load_proxies(&())?; + let proxy=proxies.iter_mut().find(|p|Some(p.id.as_str())==args["proxyId"].as_str()).ok_or(WebError::bad("Proxy not found"))?; + proxy.group_id=args["groupId"].as_str().map(String::from);config::save_proxies(&(),&proxies)?;state.broadcast("proxy-saved",Value::Null).await;Value::Null + }, + "save_proxy_group" => { + let _guard=state.mutation.lock().await;let mut group:config::ProxyGroup=argument(&args,"group")?; + if group.id.is_empty() { group.id=uuid::Uuid::new_v4().to_string(); }let id=group.id.clone();let mut groups=config::load_proxy_groups(&())?;groups.retain(|g|g.id!=id);groups.push(group);config::save_proxy_groups(&(),&groups)?;state.broadcast("proxy-saved",Value::Null).await;json!(id) + }, + "delete_proxy_group" => { + let _guard=state.mutation.lock().await;let id=text(&args,"groupId")?; + let mut groups=config::load_proxy_groups(&())?;groups.retain(|g|g.id!=id);config::save_proxy_groups(&(),&groups)?; + let mut proxies=config::load_proxies(&())?;proxies.retain(|p|p.group_id.as_deref()!=Some(id));config::save_proxies(&(),&proxies)?;state.broadcast("proxy-saved",Value::Null).await;Value::Null + }, + "get_connection_custom_icons" => json!(config::load_config(&())?.custom_icons), + "delete_connection_custom_icon" => { + let _guard=state.mutation.lock().await;let id=text(&args,"id")?;let mut cfg=config::load_config(&())?;cfg.custom_icons.retain(|icon|icon.id!=id);config::save_config(&(),&cfg)?;state.broadcast("connections-changed",Value::Null).await;Value::Null + }, + "import_connection_icon" => { + let _guard=state.mutation.lock().await; + let data=text(&args,"dataUrl")?; + if data.len()>512*1024 {return Err(WebError::bad("Icon exceeds 512 KiB"));} + let now=std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_millis() as u64; + let icon=config::connection_custom_icon_from_data_url(data,text(&args,"name")?,now).ok_or(WebError::bad("Invalid icon data"))?; + let mut cfg=config::load_config(&())?;cfg.custom_icons.retain(|i|i.id!=icon.id);cfg.custom_icons.push(icon.clone());config::save_config(&(),&cfg)?;state.broadcast("connections-changed",Value::Null).await;json!(icon) + }, + "get_supported_ssh_algorithms" => json!(nyaterm_core::ssh::algorithms::get_supported_ssh_algorithms()), + "save_group" => { + let _guard=state.mutation.lock().await; + let mut group:config::Group=argument(&args,"group")?;if group.id.is_empty(){group.id=uuid::Uuid::new_v4().to_string();} + let id=group.id.clone();let mut config=config::load_config(&())?; + config.groups.retain(|g|g.id!=id);config.groups.push(group);config::save_config(&(),&config)?; + state.broadcast("connections-changed",Value::Null).await;json!(id) + }, + "get_saved_passwords" => json!(config::load_passwords(&())?.passwords.into_iter().map(|entry| json!({"id":entry.id,"name":entry.name,"username":entry.username,"has_password":entry.password.is_some()})).collect::>()), + "save_password" => { + let _guard=state.mutation.lock().await; + let mut entry:config::SavedPassword=argument(&args,"entry")?;if entry.id.is_empty(){entry.id=uuid::Uuid::new_v4().to_string();} + let id=entry.id.clone();let mut config=config::load_passwords(&())?; + entry.password=match entry.password.as_deref(){Some("")=>None,Some(secret)=>Some(crypto::encrypt(secret)?),None=>config.passwords.iter().find(|p|p.id==id).and_then(|p|p.password.clone())}; + config.passwords.retain(|p|p.id!=id);config.passwords.push(entry);config::save_passwords(&(),&config)?;json!(id) + }, + "delete_password" => { let _guard=state.mutation.lock().await;let mut config=config::load_passwords(&())?;config.passwords.retain(|p|Some(p.id.as_str())!=args["id"].as_str());config::save_passwords(&(),&config)?;Value::Null }, + "get_ssh_keys" => json!(config::load_keys(&())?.keys.into_iter().map(|entry| json!({"id":entry.id,"name":entry.name,"has_key_data":entry.key.is_some(),"has_cert_data":entry.cert.is_some()})).collect::>()), + "save_ssh_key" => { + let _guard=state.mutation.lock().await; + let mut entry:config::SshKey=argument(&args,"key")?; + if entry.key_file_path.is_some() || entry.cert_file_path.is_some() { return Err(WebError::unsupported()); } + if entry.id.is_empty(){entry.id=uuid::Uuid::new_v4().to_string();}let id=entry.id.clone(); + let mut config=config::load_keys(&())?;let existing=config.keys.iter().find(|p|p.id==id); + // The client can supply plaintext only via transient key_data. It may + // never inject an encrypted on-disk token or a server filesystem path. + entry.key=match entry.key_data.take(){Some(data)=>{services::validate_private_key_content(&data,entry.passphrase.as_deref())?;Some(crypto::encrypt(&data)?)},None=>existing.and_then(|k|k.key.clone())}; + entry.cert=match entry.cert_data.take(){Some(data)=>{services::validate_certificate_content(&data)?;Some(crypto::encrypt(&data)?)},None=>existing.and_then(|k|k.cert.clone())}; + if entry.key.is_none() { return Err(WebError::bad("Private key required")); } + entry.passphrase=match entry.passphrase.as_deref(){Some("")=>None,Some(secret)=>Some(crypto::encrypt(secret)?),None=>existing.and_then(|k|k.passphrase.clone())}; + config.keys.retain(|p|p.id!=id);config.keys.push(entry);config::save_keys(&(),&config)?;json!(id) + }, + "delete_ssh_key" => { let _guard=state.mutation.lock().await;let mut config=config::load_keys(&())?;config.keys.retain(|p|Some(p.id.as_str())!=args["id"].as_str());config::save_keys(&(),&config)?;Value::Null }, + "get_saved_credentials" => { let entries=config::load_credentials(&())?.credentials;json!(entries.into_iter().map(|mut e| { let exists=e.password.is_some();e.password=None;let mut value=json!(e);value["has_password"]=json!(exists);value }).collect::>()) }, + "save_credential" => { let _guard=state.mutation.lock().await;let entry:config::SavedCredential=argument(&args,"entry")?;{ let mut config=config::load_credentials(&())?;let id=config::upsert_credential(&mut config,entry)?;config::save_credentials(&(),&config)?;state.broadcast("credentials-changed",Value::Null).await;json!(id) } }, + "get_connection_password_value" => {let conn=config::load_connection_by_id(&(),text(&args,"id")?)?;json!(conn.auth.as_ref().map(|a|crypto::decrypt_optional(&a.password)).transpose()?.flatten())}, + "get_saved_credential_password" => json!(config::load_credential_by_id(&(),text(&args,"id")?)?.password), + "get_password_value" | "get_saved_password_value" => json!(config::load_password_by_id(&(),text(&args,"id")?)?.password), + "get_ssh_key_passphrase" => json!(config::load_key_by_id(&(),text(&args,"id")?)?.passphrase), + "get_ssh_key_private_key" => json!(config::decrypt_key_pem(&config::load_key_by_id(&(),text(&args,"id")?)?)?), + "get_ssh_key_public_key" => { let key=config::load_key_by_id(&(),text(&args,"id")?)?;let plain=config::decrypt_key_pem(&key)?.ok_or(WebError::bad("Private key required"))?;json!(services::derive_public_key_for_copy(&plain,key.passphrase.as_deref())?) }, + "delete_credential" => { let _guard=state.mutation.lock().await;let mut config=config::load_credentials(&())?;let id=text(&args,"id")?;config.credentials.retain(|p|p.id!=id);config::save_credentials(&(),&config)?;state.broadcast("credentials-changed",Value::Null).await;Value::Null }, + "reorder_credentials" => { let _guard=state.mutation.lock().await;let updates:Vec=argument(&args,"updates")?;let mut cfg=config::load_credentials(&())?;config::reorder_credentials(&mut cfg,&updates.into_iter().map(|u|(u.id,u.sort_order)).collect::>());config::save_credentials(&(),&cfg)?;state.broadcast("credentials-changed",Value::Null).await;Value::Null }, + "get_known_hosts" => json!(storage::list_known_hosts()?), + "delete_known_host" => { storage::delete_known_host(text(&args,"id")?)?;Value::Null }, + "clear_known_hosts" => { let _guard=state.mutation.lock().await;storage::clear_known_hosts()?;Value::Null }, + "respond_host_key_verify" | "submit_ssh_auth_response" | "cancel_ssh_auth_request" | "submit_otp_response" | "respond_transfer_duplicate" => { + let id=text(&args,"requestId")?; + if command == "respond_transfer_duplicate" && !matches!(args["action"].as_str(),Some("skip"|"overwrite")) {return Err(WebError::bad("Invalid duplicate choice"));} + let mut prompts=state.prompts.lock().await; + if !prompts.get(id).is_some_and(|p|p.owner==owner.0){return Err(WebError::forbidden());} + let prompt=prompts.remove(id).unwrap(); + let reply=match command.as_str(){"respond_host_key_verify"=>args["accepted"].clone(),"cancel_ssh_auth_request"=>Value::Null,"submit_otp_response"=>args["responses"].clone(),"respond_transfer_duplicate"=>{if !matches!(args["action"].as_str(),Some("skip"|"overwrite")){return Err(WebError::bad("Invalid duplicate choice"));}args["action"].clone()},_=>args["response"].clone()}; + let _=prompt.reply.send(reply);Value::Null + }, + "close_session" => { state.session(&owner.0,text(&args,"sessionId")?).await?.cancel.cancel();Value::Null }, + "cancel_session_creation" => { let id=text(&args,"createRequestId")?;for session in state.sessions.lock().await.values().filter(|s|s.owner==owner.0&&s.request_id.as_deref()==Some(id)){session.cancel.cancel();}Value::Null }, + "vnc_input_batch" | "vnc_set_clipboard_text" | "vnc_reconnect" | "close_vnc_session" | "respond_vnc_server_key" => crate::vnc::command(&state,&owner.0,&command,&args).await?, + "list_sessions" | "get_sessions" => json!(state.sessions.lock().await.values().filter(|s|s.owner==owner.0).map(|s|s.info()).collect::>()), + "get_session_info" => state.session(&owner.0,text(&args,"sessionId")?).await?.info(), + "get_terminal_cwd" | "try_get_terminal_cwd" | "get_session_cwd" => { state.session(&owner.0,text(&args,"sessionId")?).await?;Value::Null }, + "get_app_lock_state" => json!(false), + "get_system_fonts" => json!(["JetBrains Mono","monospace"]), + "get_system_font_infos" => json!([{"family":"JetBrains Mono","monospace":true}]), + "get_plugin_catalog" | "list_plugins" | "get_plugins" => json!([]), + "get_plugin_capabilities" => crate::plugins::capabilities(), + "check_web_plugin_compatibility" => crate::plugins::check(&args["manifest"]), + "get_plugin_marketplace" => json!({"target":"web","catalog":{"catalogVersion":1,"repository":{"id":"web","name":"Web"},"generatedAt":"","plugins":[]}}), + command if crate::suggestions::supports(command) => crate::suggestions::command(&state,&owner.0,command,&args).await?, + command if crate::notes::supports(command) => crate::notes::command(&state,command,&args).await?, + command if crate::otp::supports(command) => crate::otp::command(&state,command,&args).await?, + command if crate::monitoring::supports(command) => crate::monitoring::command(&state,&owner.0,command,&args).await?, + "translate_text" => { + let settings=config::load_app_settings(&())?; + let input=text(&args,"text")?; + if input.len()>64*1024 { return Err(WebError::bad("Translation text exceeds 64 KiB")); } + let target=text(&args,"targetLanguage")?; + let target=if target.is_empty() { &settings.translation.target_language } else { target }; + json!(tokio::time::timeout(std::time::Duration::from_secs(30),nyaterm_core::core::translate::translate(text(&args,"provider")?,input,target,&settings.translation)).await.map_err(|_|WebError::bad("Translation timed out"))??) + }, + "finish_recording_scope" | "notify_mcp_session_restore_complete" => Value::Null, + command if crate::sftp::supports(command) => crate::sftp::command(&state,&owner.0,command,&args).await?, + 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 new file mode 100644 index 00000000..02ee588d --- /dev/null +++ b/src-tauri/crates/nyaterm-web/src/error.rs @@ -0,0 +1,57 @@ +use axum::{ + Json, + http::StatusCode, + response::{IntoResponse, Response}, +}; +use serde_json::json; + +#[derive(Debug)] +pub struct WebError(pub StatusCode, pub String); +pub type Result = std::result::Result; +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()) + } + pub fn unsupported() -> Self { + Self( + StatusCode::NOT_IMPLEMENTED, + "This capability is unavailable in Web mode".into(), + ) + } +} +impl From for WebError { + fn from(error: nyaterm_core::error::AppError) -> Self { + crate::observability::record_error(&error); + Self::bad("Backend operation failed") + } +} +impl From for WebError { + fn from(_: serde_json::Error) -> Self { + Self::bad("Invalid request parameters") + } +} +impl From for WebError { + 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(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 new file mode 100644 index 00000000..57b6cb60 --- /dev/null +++ b/src-tauri/crates/nyaterm-web/src/lib.rs @@ -0,0 +1,155 @@ +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; +pub mod session; +pub mod sftp; +pub mod state; +pub mod suggestions; +pub mod telnet; +pub mod vnc; + +use crate::{auth::Owner, state::State}; +use axum::{ + Extension, Router, + extract::{DefaultBodyLimit, State as ExtractState}, + http::{HeaderValue, StatusCode}, + middleware, + response::{ + Sse, + sse::{Event, KeepAlive}, + }, + routing::{get, post}, +}; +use std::{convert::Infallible, sync::Arc, time::Duration}; +use tower_http::{ + services::{ServeDir, ServeFile}, + set_header::SetResponseHeaderLayer, +}; + +async fn events( + ExtractState(state): ExtractState>, + Extension(owner): Extension, +) -> 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!{ + _ = login.cancel.cancelled()=>{yield Ok(Event::default().event("expired").data("{}"));break;}, + next=receiver.recv()=>match next{ + Ok(event)=>{if let Ok(data)=serde_json::to_string(&event){yield Ok(Event::default().data(data));}}, + Err(tokio::sync::broadcast::error::RecvError::Lagged(_))=>{yield Ok(Event::default().event("expired").data("{}"));break;}, + Err(_)=>break, + } + } + } + }; + Ok(Sse::new(stream).keep_alive(KeepAlive::new().interval(Duration::from_secs(15)))) +} +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)) + .route("/sessions/{id}/vnc", get(vnc::ws_route)) + .route("/sessions/{id}/download", get(sftp::download)) + .route( + "/sessions/{id}/upload", + post(sftp::upload).layer(DefaultBodyLimit::disable()), + ) + .route("/commands/{command}", post(commands::route)) + .route_layer(middleware::from_fn_with_state(state.clone(), auth::guard)); + let api = protected + .route("/auth/login", post(auth::sign_in)) + .fallback(|| async { + ( + StatusCode::NOT_FOUND, + axum::Json(serde_json::json!({"error":"API route not found"})), + ) + }) + .layer(DefaultBodyLimit::max(4 * 1024 * 1024)); + let site = Router::new() + .nest("/api", api) + .route_service("/", ServeFile::new(dist.join("index.html"))) + .fallback_service(ServeDir::new(&dist).fallback(ServeFile::new(dist.join("index.html")))); + let app = if state.base_path.is_empty() { + site + } else { + // Axum nests an inner `/` at the exact prefix. Include the trailing + // slash so the browser's directory URL reaches index.html. + let directory = format!("{}/", state.base_path); + Router::new().nest(&directory, site).route( + &state.base_path, + get(move || { + let directory = directory.clone(); + async move { axum::response::Redirect::permanent(&directory) } + }), + ) + }; + app.layer(middleware::from_fn_with_state(state.clone(), auth::site_guard)) + .layer(SetResponseHeaderLayer::overriding(axum::http::header::CONTENT_SECURITY_POLICY,HeaderValue::from_static("default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https:; font-src 'self' data:; connect-src 'self'; frame-src 'self' blob:; frame-ancestors 'self'; object-src 'none'; base-uri 'self'; form-action 'self'"))) + .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) +} + +pub async fn reap(state: Arc) { + let mut interval = tokio::time::interval(Duration::from_secs(1)); + loop { + tokio::select! {_ = state.shutdown.cancelled()=>break,_ = interval.tick()=>{}} + let expired = state + .logins + .lock() + .await + .iter() + .filter(|(_, l)| l.expires <= std::time::Instant::now()) + .map(|(id, _)| id.clone()) + .collect::>(); + for owner in expired { + state.close_owner(&owner).await; + } + state + .prompts + .lock() + .await + .retain(|_, prompt| !prompt.reply.is_closed()); + for session in state.sessions.lock().await.values() { + if !session.attached.load(std::sync::atomic::Ordering::Acquire) + && session.detached_at.lock().unwrap().elapsed() > Duration::from_secs(30) + { + session.cancel.cancel(); + } + } + } +} diff --git a/src-tauri/crates/nyaterm-web/src/main.rs b/src-tauri/crates/nyaterm-web/src/main.rs new file mode 100644 index 00000000..87980c2f --- /dev/null +++ b/src-tauri/crates/nyaterm-web/src/main.rs @@ -0,0 +1,143 @@ +use base64::{Engine, engine::general_purpose::STANDARD}; +use nyaterm_web::{auth, state::State}; +use std::{collections::HashMap, path::PathBuf, sync::Arc}; +use tokio::sync::Mutex; + +fn secret(name: &str) -> Result, Box> { + let value = if let Ok(path) = std::env::var(format!("{name}_FILE")) { + std::fs::read_to_string(path)? + .trim_end_matches(['\r', '\n']) + .to_owned() + } else { + std::env::var(name).map_err(|_| format!("{name} or {name}_FILE is required"))? + }; + Ok(zeroize::Zeroizing::new(value)) +} + +#[tokio::main] +async fn main() -> Result<(), Box> { + let password = secret("NYATERM_WEB_PASSWORD")?; + if password.len() < 32 { + return Err("NYATERM_WEB_PASSWORD must contain at least 32 random characters".into()); + } + let encoded = secret("NYATERM_WEB_ENCRYPTION_KEY")?; + let bytes = zeroize::Zeroizing::new( + STANDARD + .decode(encoded.as_bytes()) + .map_err(|_| "Invalid encryption key encoding")?, + ); + let key: [u8; 32] = bytes + .as_slice() + .try_into() + .map_err(|_| "Encryption key must decode to 32 bytes")?; + nyaterm_core::utils::crypto::set_server_key_material(key); + let public = + std::env::var("NYATERM_WEB_PUBLIC_URL").unwrap_or_else(|_| "http://localhost:8080/".into()); + let url = url::Url::parse(&public)?; + let origin = url.origin().ascii_serialization(); + if !matches!(url.scheme(), "http" | "https") + || !url.username().is_empty() + || url.password().is_some() + || url.query().is_some() + || url.fragment().is_some() + { + return Err("Invalid public URL".into()); + } + let loopback = matches!(url.host_str(), Some("localhost" | "127.0.0.1" | "[::1]")); + let allow_insecure_http = std::env::var("NYATERM_WEB_ALLOW_INSECURE_HTTP") + .unwrap_or_else(|_| "false".into()) + .parse() + .unwrap_or(false); + 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(); + if base_path.contains('%') || base_path.contains("//") { + return Err("Invalid base path".into()); + } + let host = url[url::Position::BeforeHost..url::Position::AfterPort].to_owned(); + 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)] + { + use std::os::unix::fs::PermissionsExt; + 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::< + nyaterm_core::config::AppSettings, + >(nyaterm_core::storage::SettingsDocKey::AppSettings)? + .security + .master_password + { + let plain = nyaterm_core::utils::crypto::decrypt_settings_secret(&secret)?; + nyaterm_core::utils::crypto::set_master_password(Some(plain)); + nyaterm_core::utils::crypto::verify_master_key_token()?; + } + nyaterm_core::utils::crypto::verify_master_key_token()?; + let dist = PathBuf::from(std::env::var("NYATERM_WEB_DIST").unwrap_or_else(|_| "dist".into())); + if !dist.join("index.html").is_file() { + return Err("React dist/index.html is missing; run pnpm build:web".into()); + } + let state = Arc::new(State { + origin, + host, + base_path, + secure_cookie: url.scheme() == "https", + password_hash: auth::digest(&password), + logins: Mutex::new(HashMap::new()), + sessions: Mutex::new(HashMap::new()), + prompts: Mutex::new(HashMap::new()), + ai_streams: Mutex::new(HashMap::new()), + login_attempts: Mutex::new(Vec::new()), + mutation: Mutex::new(()), + shutdown: tokio_util::sync::CancellationToken::new(), + }); + 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?; + tracing::info!(event = "server.started", "NyaTerm Web server started"); + let shutdown = state.shutdown.clone(); + nyaterm_web::observability::spawn(async move { + #[cfg(unix)] + { + let mut terminate = + tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) + .expect("SIGTERM handler"); + tokio::select! {_ = tokio::signal::ctrl_c()=>{},_ = terminate.recv()=>{}} + } + #[cfg(not(unix))] + { + let _ = tokio::signal::ctrl_c().await; + } + shutdown.cancel(); + }); + use std::future::IntoFuture; + let stop = state.shutdown.clone(); + let server = axum::serve(listener, nyaterm_web::router(state.clone(), dist)) + .with_graceful_shutdown(async move { stop.cancelled().await }) + .into_future(); + tokio::pin!(server); + tokio::select! { + result=&mut server=>result?, + _ = state.shutdown.cancelled()=> {let _=tokio::time::timeout(std::time::Duration::from_secs(5),&mut server).await;} + } + for session in state.sessions.lock().await.values() { + session.cancel.cancel(); + } + let _ = tokio::time::timeout(std::time::Duration::from_secs(5), async { + while !state.sessions.lock().await.is_empty() { + tokio::time::sleep(std::time::Duration::from_millis(25)).await; + } + }) + .await; + tracing::info!(event = "server.stopped", "NyaTerm Web server stopped"); + nyaterm_web::observability::shutdown(); + Ok(()) +} diff --git a/src-tauri/crates/nyaterm-web/src/monitoring.rs b/src-tauri/crates/nyaterm-web/src/monitoring.rs new file mode 100644 index 00000000..7113d2a2 --- /dev/null +++ b/src-tauri/crates/nyaterm-web/src/monitoring.rs @@ -0,0 +1,68 @@ +use crate::{ + commands::{argument, text}, + error::{Result, WebError}, + remote_exec, + state::State, +}; +use nyaterm_core::core::monitoring::{ascend_npu, gpu, process, stats}; +use serde_json::{Value, json}; +pub fn supports(command: &str) -> bool { + matches!( + command, + "get_remote_stats" + | "get_remote_gpu_overview" + | "get_remote_ascend_npu_overview" + | "get_remote_processes" + | "signal_remote_process" + ) +} +pub async fn command(state: &State, owner: &str, command: &str, args: &Value) -> Result { + let id = text(args, "sessionId")?; + let session = state.session(owner, id).await?; + Ok(match command { + "get_remote_stats" => { + let lease = session.stats.lock_session(id).await; + let plan = session.stats.probe_plan(id).await; + let output = remote_exec::exec(&session, &stats::build_stats_script(plan)).await?; + let parsed = stats::parse_stats_output(&output.stdout)?; + json!( + session + .stats + .complete_snapshot_with_lease(id, &lease, parsed) + .await + ) + } + "get_remote_gpu_overview" => json!(gpu::parse_gpu_overview_output( + &remote_exec::exec(&session, gpu::GPU_OVERVIEW_SCRIPT) + .await? + .stdout + )), + "get_remote_ascend_npu_overview" => json!(ascend_npu::parse_npu_overview_output( + &remote_exec::exec(&session, ascend_npu::ASCEND_NPU_OVERVIEW_SCRIPT) + .await? + .stdout + )), + "get_remote_processes" => { + let output = remote_exec::exec(&session, process::PROCESS_LIST_SCRIPT).await?; + if process::is_process_list_unsupported(&output.stdout) { + return Err(WebError::bad(process::PROCESS_LIST_UNSUPPORTED_ERROR)); + } + json!(process::parse_process_output(&output.stdout)) + } + _ => { + let pid: u32 = argument(args, "pid")?; + if pid == 0 { + return Err(WebError::bad("Invalid PID")); + } + let signal = match text(args, "signal")?.trim().to_ascii_uppercase().as_str() { + "TERM" | "SIGTERM" | "15" => "TERM", + "KILL" | "SIGKILL" | "9" => "KILL", + "HUP" | "SIGHUP" | "1" => "HUP", + "STOP" | "SIGSTOP" | "19" => "STOP", + "CONT" | "SIGCONT" | "18" => "CONT", + _ => return Err(WebError::bad("Unsupported signal")), + }; + json!(remote_exec::exec(&session, &format!("kill -{signal} -- {pid}")).await?) + } + }) +} diff --git a/src-tauri/crates/nyaterm-web/src/network.rs b/src-tauri/crates/nyaterm-web/src/network.rs new file mode 100644 index 00000000..ee8e257b --- /dev/null +++ b/src-tauri/crates/nyaterm-web/src/network.rs @@ -0,0 +1,154 @@ +//! Authenticated proxy and recursive SSH jump transports for Web sessions. +use crate::{ + error::{Result, WebError}, + session, + state::State, +}; +use futures_util::future::BoxFuture; +use nyaterm_core::{ + config::{self, ConnectionNetwork}, + network::{self, OpenedTransport, TransportRouteKind}, +}; +use std::{ + pin::Pin, + sync::Arc, + task::{Context, Poll}, +}; +use tokio::io::{AsyncRead, AsyncWrite, ReadBuf}; +use tokio_util::sync::CancellationToken; + +pub fn open( + state: Arc, + owner: String, + cancel: CancellationToken, + host: String, + port: u16, + route: Option, +) -> BoxFuture<'static, Result> { + Box::pin(async move { + validate_target(&host, port)?; + tokio::select! { + _ = cancel.cancelled() => Err(WebError::bad("Connection cancelled")), + result = tokio::time::timeout(std::time::Duration::from_secs(120), open_inner(state, owner, cancel.clone(), host, port, route, Vec::new())) => result.unwrap_or(Err(WebError::bad("Network connection timed out"))), + } + }) +} +pub fn validate_target(host: &str, port: u16) -> Result<()> { + if host.is_empty() + || host.len() > 253 + || host.chars().any(|c| c.is_whitespace() || c.is_control()) + || port == 0 + { + return Err(WebError::bad("Invalid host or port")); + } + Ok(()) +} +fn open_inner( + state: Arc, + owner: String, + cancel: CancellationToken, + host: String, + port: u16, + route: Option, + mut visited: Vec, +) -> BoxFuture<'static, Result> { + Box::pin(async move { + validate_target(&host, port)?; + let resolved = network::resolve_transport_route(route.as_ref()); + match resolved.kind { + TransportRouteKind::Direct => Ok(network::open_direct_transport(&host, port) + .await + .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| { + 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"), + } + })?; + if !matches!(proxy.protocol.as_str(), "socks5" | "http") { + return Err(WebError::bad( + "Web supports SOCKS5 and HTTP CONNECT proxies only", + )); + } + validate_target(&proxy.host, proxy.port)?; + Ok(network::open_proxy_transport(proxy, &host, port) + .await + .map_err(|error| { + crate::observability::record_error(&error); + WebError::bad(error.to_string()) + })?) + } + TransportRouteKind::SshJump => { + let id = resolved.proxy_jump_id.unwrap(); + if visited.contains(&id) || visited.len() >= 8 { + return Err(WebError::bad("SSH jump cycle or depth limit exceeded")); + } + visited.push(id.clone()); + let connection = config::load_connection_by_id(&(), &id)?; + if !matches!(connection.config, config::ConnectionType::Ssh { .. }) { + return Err(WebError::bad("SSH jump must refer to an SSH connection")); + } + let target = session::jump_target(&id)?; + let transport = open_inner( + state.clone(), + owner.clone(), + cancel.clone(), + target.host.clone(), + target.port, + target.network.clone(), + visited, + ) + .await?; + let handle = + session::authenticate(&state, &owner, &cancel, target, transport.stream) + .await?; + let channel = handle + .channel_open_direct_tcpip(&host, u32::from(port), "127.0.0.1", 0) + .await?; + Ok(OpenedTransport { + stream: Box::new(ForwardedStream { + stream: channel.into_stream(), + _handle: handle, + }), + local_addr: None, + }) + } + } + }) +} +struct ForwardedStream { + stream: russh::ChannelStream, + _handle: russh::client::Handle, +} +impl AsyncRead for ForwardedStream { + fn poll_read( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &mut ReadBuf<'_>, + ) -> Poll> { + Pin::new(&mut self.stream).poll_read(cx, buf) + } +} +impl AsyncWrite for ForwardedStream { + fn poll_write( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &[u8], + ) -> Poll> { + Pin::new(&mut self.stream).poll_write(cx, buf) + } + fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + Pin::new(&mut self.stream).poll_flush(cx) + } + fn poll_shutdown(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + Pin::new(&mut self.stream).poll_shutdown(cx) + } +} diff --git a/src-tauri/crates/nyaterm-web/src/notes.rs b/src-tauri/crates/nyaterm-web/src/notes.rs new file mode 100644 index 00000000..9a82b567 --- /dev/null +++ b/src-tauri/crates/nyaterm-web/src/notes.rs @@ -0,0 +1,102 @@ +use crate::{ + commands::{argument, text}, + error::{Result, WebError}, + state::State, +}; +use nyaterm_core::{config, storage}; +use serde_json::{Value, json}; +pub fn supports(command: &str) -> bool { + matches!( + command, + "list_note_tree" + | "get_note" + | "create_note" + | "create_note_folder" + | "update_note" + | "rename_note_node" + | "move_note_node" + | "delete_note_node" + | "get_notes_export" + ) +} +pub async fn command(state: &State, command: &str, args: &Value) -> Result { + if command == "get_notes_export" { + return Ok(json!(storage::load_notes_snapshot()?)); + } + if command == "list_note_tree" { + return Ok(json!(config::NoteTreePayload { + folders: storage::list_note_folders()?, + notes: storage::list_note_summaries()? + })); + } + if command == "get_note" { + return Ok(json!( + storage::get_note(text(args, "noteId")?)?.ok_or(WebError::bad("Note not found"))? + )); + } + let _guard = state.mutation.lock().await; + let (result, event) = match command { + "create_note" => { + let note = storage::create_note( + argument(args, "parentId")?, + argument(args, "title")?, + argument(args, "markdown")?, + )?; + let event = json!({"kind":"created","nodeKind":"note","ids":[note.id],"folders":[],"notes":[config::NoteSummary::from(note.clone())],"treeChanged":true}); + (json!(note), event) + } + "create_note_folder" => { + let folder = + storage::create_note_folder(argument(args, "parentId")?, argument(args, "name")?)?; + let event = json!({"kind":"created","nodeKind":"folder","ids":[folder.id],"folders":[folder],"notes":[],"treeChanged":true}); + (json!(folder), event) + } + "update_note" => { + let update = storage::update_note( + text(args, "noteId")?, + argument(args, "title")?, + argument(args, "markdown")?, + argument(args, "expectedRevision")?, + args["force"].as_bool().unwrap_or(false), + ) + .map_err(|error| match error { + nyaterm_core::error::AppError::Config(message) + if message.starts_with("Revision conflict:") => + { + WebError(axum::http::StatusCode::CONFLICT, "Revision conflict".into()) + } + other => other.into(), + })?; + let event = json!({"kind":"updated","nodeKind":"note","ids":[update.note.id],"folders":[],"notes":[config::NoteSummary::from(update.note.clone())],"treeChanged":update.tree_changed}); + (json!(update.note), event) + } + "delete_note_node" => { + let deleted = + storage::delete_note_node(text(args, "nodeKind")?, text(args, "nodeId")?)?; + let event = json!({"kind":"deleted","nodeKind":args["nodeKind"],"ids":deleted.ids,"folders":[],"notes":[],"treeChanged":true}); + (json!(deleted), event) + } + _ => { + let change = if command == "rename_note_node" { + storage::rename_note_node( + text(args, "nodeKind")?, + text(args, "nodeId")?, + argument(args, "name")?, + )? + } else { + storage::move_note_node( + text(args, "nodeKind")?, + text(args, "nodeId")?, + argument(args, "parentId")?, + argument(args, "sortOrder")?, + )? + }; + ( + Value::Null, + json!({"kind":if command=="rename_note_node" {"renamed"} else {"moved"},"nodeKind":args["nodeKind"],"ids":[args["nodeId"]],"folders":change.folder.into_iter().collect::>(),"notes":change.note.into_iter().collect::>(),"treeChanged":change.tree_changed}), + ) + } + }; + state.broadcast("notes-changed", event).await; + Ok(result) +} 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/otp.rs b/src-tauri/crates/nyaterm-web/src/otp.rs new file mode 100644 index 00000000..eb2cf57d --- /dev/null +++ b/src-tauri/crates/nyaterm-web/src/otp.rs @@ -0,0 +1,159 @@ +use crate::{ + commands::{argument, text}, + error::{Result, WebError}, + state::State, +}; +use nyaterm_core::{config, utils::crypto}; +use serde_json::{Value, json}; +pub fn supports(command: &str) -> bool { + matches!( + command, + "get_otp_entries" + | "get_otp_secret_value" + | "save_otp_entry" + | "delete_otp_entry" + | "generate_otp_code" + | "parse_otp_uri" + ) +} +fn algorithm(value: &str) -> Result { + Ok(match value { + "SHA1" => nyaterm_otp::Algorithm::SHA1, + "SHA256" => nyaterm_otp::Algorithm::SHA256, + "SHA512" => nyaterm_otp::Algorithm::SHA512, + _ => return Err(WebError::bad("Invalid OTP algorithm")), + }) +} +fn validate(entry: &config::OtpEntry) -> Result<()> { + if !matches!(entry.otp_type.as_str(), "totp" | "hotp") + || !(6..=8).contains(&entry.digits) + || entry.period == 0 + || entry.period > 86400 + { + return Err(WebError::bad("Invalid OTP parameters")); + } + algorithm(&entry.algorithm)?; + if let Some(secret) = &entry.secret { + nyaterm_otp::Secret::from_base32(secret) + .map_err(|_| WebError::bad("Invalid base32 OTP secret"))?; + } + Ok(()) +} +pub async fn command(state: &State, command: &str, args: &Value) -> Result { + let _guard = state.mutation.lock().await; + Ok(match command { + "get_otp_entries" => json!( + config::load_otp_entries(&())? + .entries + .into_iter() + .map(|mut e| { + let exists = e.secret.is_some(); + e.secret = None; + let mut value = json!(e); + value["has_secret"] = json!(exists); + value + }) + .collect::>() + ), + "get_otp_secret_value" => { + json!(config::load_otp_entry_by_id(&(), text(args, "id")?)?.secret) + } + "save_otp_entry" => { + let mut entry: config::OtpEntry = argument(args, "entry")?; + if entry.id.is_empty() { + entry.id = uuid::Uuid::new_v4().to_string(); + } + validate(&entry)?; + let mut cfg = config::load_otp_entries(&())?; + entry.secret = match entry.secret.as_deref() { + Some(secret) if !secret.is_empty() => Some(crypto::encrypt(secret)?), + _ => cfg + .entries + .iter() + .find(|e| e.id == entry.id) + .and_then(|e| e.secret.clone()), + }; + if entry.secret.is_none() { + return Err(WebError::bad("OTP secret required")); + } + let id = entry.id.clone(); + cfg.entries.retain(|e| e.id != id); + cfg.entries.push(entry); + config::save_otp_entries(&(), &cfg)?; + state.broadcast("otp-entries-changed", Value::Null).await; + json!(id) + } + "delete_otp_entry" => { + let mut cfg = config::load_otp_entries(&())?; + let id = text(args, "id")?; + cfg.entries.retain(|e| e.id != id); + config::save_otp_entries(&(), &cfg)?; + state.broadcast("otp-entries-changed", Value::Null).await; + Value::Null + } + "parse_otp_uri" => { + let uri = text(args, "uri")?; + if uri.starts_with("otpauth://totp/") { + let otp = nyaterm_otp::Totp::from_uri(uri) + .map_err(|_| WebError::bad("Invalid TOTP URI"))?; + json!({"id":"","otp_type":"totp","issuer":otp.issuer(),"username":otp.label(),"secret":otp.secret().into_base32(),"algorithm":otp.alg().to_string(),"digits":otp.digits(),"period":otp.period(),"counter":0}) + } else if uri.starts_with("otpauth://hotp/") { + let otp = nyaterm_otp::Hotp::from_uri(uri) + .map_err(|_| WebError::bad("Invalid HOTP URI"))?; + json!({"id":"","otp_type":"hotp","issuer":otp.issuer(),"username":otp.label(),"secret":otp.secret().into_base32(),"algorithm":otp.alg().to_string(),"digits":otp.digits(),"period":30,"counter":otp.counter()}) + } else { + return Err(WebError::bad("Invalid OTP URI")); + } + } + _ => { + let id = text(args, "id")?; + let entry = config::load_otp_entry_by_id(&(), id)?; + validate(&entry)?; + let secret = nyaterm_otp::Secret::from_base32( + entry + .secret + .as_deref() + .ok_or(WebError::bad("OTP secret unavailable"))?, + ) + .map_err(|_| WebError::bad("Invalid OTP secret"))?; + let alg = algorithm(&entry.algorithm)?; + if entry.otp_type == "hotp" { + let next = entry + .counter + .checked_add(1) + .ok_or(WebError::bad("HOTP counter exhausted"))?; + let mut otp = nyaterm_otp::Hotp::new( + alg, + entry.issuer, + entry.username, + entry.digits, + entry.counter, + secret, + ); + let code = format!("{:0>width$}", otp.generate(), width = entry.digits as usize); + let mut cfg = config::load_otp_entries(&())?; + cfg.entries + .iter_mut() + .find(|e| e.id == id) + .ok_or(WebError::bad("OTP not found"))? + .counter = next; + config::save_otp_entries(&(), &cfg)?; + json!({"code":code,"remainingSeconds":0}) + } else { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + let otp = nyaterm_otp::Totp::new( + alg, + entry.issuer, + entry.username, + entry.digits, + entry.period, + secret, + ); + json!({"code":format!("{:0>width$}",otp.generate_at(now),width=entry.digits as usize),"remainingSeconds":entry.period-now%entry.period}) + } + } + }) +} diff --git a/src-tauri/crates/nyaterm-web/src/plugins.rs b/src-tauri/crates/nyaterm-web/src/plugins.rs new file mode 100644 index 00000000..fa45cb96 --- /dev/null +++ b/src-tauri/crates/nyaterm-web/src/plugins.rs @@ -0,0 +1,20 @@ +//! Web compatibility boundary. No untrusted plugin code executes on the server. +use serde_json::{Value, json}; +pub fn capabilities() -> Value { + json!({"runtime":"web","installation":false,"native":false,"server_process":false,"server_filesystem":false,"allowedPermissions":["ui"],"scope":"authenticated-browser"}) +} +pub fn check(manifest: &Value) -> Value { + let denied = manifest["permissions"] + .as_array() + .map(|permissions| { + permissions + .iter() + .filter(|p| p.as_str() != Some("ui")) + .cloned() + .collect::>() + }) + .unwrap_or_default(); + let browser_only = + manifest["backend"].is_null() && denied.is_empty() && manifest["permissions"].is_array(); + json!({"runtime":"web","browserCompatible":browser_only,"deniedPermissions":denied,"installationAvailable":false}) +} diff --git a/src-tauri/crates/nyaterm-web/src/remote_exec.rs b/src-tauri/crates/nyaterm-web/src/remote_exec.rs new file mode 100644 index 00000000..a24bddc7 --- /dev/null +++ b/src-tauri/crates/nyaterm-web/src/remote_exec.rs @@ -0,0 +1,84 @@ +//! Bounded private SSH exec channels, scoped to the authenticated user's session. +use crate::{ + error::{Result, WebError}, + session::{SessionProtocol, WebSession}, +}; +use russh::ChannelMsg; +use std::time::Duration; + +struct ChannelGuard(Option>); +impl Drop for ChannelGuard { + fn drop(&mut self) { + if let Some(channel) = self.0.take() { + crate::observability::spawn(async move { + let _ = tokio::time::timeout(Duration::from_secs(2), channel.close()).await; + }); + } + } +} +#[derive(serde::Serialize)] +pub struct Output { + pub stdout: String, + pub stderr: String, + pub exit_status: Option, +} +pub async fn exec(session: &WebSession, script: &str) -> Result { + if !matches!(session.protocol, SessionProtocol::Ssh) + || !session.ready.load(std::sync::atomic::Ordering::Acquire) + { + return Err(WebError::bad("SSH session is not ready")); + } + let _permit = session + .transfers + .clone() + .try_acquire_owned() + .map_err(|_| WebError::bad("Too many remote operations"))?; + let operation = async { + let channel = session + .handle + .lock() + .await + .as_ref() + .ok_or(WebError::bad("SSH disconnected"))? + .channel_open_session() + .await?; + let mut guard = ChannelGuard(Some(channel)); + let channel = guard.0.as_mut().unwrap(); + channel.exec(true, script.as_bytes()).await?; + channel.eof().await?; + let mut stdout = Vec::new(); + let mut stderr = Vec::new(); + let mut status = None; + while let Some(message) = channel.wait().await { + match message { + ChannelMsg::Data { data } => { + if stdout.len() + stderr.len() + data.len() > 4 * 1024 * 1024 { + return Err(WebError::bad("Remote output exceeds 4 MiB")); + } + stdout.extend_from_slice(&data); + } + ChannelMsg::ExtendedData { data, .. } => { + if stdout.len() + stderr.len() + data.len() > 4 * 1024 * 1024 { + return Err(WebError::bad("Remote output exceeds 4 MiB")); + } + stderr.extend_from_slice(&data); + } + ChannelMsg::ExitStatus { exit_status } => status = Some(exit_status), + ChannelMsg::Close => break, + _ => {} + } + } + if status != Some(0) { + return Err(WebError::bad("Remote command failed")); + } + Ok(Output { + stdout: String::from_utf8_lossy(&stdout).into(), + stderr: String::from_utf8_lossy(&stderr).into(), + exit_status: status, + }) + }; + tokio::select! {_=session.cancel.cancelled()=>Err(WebError::bad("Session closed")),result=tokio::time::timeout(Duration::from_secs(30),operation)=>result.map_err(|_|WebError::bad("Remote command timed out"))?} +} +pub fn quote(value: &str) -> String { + format!("'{}'", value.replace('\'', "'\\''")) +} diff --git a/src-tauri/crates/nyaterm-web/src/session.rs b/src-tauri/crates/nyaterm-web/src/session.rs new file mode 100644 index 00000000..6b768845 --- /dev/null +++ b/src-tauri/crates/nyaterm-web/src/session.rs @@ -0,0 +1,783 @@ +use crate::{ + auth::Owner, + error::{Result, WebError}, + state::State, +}; +use axum::{ + Extension, Json, + extract::{ + Path, State as ExtractState, WebSocketUpgrade, + ws::{Message, WebSocket}, + }, + response::Response, +}; +use nyaterm_core::{ + config, + ssh::{ + algorithms, protocol, + terminal::{self, Command, Output}, + }, + storage, + utils::crypto, +}; +use russh::{client, keys::PublicKeyBase64}; +use serde_json::{Value, json}; +use std::{ + sync::{ + Arc, Mutex as StdMutex, + atomic::{AtomicBool, Ordering}, + }, + time::{Duration, Instant}, +}; +use tokio::sync::{Mutex, OwnedMutexGuard, mpsc}; +use tokio_util::sync::CancellationToken; +use tracing::Instrument; + +pub struct Handler { + state: Arc, + owner: String, + host: String, + port: u16, + cancel: CancellationToken, +} +impl client::Handler for Handler { + type Error = russh::Error; + async fn check_server_key( + &mut self, + key: &russh::keys::PublicKey, + ) -> std::result::Result { + let host_id = if self.port == 22 { + self.host.clone() + } else { + format!("[{}]:{}", self.host, self.port) + }; + let key_type = key.algorithm().to_string(); + let encoded = key.public_key_base64(); + let known = storage::check_known_host(&host_id, &key_type, &encoded) + .map_err(|_| russh::Error::UnknownKey)?; + if matches!(known, storage::KnownHostCheck::Match) { + return Ok(true); + } + let reply = self + .state + .prompt( + &self.owner, + "host-key-verify", + json!({ + "host":self.host,"port":self.port,"keyType":key_type, + "fingerprint":key.fingerprint(russh::keys::HashAlg::Sha256).to_string(), + "isKeyChanged":matches!(known,storage::KnownHostCheck::HostSeen), + }), + &self.cancel, + ) + .await + .map_err(|_| russh::Error::UnknownKey)?; + if reply.as_bool() != Some(true) { + return Ok(false); + } + storage::replace_known_host_for_host(&host_id, &format!("{host_id} {key_type} {encoded}")) + .map_err(|_| russh::Error::UnknownKey)?; + Ok(true) + } +} +pub struct WebSession { + pub protocol: SessionProtocol, + pub id: String, + pub owner: String, + pub name: String, + pub host: String, + pub port: u16, + pub username: String, + pub connection_id: Option, + pub request_id: Option, + pub workspace_pane_id: Option, + pub cancel: CancellationToken, + pub handle: Mutex>>, + pub input: mpsc::Sender, + pub output: Arc>>, + pub attached: AtomicBool, + pub detached_at: StdMutex, + pub ready: AtomicBool, + pub transfers: Arc, + pub sftp: config::SftpSettings, + pub stats: nyaterm_core::core::monitoring::stats::RemoteStatsSampler, +} +pub enum SessionProtocol { + Ssh, + Telnet, + Vnc(Arc), +} +impl WebSession { + pub fn info(&self) -> Value { + let kind = match self.protocol { + SessionProtocol::Ssh => "SSH", + SessionProtocol::Telnet => "Telnet", + SessionProtocol::Vnc(_) => "VNC", + }; + let terminal = !matches!(self.protocol, SessionProtocol::Vnc(_)); + let sftp = matches!(self.protocol, SessionProtocol::Ssh) + && self.sftp.enabled + && self.ready.load(Ordering::Acquire); + let connected = match &self.protocol { + SessionProtocol::Vnc(web) => *web.state.lock().unwrap() == "active", + _ => self.ready.load(Ordering::Acquire), + } && !self.cancel.is_cancelled(); + let stats = matches!(self.protocol, SessionProtocol::Ssh) && connected; + json!({"id":self.id,"name":self.name,"session_type":kind,"host":self.host,"port":self.port,"username":self.username,"connection_id":self.connection_id,"owner_window_label":"main","workspace_pane_id":self.workspace_pane_id,"attached":self.attached.load(Ordering::Acquire),"ready":self.ready.load(Ordering::Acquire),"connected":connected,"terminal_available":terminal,"shell_available":terminal,"sftp_available":sftp,"remote_file_browser_enabled":sftp,"remote_stats_enabled":stats,"injection_active":false,"runtime_mode":"standard","ssh_runtime_mode":"standard","dynamic_title_enabled":false,"dynamic_title_integration_active":false}) + } +} + +pub(crate) struct SessionMetadata { + pub host: String, + pub port: u16, + pub username: String, + pub name: String, + pub connection_id: Option, + pub sftp: Option, +} +pub(crate) async fn register( + state: &Arc, + owner: &str, + args: &Value, + protocol: SessionProtocol, + metadata: SessionMetadata, + cancel: CancellationToken, +) -> Result<( + Arc, + mpsc::Receiver, + mpsc::Sender, +)> { + state.login(owner).await?; + crate::network::validate_target(&metadata.host, metadata.port)?; + let (input, commands) = mpsc::channel(128); + let (sender, output) = mpsc::channel(terminal::OUTPUT_CAPACITY); + let session = Arc::new(WebSession { + protocol, + id: uuid::Uuid::new_v4().to_string(), + owner: owner.into(), + name: metadata.name, + host: metadata.host, + port: metadata.port, + username: metadata.username, + connection_id: metadata.connection_id, + request_id: args["createRequestId"].as_str().map(String::from), + workspace_pane_id: args["recordingScopeId"].as_str().map(String::from), + cancel, + handle: Mutex::new(None), + input, + output: Arc::new(Mutex::new(output)), + attached: AtomicBool::new(false), + detached_at: StdMutex::new(Instant::now()), + ready: AtomicBool::new(false), + transfers: Arc::new(tokio::sync::Semaphore::new(4)), + sftp: metadata.sftp.unwrap_or(config::SftpSettings { + enabled: false, + ..Default::default() + }), + stats: Default::default(), + }); + let mut registry = state.sessions.lock().await; + if registry.len() >= 128 || registry.values().filter(|s| s.owner == owner).count() >= 16 { + return Err(WebError::bad("Too many sessions")); + } + registry.insert(session.id.clone(), session.clone()); + Ok((session, commands, sender)) +} +pub(crate) fn connection(args: &Value, kind: &str) -> Result<(config::SavedConnection, bool)> { + if let Some(id) = args["connectionId"].as_str() { + return Ok((config::load_connection_by_id(&(), id)?, true)); + } + let mut value = if args["config"].is_object() { + args["config"].clone() + } else { + args.clone() + }; + value["type"] = json!(kind); + value["id"] = json!(""); + if !value["name"].is_string() { + value["name"] = json!(kind); + } + Ok((serde_json::from_value(value)?, false)) +} + +pub(crate) struct Target { + pub(crate) host: String, + pub(crate) port: u16, + pub(crate) username: String, + name: String, + mode: String, + secret: Option>, + key: Option, + passphrase: Option>, + preferences: Option, + connection_id: Option, + pub(crate) network: Option, + terminal_type: config::SshTerminalType, + sftp: config::SftpSettings, +} +fn require_utf8(encoding: &str) -> Result<()> { + let effective = if encoding.is_empty() || encoding.eq_ignore_ascii_case("global") { + config::load_app_settings(&())?.interaction.default_encoding + } else { + encoding.to_owned() + }; + if !matches!(effective.to_lowercase().as_str(), "utf-8" | "utf8") { + return Err(WebError::bad("Web SSH requires UTF-8 terminal encoding")); + } + Ok(()) +} +fn validate_sftp(settings: &config::SftpSettings) -> Result<()> { + if settings.enabled { + if settings.compatibility_mode { + return Err(WebError::unsupported()); + } + if !settings.filename_encoding.is_empty() { + require_utf8(&settings.filename_encoding)?; + } + } + Ok(()) +} +pub(crate) fn target(args: &Value) -> Result { + target_for(args, false) +} +pub(crate) fn jump_target(id: &str) -> Result { + target_for(&json!({"connectionId": id}), true) +} +fn target_for(args: &Value, transport_only: bool) -> Result { + if let Some(id) = args["connectionId"].as_str() { + let conn = config::load_connection_by_id(&(), id)?; + let config::ConnectionType::Ssh { + host, + port, + username, + x11_forwarding, + agent_forwarding_config, + encoding, + .. + } = &conn.config + else { + return Err(WebError::unsupported()); + }; + if !transport_only + && (*x11_forwarding || agent_forwarding_config.as_ref().is_some_and(|c| c.enabled)) + { + return Err(WebError::unsupported()); + } + if !transport_only { + require_utf8(encoding)?; + validate_sftp(&conn.sftp)?; + } + if !transport_only + && (conn.post_login.as_ref().is_some_and(|p| p.enabled) + || conn.ssh_profile != config::SshProfile::Standard) + { + return Err(WebError::unsupported()); + } + let terminal_type = + config::resolve_ssh_terminal_type(&conn.ssh_profile, conn.terminal_type.as_ref()); + let account = conn + .auth + .as_ref() + .map(|a| { + config::load_saved_account(&(), a.account_id.as_deref(), a.password_id.as_deref()) + }) + .transpose()? + .flatten(); + let username = config::resolve_account_username(account.as_ref(), username); + let mut secret = None; + let mut key = None; + let mut passphrase = None; + let mode = conn + .auth + .as_ref() + .map(|a| a.mode.clone()) + .unwrap_or_else(|| "password".into()); + if let Some(auth) = &conn.auth { + secret = match &auth.password { + Some(ciphertext) => Some(crypto::decrypt(ciphertext)?), + None if auth.password_source.as_deref() != Some("connection") => { + config::decrypt_account_password(account.as_ref())? + } + _ => None, + }; + if let Some(key_id) = &auth.key_id { + let saved = config::load_key_by_id(&(), key_id)?; + if saved.cert.is_some() { + return Err(WebError::unsupported()); + } + key = saved.key.as_deref().map(crypto::decrypt).transpose()?; + passphrase = saved.passphrase; + } + } + Ok(Target { + host: host.clone(), + port: *port, + username, + name: conn.name, + mode, + secret: secret.map(zeroize::Zeroizing::new), + key, + passphrase: passphrase.map(zeroize::Zeroizing::new), + preferences: conn.ssh_algorithms, + network: conn.network.clone(), + connection_id: Some(id.into()), + terminal_type, + sftp: conn.sftp, + }) + } else { + let value = &args["config"]; + if !value["proxy"].is_null() + || !value["proxy_jump"].is_null() + || value["x11_forwarding"].as_bool() == Some(true) + { + return Err(WebError::unsupported()); + } + if value["agent_forwarding_config"]["enabled"].as_bool() == Some(true) + || !value["auth"]["cert_data"].is_null() + { + return Err(WebError::unsupported()); + } + require_utf8(value["encoding"].as_str().unwrap_or(""))?; + let sftp: config::SftpSettings = value + .get("sftp") + .filter(|v| !v.is_null()) + .map(|v| serde_json::from_value(v.clone())) + .transpose()? + .unwrap_or_default(); + validate_sftp(&sftp)?; + if value["ssh_profile"] + .as_str() + .is_some_and(|p| p != "standard") + { + return Err(WebError::unsupported()); + } + let terminal_type = value + .get("terminal_type") + .filter(|v| !v.is_null()) + .map(|v| serde_json::from_value(v.clone())) + .transpose()? + .unwrap_or(config::SshTerminalType::Xterm256Color); + let port = match value["port"].as_u64() { + Some(p) => u16::try_from(p) + .ok() + .filter(|p| *p > 0) + .ok_or(WebError::bad("Invalid SSH port"))?, + None if value["port"].is_null() => 22, + _ => return Err(WebError::bad("Invalid SSH port")), + }; + Ok(Target { + host: value["host"] + .as_str() + .ok_or(WebError::bad("SSH host required"))? + .into(), + port, + username: value["username"].as_str().unwrap_or("root").into(), + name: value["name"].as_str().unwrap_or("SSH").into(), + mode: value["auth"]["type"].as_str().unwrap_or("password").into(), + secret: value["auth"]["password"] + .as_str() + .map(|v| zeroize::Zeroizing::new(v.into())), + key: value["auth"]["key_data"].as_str().map(String::from), + passphrase: value["auth"]["passphrase"] + .as_str() + .map(|v| zeroize::Zeroizing::new(v.into())), + preferences: value + .get("ssh_algorithms") + .filter(|v| !v.is_null()) + .map(|v| serde_json::from_value(v.clone())) + .transpose()?, + network: value + .get("network") + .filter(|v| !v.is_null()) + .map(|v| serde_json::from_value(v.clone())) + .transpose()?, + connection_id: None, + terminal_type, + sftp, + }) + } +} +pub async fn create(state: Arc, owner: &str, args: Value) -> Result { + match args["type"].as_str().unwrap_or("ssh") { + "telnet" => return crate::telnet::create(state, owner, args).await, + "vnc" => return crate::vnc::create(state, owner, args).await, + "ssh" => {} + _ => return Err(WebError::unsupported()), + } + if args["startupCommand"].as_object().is_some() + || args["runtimeMode"] + .as_str() + .is_some_and(|m| m != "standard") + || args["config"]["post_login"]["enabled"].as_bool() == Some(true) + { + return Err(WebError::unsupported()); + } + let target = target(&args)?; + if !matches!(target.mode.as_str(), "none" | "password" | "key") { + return Err(WebError::unsupported()); + } + if target.host.is_empty() + || target.host.len() > 253 + || target + .host + .chars() + .any(|c| c.is_whitespace() || c.is_control()) + || target.port == 0 + || target.username.is_empty() + || target.username.len() > 256 + || target.username.contains('\0') + { + return Err(WebError::bad("Invalid SSH target")); + } + let login = state.login(owner).await?; + let metadata = SessionMetadata { + host: target.host.clone(), + port: target.port, + username: target.username.clone(), + name: target.name.clone(), + connection_id: target.connection_id.clone(), + sftp: Some(target.sftp.clone()), + }; + let (session, commands, sender) = register( + &state, + owner, + &args, + SessionProtocol::Ssh, + metadata, + login.cancel.child_token(), + ) + .await?; + let id = session.id.clone(); + crate::observability::spawn(async move { + let result = tokio::select! { + _ = session.cancel.cancelled() => 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) + .await; + terminal::run(channel, commands, sender, session.cancel.clone()).await; + } + Err(error) => { + tracing::warn!(event="session.connect_failed", session_id=%session.id, reason=crate::observability::error_reason(&error.1), "SSH connection failed"); + state + .event( + &session.owner, + &format!("connection-error-{}", session.id), + json!(error.1), + ) + .await; + let _ = sender.try_send(Output::Failure(error.1)); + } + } + finish(&state, &session).await; + }); + Ok(id) +} +pub(crate) async fn finish(state: &State, session: &WebSession) { + tracing::info!(event="session.closed", session_id=%session.id, "Web session closed"); + session.cancel.cancel(); + // Let cancelled transfers remove their temporary files while SSH is still alive. + let _transfers = tokio::time::timeout( + Duration::from_secs(6), + session.transfers.clone().acquire_many_owned(4), + ) + .await; + if let Some(handle) = session.handle.lock().await.take() { + let _ = tokio::time::timeout( + Duration::from_secs(2), + handle.disconnect(russh::Disconnect::ByApplication, "", ""), + ) + .await; + } + state.sessions.lock().await.remove(&session.id); + if !session.attached.load(Ordering::Acquire) { + state + .event( + &session.owner, + &format!("session-closed-{}", session.id), + Value::Null, + ) + .await; + } + state + .event(&session.owner, "sessions-changed", Value::Null) + .await; +} +async fn connect( + state: &Arc, + session: &Arc, + target: Target, +) -> Result> { + let stream = crate::network::open( + state.clone(), + session.owner.clone(), + session.cancel.clone(), + target.host.clone(), + target.port, + target.network.clone(), + ) + .await?; + let terminal = target.terminal_type.as_str().to_owned(); + let connection_id = target.connection_id.clone(); + let mut handle = authenticate( + state, + &session.owner, + &session.cancel, + target, + stream.stream, + ) + .await?; + let channel = protocol::open_shell_with_terminal(&mut handle, 80, 24, &terminal).await?; + *session.handle.lock().await = Some(handle); + if let Some(id) = connection_id { + storage::mark_connection_used(&id)?; + } + Ok(channel) +} +pub(crate) async fn authenticate( + state: &Arc, + owner: &str, + cancel: &CancellationToken, + mut target: Target, + stream: nyaterm_core::network::BoxedTransportStream, +) -> Result> { + if !matches!(target.mode.as_str(), "none" | "password" | "key") { + return Err(WebError::bad("Unsupported SSH authentication mode")); + } + let client_config = client::Config { + preferred: algorithms::resolve_preferred_algorithms(target.preferences.as_ref())?, + keepalive_interval: Some(Duration::from_secs(30)), + keepalive_max: 3, + ..Default::default() + }; + let handler = Handler { + state: state.clone(), + owner: owner.into(), + host: target.host.clone(), + port: target.port, + cancel: cancel.clone(), + }; + let mut handle = client::connect_stream(Arc::new(client_config), stream, handler).await?; + let mut authenticated = false; + if target.mode == "none" { + authenticated = handle.authenticate_none(&target.username).await?.success(); + } + if target.mode == "key" { + let key_data = zeroize::Zeroizing::new( + target + .key + .take() + .ok_or(WebError::bad("Private key required"))?, + ); + let mut decoded = russh::keys::decode_secret_key( + &key_data, + target.passphrase.as_deref().map(|p| p.as_str()), + ); + if decoded.is_err() && target.passphrase.is_none() { + let response=state.prompt(owner,"ssh-auth-request",json!({"connectionId":target.connection_id,"connectionName":target.name,"host":target.host,"port":target.port,"username":target.username,"reason":"key_passphrase_required","promptKind":"passphrase","availableMethods":["publickey"],"currentAuthMode":"key","attempt":1,"canSave":false}),cancel).await?; + target.passphrase = response["secret"] + .as_str() + .map(|s| zeroize::Zeroizing::new(s.into())); + decoded = russh::keys::decode_secret_key( + &key_data, + target.passphrase.as_deref().map(|p| p.as_str()), + ); + } + let key = decoded.map_err(|_| WebError::bad("Private key could not be unlocked"))?; + let hash = handle.best_supported_rsa_hash().await?.flatten(); + authenticated = handle + .authenticate_publickey( + &target.username, + russh::keys::PrivateKeyWithHashAlg::new(Arc::new(key), hash), + ) + .await? + .success(); + } + if !authenticated && target.mode != "key" { + for attempt in 1..=3 { + if target.secret.is_none() { + let response = state.prompt(owner,"ssh-auth-request",json!({ + "connectionId":target.connection_id,"connectionName":target.name,"host":target.host,"port":target.port,"username":target.username, + "reason":if attempt == 1 {"missing_password"} else {"password_rejected"},"promptKind":"password","availableMethods":["password"],"currentAuthMode":"password","attempt":attempt,"canSave":false,"accountId":null, + }),cancel).await?; + target.secret = response["secret"] + .as_str() + .map(|s| zeroize::Zeroizing::new(s.into())); + } + let Some(secret) = &target.secret else { + return Err(WebError::bad("SSH authentication cancelled")); + }; + let result = protocol::password(&mut handle, &target.username, secret).await?; + if result.success() { + authenticated = true; + break; + } + if let russh::client::AuthResult::Failure { + remaining_methods, .. + } = result + { + if remaining_methods.contains(&russh::MethodKind::KeyboardInteractive) { + authenticated = + keyboard_auth(state, owner, cancel, &mut handle, &target).await?; + break; + } + } + target.secret = None; + } + } + if !authenticated && target.mode == "key" { + authenticated = keyboard_auth(state, owner, cancel, &mut handle, &target).await?; + } + if !authenticated { + return Err(WebError::bad("SSH authentication failed")); + } + Ok(handle) +} +async fn keyboard_auth( + state: &Arc, + owner: &str, + cancel: &CancellationToken, + handle: &mut client::Handle, + target: &Target, +) -> Result { + let mut step = protocol::keyboard_start(handle, &target.username).await?; + for round in 1..=8 { + match step { + client::KeyboardInteractiveAuthResponse::Success => return Ok(true), + client::KeyboardInteractiveAuthResponse::Failure { .. } => return Ok(false), + client::KeyboardInteractiveAuthResponse::InfoRequest { + name, + instructions, + prompts, + } => { + if prompts.len() > 16 { + return Err(WebError::bad("Too many authentication prompts")); + } + let response=state.prompt(owner,"otp-request",json!({"connectionName":target.name,"name":name,"instructions":instructions,"round":round,"prompts":prompts.iter().map(|p|json!({"prompt":p.prompt,"echo":p.echo})).collect::>(),"otpEntryId":null}),cancel).await?; + let responses: Vec = serde_json::from_value(response)?; + if responses.len() != prompts.len() { + return Err(WebError::bad("Invalid authentication response")); + } + step = protocol::keyboard_respond(handle, responses).await?; + } + } + } + Err(WebError::bad("Too many authentication rounds")) +} +pub async fn create_route( + ExtractState(state): ExtractState>, + Extension(owner): Extension, + Json(args): Json, +) -> Result> { + Ok(Json( + json!({"session_id":create(state,&owner.0,args).await?}), + )) +} +pub async fn ws_route( + ExtractState(state): ExtractState>, + Extension(owner): Extension, + Path(id): Path, + upgrade: WebSocketUpgrade, +) -> Result { + let session = state.session(&owner.0, &id).await?; + if matches!(session.protocol, SessionProtocol::Vnc(_)) { + return Err(WebError::bad("This session has no terminal")); + } + let receiver = session.output.clone().try_lock_owned().map_err(|_| { + WebError( + axum::http::StatusCode::CONFLICT, + "Terminal already attached".into(), + ) + })?; + Ok(upgrade + .max_message_size(64 * 1024) + .max_frame_size(64 * 1024) + .max_write_buffer_size(256 * 1024) + .on_upgrade({ + let span = tracing::Span::current(); + let guard = crate::observability::StreamLogGuard::new("terminal.disconnected", Some(session.id.clone())); + move |socket| async move { + let _guard = guard; + tracing::info!(event="terminal.attached", session_id=%session.id, "Web stream attached"); + socket_loop(session, receiver, socket).await; + }.instrument(span) + })) +} +struct Attachment(Arc); +impl Drop for Attachment { + fn drop(&mut self) { + *self.0.detached_at.lock().unwrap() = Instant::now(); + self.0.attached.store(false, Ordering::Release); + } +} +async fn socket_loop( + session: Arc, + mut output: OwnedMutexGuard>, + mut socket: WebSocket, +) { + session.attached.store(true, Ordering::Release); + let _attachment = Attachment(session.clone()); + let mut heartbeat = tokio::time::interval(Duration::from_secs(15)); + let mut last_peer = Instant::now(); + loop { + tokio::select! { + // Read control frames and heartbeats even when output is always ready. + // Keep buffered terminal output ahead of backend cancellation so the + // final bytes are delivered before the closed frame. + biased; + message = socket.recv() => match message { + Some(Ok(Message::Text(text))) => { + last_peer = Instant::now(); + let Ok(value) = serde_json::from_str::(&text) else { break; }; + let command = match value["type"].as_str() { + Some("input") => value["data"].as_str().map(|s| Command::Input(s.as_bytes().to_vec())), + Some("resize") => value["cols"].as_u64().zip(value["rows"].as_u64()).filter(|(c,r)| (1..=1000).contains(c)&&(1..=1000).contains(r)).map(|(c,r)| Command::Resize(c as u32,r as u32)), + Some("close") => { session.cancel.cancel(); break; }, + _ => None, + }; + let Some(command) = command else { break; }; + if session.input.try_send(command).is_err() { break; } + }, + Some(Ok(Message::Binary(bytes))) => { last_peer=Instant::now(); if session.input.try_send(Command::RawInput(bytes.to_vec())).is_err() { break; } }, + Some(Ok(Message::Pong(_))) => last_peer=Instant::now(), + Some(Ok(Message::Ping(data))) => { if !matches!(tokio::time::timeout(Duration::from_secs(2),socket.send(Message::Pong(data))).await,Ok(Ok(()))) { break; } }, + _ => break, + }, + _ = heartbeat.tick() => { + if last_peer.elapsed() > Duration::from_secs(45) { break; } + if !matches!(tokio::time::timeout(Duration::from_secs(2),socket.send(Message::Ping(Vec::new().into()))).await,Ok(Ok(()))) { break; } + }, + next = output.recv() => { + let final_frame = !matches!(&next, Some(Output::Data(_))); + let message = match next { + Some(Output::Data(data)) => Message::Binary(data.into()), + Some(Output::Error) => Message::Text(json!({"type":"error","error":if matches!(session.protocol, SessionProtocol::Telnet) { "Telnet connection failed" } else { "SSH connection failed" }}).to_string().into()), + Some(Output::Failure(error)) => Message::Text(json!({"type":"error","error":error}).to_string().into()), + _ => Message::Text(json!({"type":"closed"}).to_string().into()), + }; + // A failed attachment leaves the remote alive for its reconnect lease. + if !matches!(tokio::time::timeout(Duration::from_secs(10),socket.send(message)).await,Ok(Ok(()))) { break; } + if final_frame { break; } + }, + _ = session.cancel.cancelled() => { let _ = tokio::time::timeout(Duration::from_secs(2),socket.send(Message::Text(json!({"type":"closed"}).to_string().into()))).await; break; }, + } + } + // Complete the WebSocket close handshake before dropping the TCP stream. + // Dropping with an unread pong/input can reset the connection on Windows, + // discarding the last terminal output even though send() already succeeded. + let _ = tokio::time::timeout(Duration::from_secs(2), async { + if socket.send(Message::Close(None)).await.is_ok() { + while let Some(Ok(message)) = socket.recv().await { + if matches!(message, Message::Close(_)) { + break; + } + } + } + }) + .await; +} diff --git a/src-tauri/crates/nyaterm-web/src/sftp.rs b/src-tauri/crates/nyaterm-web/src/sftp.rs new file mode 100644 index 00000000..07af0297 --- /dev/null +++ b/src-tauri/crates/nyaterm-web/src/sftp.rs @@ -0,0 +1,908 @@ +use crate::{ + auth::Owner, + error::{Result, WebError}, + session::WebSession, + state::State, +}; +use axum::{ + Extension, Json, + body::Body, + extract::{Path, Query, State as ExtractState}, + http::header, + response::Response, +}; +use nyaterm_core::ssh::{files, protocol}; +use serde::Deserialize; +use serde_json::{Value, json}; +use std::{sync::Arc, time::Duration}; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio_util::io::ReaderStream; + +pub fn supports(command: &str) -> bool { + matches!( + command, + "get_home_dir" + | "list_remote_dir" + | "list_remote_child_directories" + | "delete_remote_file" + | "rename_remote_file" + | "create_remote_dir" + | "create_remote_file" + | "read_remote_file_text" + | "open_remote_file_text" + | "write_remote_file_text" + | "get_file_properties" + | "get_remote_file_stat" + | "read_remote_file_bytes" + | "create_remote_symlink" + | "update_remote_symlink_target" + | "update_remote_file_attributes" + | "chmod_remote_file" + | "find_missing_remote_entries" + | "copy_file_entry" + | "move_file_entry" + ) +} +pub async fn open(session: &WebSession) -> Result { + if !matches!(session.protocol, crate::session::SessionProtocol::Ssh) || !session.sftp.enabled { + return Err(WebError::unsupported()); + } + if !session.ready.load(std::sync::atomic::Ordering::Acquire) { + return Err(WebError::bad("SSH is still connecting")); + } + let operation = async { + let mut handle = session.handle.lock().await; + let handle = handle + .as_mut() + .ok_or(WebError::bad("SSH is disconnected"))?; + let config = russh_sftp::client::Config { + max_concurrent_writes: session.sftp.pipeline_depth.unwrap_or(8) as usize, + ..Default::default() + }; + Ok(protocol::open_sftp_with_config(handle, config).await?) + }; + tokio::select! { + _ = session.cancel.cancelled() => Err(WebError::bad("Session closed")), + result = tokio::time::timeout(Duration::from_secs(30), operation) => result.map_err(|_| WebError::bad("SFTP timed out"))?, + } +} +fn validate_path(path: &str) -> Result<&str> { + if path.is_empty() || path.contains('\0') || path.len() > 4096 { + Err(WebError::bad("Invalid remote path")) + } else { + Ok(path) + } +} +fn path<'a>(args: &'a Value, name: &str) -> Result<&'a str> { + validate_path( + args[name] + .as_str() + .ok_or(WebError::bad("Remote path required"))?, + ) +} +fn mode(value: Option<&str>) -> Result> { + value + .filter(|v| !v.trim().is_empty()) + .map(|v| { + let value = v.trim(); + let parsed = u32::from_str_radix(value, 8) + .map_err(|_| WebError::bad("Invalid octal permissions"))?; + if parsed > 0o7777 { + return Err(WebError::bad("Invalid octal permissions")); + } + Ok(parsed) + }) + .transpose() +} +async fn apply_mode( + sftp: &russh_sftp::client::SftpSession, + path: &str, + permissions: Option, +) -> Result<()> { + if let Some(permissions) = permissions { + sftp.set_metadata( + path, + russh_sftp::protocol::FileAttributes { + permissions: Some(permissions), + ..russh_sftp::protocol::FileAttributes::empty() + }, + ) + .await?; + } + Ok(()) +} +fn entry(name: String, attrs: &russh_sftp::protocol::FileAttributes) -> files::FileEntry { + files::FileEntry { + name, + is_dir: attrs.is_dir(), + is_symlink: attrs.is_symlink(), + size: attrs.size.unwrap_or(0), + permissions: files::describe_permissions(attrs.permissions), + owner: files::owner_or_id(&attrs.user, attrs.uid), + group: files::group_or_id(&attrs.group, attrs.gid), + mtime: attrs.mtime.unwrap_or(0) as u64, + raw_path_token: None, + } +} +pub async fn command( + state: &Arc, + owner: &str, + command: &str, + args: &Value, +) -> Result { + // Raw filename encodings and recursive/native workflows have no Web MVP contract. + if matches!(command, "copy_file_entry" | "move_file_entry") { + return copy_entry(state, owner, command, args).await; + } + if !args["rawPathToken"].is_null() { + return Err(WebError::unsupported()); + } + let session = state.session(owner, path(args, "sessionId")?).await?; + let _permit = session + .transfers + .clone() + .try_acquire_owned() + .map_err(|_| WebError::bad("Too many SFTP operations"))?; + let sftp = open(&session).await?; + let mut temporary_link = None; + let operation = async { + Ok(match command { + "get_home_dir" => json!(sftp.canonicalize(".").await?), + "list_remote_dir" | "list_remote_child_directories" => { + let parent = path(args, "path")?; + let entries = sftp.read_dir(parent).await?.collect::>(); + if entries + .iter() + .any(|e| std::str::from_utf8(e.file_name_bytes()).is_err()) + { + return Err(WebError::bad("Web SFTP requires UTF-8 filenames")); + } + let entries = entries.into_iter(); + let entries = entries.filter(|e| e.file_name() != "." && e.file_name() != ".."); + if command == "list_remote_child_directories" { + json!( + entries + .filter(|e| e.metadata().is_dir() + && (args["showHiddenFiles"].as_bool() == Some(true) + || !e.file_name().starts_with('.'))) + .map(|e| files::DirectoryChild { + name: e.file_name(), + path: format!("{}/{}", parent.trim_end_matches('/'), e.file_name()), + is_symlink: e.metadata().is_symlink(), + raw_path_token: None + }) + .collect::>() + ) + } else { + json!( + entries + .map(|e| entry(e.file_name(), &e.metadata())) + .collect::>() + ) + } + } + "delete_remote_file" => { + let p = path(args, "path")?; + if sftp.symlink_metadata(p).await?.is_dir() { + sftp.remove_dir(p).await?; + } else { + sftp.remove_file(p).await?; + } + Value::Null + } + "rename_remote_file" => { + sftp.rename(path(args, "oldPath")?, path(args, "newPath")?) + .await?; + Value::Null + } + "create_remote_dir" => { + let input_mode: Option = crate::commands::argument(args, "mode")?; + let permissions = mode(input_mode.as_deref())?; + let p = path(args, "path")?; + sftp.create_dir(p).await?; + apply_mode(&sftp, p, permissions).await?; + Value::Null + } + "create_remote_file" => { + let input_mode: Option = crate::commands::argument(args, "mode")?; + let permissions = mode(input_mode.as_deref())?; + sftp.create(path(args, "path")?) + .await? + .shutdown() + .await + .map_err(|error| WebError::io(&error, "Remote write failed"))?; + apply_mode(&sftp, path(args, "path")?, permissions).await?; + Value::Null + } + "create_remote_symlink" => { + sftp.symlink_openssh(path(args, "targetPath")?, path(args, "linkPath")?) + .await?; + Value::Null + } + "update_remote_symlink_target" => { + let p = path(args, "path")?; + let target = path(args, "targetPath")?; + if !sftp.symlink_metadata(p).await?.is_symlink() { + return Err(WebError::bad("Remote path is not a symbolic link")); + } + // Create the replacement before touching the original link. + let temporary = format!("{p}.nyaterm-{}.link", uuid::Uuid::new_v4()); + temporary_link = Some(temporary.clone()); + sftp.symlink_openssh(target, &temporary).await?; + sftp.rename_replace(&temporary, p).await?; + temporary_link = None; + Value::Null + } + "chmod_remote_file" | "update_remote_file_attributes" => { + let p = path(args, "path")?; + let update: files::RemoteFileAttributeUpdate = if command == "chmod_remote_file" { + files::RemoteFileAttributeUpdate { + mode: Some(crate::commands::text(args, "mode")?.into()), + ..Default::default() + } + } else { + crate::commands::argument(args, "update")? + }; + if update.recursive { + return Err(WebError::bad( + "Recursive attribute changes require a transfer task", + )); + } + let current = sftp.metadata(p).await?; + let parse_id = |value: Option<&str>| -> Result> { + value + .filter(|v| !v.trim().is_empty()) + .map(|v| { + v.trim().parse().map_err(|_| { + WebError::bad("Web SFTP requires a numeric UID or GID") + }) + }) + .transpose() + }; + let uid = parse_id(update.owner.as_deref())?; + let gid = parse_id(update.group.as_deref())?; + let permissions = mode(update.mode.as_deref())?; + if uid.is_some() || gid.is_some() || permissions.is_some() { + // SFTP v3 serializes UID and GID together: preserve the + // unspecified half instead of silently changing it to root. + let ownership = uid.is_some() || gid.is_some(); + let attrs = russh_sftp::protocol::FileAttributes { + permissions, + uid: if ownership { + Some( + uid.or(current.uid) + .ok_or(WebError::bad("Remote UID unavailable"))?, + ) + } else { + None + }, + gid: if ownership { + Some( + gid.or(current.gid) + .ok_or(WebError::bad("Remote GID unavailable"))?, + ) + } else { + None + }, + ..russh_sftp::protocol::FileAttributes::empty() + }; + sftp.set_metadata(p, attrs).await?; + } + Value::Null + } + "find_missing_remote_entries" => { + let paths: Vec = crate::commands::argument(args, "paths")?; + let mut missing = Vec::new(); + for p in paths { + validate_path(&p)?; + match sftp.symlink_metadata(&p).await { + Ok(_) => {} + Err(russh_sftp::client::error::Error::Status(status)) + if status.status_code + == russh_sftp::protocol::StatusCode::NoSuchFile => + { + missing.push(p) + } + Err(error) => return Err(error.into()), + } + } + json!(missing) + } + "read_remote_file_bytes" => { + let p = path(args, "path")?; + let limit = args["maxBytes"] + .as_u64() + .filter(|v| *v > 0 && *v <= 25 * 1024 * 1024) + .ok_or(WebError::bad( + "Preview limit must be between 1 byte and 25 MiB", + ))?; + let attrs = sftp.metadata(p).await?; + if attrs.is_dir() || attrs.size.is_some_and(|size| size > limit) { + return Err(WebError::bad("Remote file exceeds preview limit")); + } + let mut bytes = Vec::new(); + sftp.open(p) + .await? + .take(limit + 1) + .read_to_end(&mut bytes) + .await + .map_err(|error| WebError::io(&error, "Remote read failed"))?; + if bytes.len() as u64 > limit { + return Err(WebError::bad("Remote file exceeds preview limit")); + } + json!(files::RemoteBinaryFile { + path: p.into(), + size: bytes.len() as u64, + mtime: attrs.mtime.unwrap_or(0) as u64, + mtime_nanos: None, + content_bytes: bytes + }) + } + "read_remote_file_text" | "open_remote_file_text" => { + let p = path(args, "path")?; + let attrs = sftp.metadata(p).await?; + let mut file = sftp.open(p).await?; + let limit = args["maxBytes"] + .as_u64() + .unwrap_or(1024 * 1024) + .min(4 * 1024 * 1024); + let mut bytes = Vec::new(); + (&mut file) + .take(limit + 1) + .read_to_end(&mut bytes) + .await + .map_err(|error| WebError::io(&error, "Remote read failed"))?; + if bytes.len() as u64 > limit { + return Err(WebError::bad("Remote file is too large")); + } + let result = files::classify_text_file(files::RemoteBinaryFile { + path: p.into(), + size: bytes.len() as u64, + mtime: attrs.mtime.unwrap_or(0) as u64, + mtime_nanos: None, + content_bytes: bytes, + }); + if command == "open_remote_file_text" { + json!(result) + } else { + match result { + files::TextFileOpenResult::Text { file } => json!(file), + _ => return Err(WebError::bad("Remote file is not UTF-8 text")), + } + } + } + "write_remote_file_text" => { + let p = path(args, "path")?; + let content = args["content"] + .as_str() + .ok_or(WebError::bad("Content required"))?; + let attrs = sftp.metadata(p).await?; + if args["force"].as_bool() != Some(true) { + let changed = args["expectedMtime"] + .as_u64() + .is_some_and(|t| t != attrs.mtime.unwrap_or(0) as u64) + || args["expectedSize"] + .as_u64() + .is_some_and(|s| s != attrs.size.unwrap_or(0)); + let mut hash_changed = false; + if let Some(expected) = args["expectedHash"].as_str() { + let mut old = Vec::new(); + sftp.open(p) + .await? + .take(4 * 1024 * 1024 + 1) + .read_to_end(&mut old) + .await + .map_err(|error| WebError::io(&error, "Remote read failed"))?; + hash_changed = files::content_hash(&old) != expected; + } + if changed || hash_changed { + return Ok(json!(files::WriteRemoteTextResult::conflict( + attrs.mtime.unwrap_or(0) as u64, + attrs.size.unwrap_or(0), + None + ))); + } + } + let mut file = sftp.create(p).await?; + file.write_all(content.as_bytes()) + .await + .map_err(|error| WebError::io(&error, "Remote write failed"))?; + file.shutdown() + .await + .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, + content.len() as u64, + None, + files::content_hash(content.as_bytes()) + )) + } + "get_file_properties" | "get_remote_file_stat" => { + let p = path(args, "path")?; + let attrs = sftp.symlink_metadata(p).await?; + let base = entry(p.rsplit('/').next().unwrap_or(p).into(), &attrs); + json!(files::FileProperties { + name: base.name, + is_dir: base.is_dir, + is_symlink: base.is_symlink, + symlink_target: if attrs.is_symlink() { + sftp.read_link(p).await.ok() + } else { + None + }, + size: base.size, + permissions: base.permissions, + owner: base.owner, + group: base.group, + uid: attrs.uid.map(|v| v.to_string()).unwrap_or_default(), + gid: attrs.gid.map(|v| v.to_string()).unwrap_or_default(), + mtime: base.mtime, + atime: attrs.atime.unwrap_or(0) as u64 + }) + } + _ => return Err(WebError::unsupported()), + }) + }; + let result = tokio::select! {_ = session.cancel.cancelled()=>Err(WebError::bad("Session closed")),result=tokio::time::timeout(Duration::from_secs(30),operation)=>result.map_err(|_|WebError::bad("SFTP timed out"))?}; + if let Some(path) = temporary_link { + let _ = tokio::time::timeout(Duration::from_secs(2), sftp.remove_file(path)).await; + } + let _ = tokio::time::timeout(Duration::from_secs(2), sftp.close()).await; + result +} +#[derive(Deserialize)] +pub struct TransferPath { + path: String, +} +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct CopyEndpoint { + session_id: String, + kind: String, + path: String, +} +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct CopyRequest { + source: CopyEndpoint, + target: CopyEndpoint, + file_name: String, + is_directory: bool, + transfer_id: Option, + duplicate_strategy_override: Option, +} +async fn copy_entry(state: &Arc, owner: &str, command: &str, args: &Value) -> Result { + let request: CopyRequest = crate::commands::argument(args, "request")?; + if request.source.kind != "remote" || request.target.kind != "remote" { + return Err(WebError::unsupported()); + } + validate_path(&request.source.path)?; + validate_path(&request.target.path)?; + let source = state.session(owner, &request.source.session_id).await?; + let target = state.session(owner, &request.target.session_id).await?; + let same = source.id == target.id; + if request.is_directory && !(same && command == "move_file_entry") { + return Err(WebError::bad("Recursive copies require a transfer task")); + } + if request.file_name.is_empty() + || matches!(request.file_name.as_str(), "." | "..") + || request.file_name.contains(['/', '\\', '\0']) + { + return Err(WebError::bad("Invalid file name")); + } + let target_path = format!( + "{}/{}", + request.target.path.trim_end_matches('/'), + request.file_name + ); + validate_path(&target_path)?; + if same && request.source.path == target_path { + return Err(WebError::bad("Source and destination are identical")); + } + let _source_permit = source + .transfers + .clone() + .try_acquire_owned() + .map_err(|_| WebError::bad("Too many transfers"))?; + let _target_permit = if same { + None + } else { + Some( + target + .transfers + .clone() + .try_acquire_owned() + .map_err(|_| WebError::bad("Too many transfers"))?, + ) + }; + let source_sftp = Arc::new(open(&source).await?); + let target_sftp = if same { + source_sftp.clone() + } else { + Arc::new(open(&target).await?) + }; + let attrs = source_sftp.symlink_metadata(&request.source.path).await?; + let mut destination = target_path; + let mut overwrite = false; + if target_sftp.try_exists(&destination).await? { + let strategy = request.duplicate_strategy_override.clone().unwrap_or( + nyaterm_core::config::load_app_settings(&())? + .transfer + .duplicate_strategy, + ); + let strategy = if strategy == "ask" { + state.prompt(owner,"transfer-duplicate-request",json!({"sessionId":target.id,"remotePath":destination,"fileName":request.file_name,"isDirectory":attrs.is_dir()}),&target.cancel).await?.as_str().unwrap_or("skip").to_owned() + } else { + strategy + }; + match strategy.as_str() { + "skip" => return Ok(json!("skipped")), + "overwrite" if !attrs.is_dir() => { + overwrite = true; + } + "rename" => { + destination = format!("{destination}.{}", uuid::Uuid::new_v4()); + } + _ => { + return Err(WebError::bad( + "Destination exists; choose skip, rename or overwrite", + )); + } + } + } + let id = request + .transfer_id + .unwrap_or_else(|| uuid::Uuid::new_v4().to_string()); + let temporary = format!("{destination}.nyaterm-{}.part", uuid::Uuid::new_v4()); + let mut transfer = Transfer { + state: state.clone(), + owner: owner.into(), + session_id: target.id.clone(), + id, + path: destination.clone(), + direction: "copy", + total: attrs.size.unwrap_or(0), + bytes: 0, + sftp: target_sftp.clone(), + permits: std::iter::once(_source_permit) + .chain(_target_permit) + .collect(), + temporary: None, + done: false, + last_progress: std::time::Instant::now(), + }; + let operation = async { + transfer.progress("started").await; + if same && command == "move_file_entry" { + if overwrite { + source_sftp + .rename_replace(&request.source.path, &destination) + .await?; + } else { + source_sftp + .rename(&request.source.path, &destination) + .await?; + } + transfer.bytes = transfer.total; + } else { + if attrs.is_dir() { + return Err(WebError::bad("Recursive copies require a transfer task")); + } + transfer.temporary = Some(temporary.clone()); + if attrs.is_symlink() { + let link = source_sftp.read_link(&request.source.path).await?; + target_sftp.symlink_openssh(link, &temporary).await?; + } else { + if transfer.total > 1024 * 1024 * 1024 { + return Err(WebError::bad("Copy exceeds 1 GiB")); + } + let mut reader = source_sftp.open(&request.source.path).await?; + let mut writer = target_sftp.create(&temporary).await?; + let mut buffer = vec![0u8; 64 * 1024]; + loop { + let size = reader + .read(&mut buffer) + .await + .map_err(|error| WebError::io(&error, "Remote read failed"))?; + if size == 0 { + break; + } + transfer.bytes += size as u64; + if transfer.bytes > 1024 * 1024 * 1024 { + return Err(WebError::bad("Copy exceeds 1 GiB")); + } + writer + .write_all(&buffer[..size]) + .await + .map_err(|error| WebError::io(&error, "Remote write failed"))?; + transfer.progress("progress").await; + } + writer + .shutdown() + .await + .map_err(|error| WebError::io(&error, "Remote write failed"))?; + if transfer.bytes != transfer.total { + return Err(WebError::bad("Source changed during copy")); + } + apply_mode( + &target_sftp, + &temporary, + attrs.permissions.map(|v| v & 0o7777), + ) + .await?; + } + if overwrite { + target_sftp.rename_replace(&temporary, &destination).await?; + } else { + target_sftp.rename(&temporary, &destination).await?; + } + transfer.temporary = None; + if command == "move_file_entry" { + source_sftp.remove_file(&request.source.path).await?; + } + } + Ok::<_, WebError>(()) + }; + let result = tokio::select! {_=source.cancel.cancelled()=>Err(WebError::bad("Source session closed")),_=target.cancel.cancelled()=>Err(WebError::bad("Target session closed")),result=tokio::time::timeout(Duration::from_secs(3600),operation)=>result.unwrap_or(Err(WebError::bad("Copy timed out")))}; + if let Err(error) = result { + transfer.finish("error").await; + return Err(error); + } + transfer.finish("completed").await; + Ok(json!("copied")) +} +struct Transfer { + state: Arc, + owner: String, + session_id: String, + id: String, + path: String, + direction: &'static str, + total: u64, + bytes: u64, + sftp: Arc, + permits: Vec, + temporary: Option, + done: bool, + last_progress: std::time::Instant, +} +impl Transfer { + async fn progress(&mut self, status: &str) { + if status == "progress" && self.last_progress.elapsed() < Duration::from_millis(200) { + return; + } + self.last_progress = std::time::Instant::now(); + 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; + } +} +impl Drop for Transfer { + fn drop(&mut self) { + let sftp = self.sftp.clone(); + let permits = std::mem::take(&mut self.permits); + let temporary = self.temporary.take(); + let state = self.state.clone(); + let owner = self.owner.clone(); + let id = self.id.clone(); + let sid = self.session_id.clone(); + let done = self.done; + crate::observability::spawn(async move { + // Session shutdown waits for permits, including asynchronous cleanup. + let _permits = permits; + if let Some(path) = temporary { + let _ = tokio::time::timeout(Duration::from_secs(4), sftp.remove_file(path)).await; + } + // A stalled removal must still allow the SFTP close attempt. + let _ = tokio::time::timeout(Duration::from_secs(1), sftp.close()).await; + if !done { + state + .event( + &owner, + "transfer-event", + json!({"id":id,"session_id":sid,"status":"cancelled"}), + ) + .await; + } + }); + } +} +pub async fn download( + ExtractState(state): ExtractState>, + Extension(owner): Extension, + Path(id): Path, + Query(query): Query, +) -> Result { + validate_path(&query.path)?; + let session = state.session(&owner.0, &id).await?; + let permit = session + .transfers + .clone() + .try_acquire_owned() + .map_err(|_| WebError::bad("Too many transfers"))?; + let sftp = Arc::new(open(&session).await?); + let total = sftp.metadata(&query.path).await?.size.unwrap_or(0); + let file = sftp.open(&query.path).await?; + let mut transfer = Transfer { + state, + owner: owner.0, + session_id: id, + id: uuid::Uuid::new_v4().to_string(), + path: query.path.clone(), + direction: "download", + total, + bytes: 0, + sftp, + permits: vec![permit], + temporary: None, + done: false, + last_progress: std::time::Instant::now(), + }; + let cancel = session.cancel.clone(); + use futures_util::StreamExt; + let stream = async_stream::try_stream! { + let mut chunks=ReaderStream::with_capacity(file,64*1024);transfer.progress("started").await; + loop { + let next=tokio::select!{_ = cancel.cancelled()=>Some(Err(std::io::Error::other("Session closed"))),result=tokio::time::timeout(Duration::from_secs(30),chunks.next())=>result.unwrap_or(Some(Err(std::io::Error::other("SFTP download timed out"))))}; + match next { Some(Ok(bytes))=>{transfer.bytes+=bytes.len() as u64;transfer.progress("progress").await;yield bytes;},Some(Err(error))=>{transfer.finish("error").await;Err(error)?;},None=>break } + } + if transfer.bytes!=transfer.total {transfer.finish("error").await;Err(std::io::Error::other("Remote file changed during download"))?;} + transfer.finish("completed").await; + }; + let safe = + files::sanitize_download_file_name(query.path.rsplit('/').next().unwrap_or("download")) + .chars() + .map(|c| { + if c.is_ascii_alphanumeric() || ".-_%".contains(c) { + c + } else { + '_' + } + }) + .collect::(); + Ok(Response::builder() + .header(header::CONTENT_TYPE, "application/octet-stream") + .header(header::CONTENT_LENGTH, total) + .header( + header::CONTENT_DISPOSITION, + format!("attachment; filename=\"{safe}\""), + ) + .body(Body::from_stream( + stream.map(|r: std::io::Result| r), + )) + .unwrap()) +} +pub async fn upload( + ExtractState(state): ExtractState>, + Extension(owner): Extension, + Path(id): Path, + Query(query): Query, + request: axum::extract::Request, +) -> Result> { + use futures_util::StreamExt; + validate_path(&query.path)?; + let session = state.session(&owner.0, &id).await?; + let _permit = session + .transfers + .clone() + .try_acquire_owned() + .map_err(|_| WebError::bad("Too many transfers"))?; + let sftp = Arc::new(open(&session).await?); + // Own cleanup before metadata lookup or a potentially long conflict prompt. + let mut transfer = Transfer { + state, + owner: owner.0, + session_id: id, + id: uuid::Uuid::new_v4().to_string(), + path: query.path.clone(), + direction: "upload", + total: request + .headers() + .get(header::CONTENT_LENGTH) + .and_then(|v| v.to_str().ok()) + .and_then(|v| v.parse().ok()) + .unwrap_or(0), + bytes: 0, + sftp: sftp.clone(), + permits: vec![_permit], + temporary: None, + done: false, + last_progress: std::time::Instant::now(), + }; + let operation = async { + let mut overwrite = false; + match sftp.symlink_metadata(&transfer.path).await { + Ok(existing) => { + let strategy = nyaterm_core::config::load_app_settings(&())? + .transfer + .duplicate_strategy; + let strategy = if strategy == "ask" { + transfer + .state + .prompt( + &transfer.owner, + "transfer-duplicate-request", + json!({"sessionId":session.id,"remotePath":transfer.path, + "fileName":transfer.path.rsplit('/').next().unwrap_or("file"), + "isDirectory":existing.is_dir()}), + &session.cancel, + ) + .await? + .as_str() + .unwrap_or("skip") + .to_owned() + } else { + strategy + }; + match strategy.as_str() { + "skip" => { + transfer.finish("cancelled").await; + return Ok(json!({"bytes":0,"status":"skipped","path":transfer.path})); + } + "rename" => { + transfer.path = format!("{}.{}", transfer.path, uuid::Uuid::new_v4()); + validate_path(&transfer.path)?; + } + "overwrite" if !existing.is_dir() => overwrite = true, + "overwrite" => { + return Err(WebError::bad("Cannot overwrite a directory with a file")); + } + _ => return Err(WebError::bad("Invalid duplicate strategy")), + } + } + Err(russh_sftp::client::error::Error::Status(status)) + if status.status_code == russh_sftp::protocol::StatusCode::NoSuchFile => {} + Err(error) => return Err(error.into()), + } + let temporary = format!("{}.nyaterm-{}.part", transfer.path, transfer.id); + validate_path(&temporary)?; + transfer.temporary = Some(temporary.clone()); + let mut file = sftp.create(&temporary).await?; + let mut stream = request.into_body().into_data_stream(); + transfer.progress("started").await; + while let Some(chunk) = stream.next().await { + let chunk = chunk.map_err(|_| WebError::bad("Upload interrupted"))?; + transfer.bytes += chunk.len() as u64; + if transfer.bytes > 1024 * 1024 * 1024 { + return Err(WebError::bad("Upload exceeds 1 GiB")); + } + file.write_all(&chunk) + .await + .map_err(|error| WebError::io(&error, "SFTP write failed"))?; + transfer.progress("progress").await; + } + file.shutdown() + .await + .map_err(|error| WebError::io(&error, "SFTP write failed"))?; + if overwrite { + sftp.rename_replace(&temporary, &transfer.path) + .await + .map_err(|_| { + WebError::bad( + "Atomic upload replacement failed; no non-atomic fallback was attempted", + ) + })?; + } else { + sftp.rename(&temporary, &transfer.path).await?; + } + transfer.temporary = None; + transfer.total = transfer.bytes; + transfer.finish("completed").await; + Ok::<_, WebError>(json!({"bytes":transfer.bytes,"status":"completed","path":transfer.path})) + }; + let result = tokio::select! { + _ = session.cancel.cancelled() => Err(WebError::bad("Session closed")), + result = tokio::time::timeout(Duration::from_secs(3600), operation) => result.unwrap_or(Err(WebError::bad("Upload timed out"))), + }; + match result { + Ok(value) => Ok(Json(value)), + Err(error) => { + transfer.finish("error").await; + Err(error) + } + } +} diff --git a/src-tauri/crates/nyaterm-web/src/state.rs b/src-tauri/crates/nyaterm-web/src/state.rs new file mode 100644 index 00000000..98cd64fc --- /dev/null +++ b/src-tauri/crates/nyaterm-web/src/state.rs @@ -0,0 +1,152 @@ +use crate::{ + error::{Result, WebError}, + session::WebSession, +}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::{ + collections::HashMap, + sync::Arc, + time::{Duration, Instant}, +}; +use tokio::sync::{Mutex, broadcast, oneshot}; +use tokio_util::sync::CancellationToken; + +#[derive(Clone, Serialize, Deserialize)] +pub struct Event { + pub event: String, + pub payload: Value, +} +pub struct Login { + pub csrf: String, + pub expires: Instant, + pub events: broadcast::Sender, + pub cancel: CancellationToken, +} +pub struct Prompt { + pub owner: String, + pub reply: oneshot::Sender, +} +pub struct State { + pub origin: String, + pub host: String, + pub base_path: String, + pub secure_cookie: bool, + pub password_hash: [u8; 32], + pub logins: Mutex>>, + pub sessions: Mutex>>, + pub prompts: Mutex>, + pub ai_streams: Mutex>, + pub login_attempts: Mutex>, + pub mutation: Mutex<()>, + pub shutdown: CancellationToken, +} +impl State { + pub async fn login(&self, owner: &str) -> Result> { + self.logins + .lock() + .await + .get(owner) + .filter(|login| login.expires > Instant::now() && !login.cancel.is_cancelled()) + .cloned() + .ok_or(WebError( + axum::http::StatusCode::UNAUTHORIZED, + "Sign in required".into(), + )) + } + pub async fn event(&self, owner: &str, event: &str, payload: Value) { + if let Ok(login) = self.login(owner).await { + let _ = login.events.send(Event { + event: event.into(), + payload, + }); + } + } + pub async fn broadcast(&self, event: &str, payload: Value) { + for login in self.logins.lock().await.values() { + let _ = login.events.send(Event { + event: event.into(), + payload: payload.clone(), + }); + } + } + pub async fn prompt( + self: &Arc, + owner: &str, + event: &str, + mut payload: Value, + cancel: &CancellationToken, + ) -> Result { + let id = uuid::Uuid::new_v4().to_string(); + let (reply, rx) = oneshot::channel(); + payload["requestId"] = Value::String(id.clone()); + payload["targetWindowLabel"] = Value::Null; + self.prompts.lock().await.insert( + id.clone(), + Prompt { + owner: owner.into(), + reply, + }, + ); + // HTTP disconnects can drop this future before its normal cleanup. + let _cleanup = PromptCleanup { + state: self.clone(), + id: id.clone(), + }; + self.event(owner, event, payload).await; + let result = tokio::select! { + _ = cancel.cancelled() => Err(WebError::bad("Session creation cancelled")), + response = tokio::time::timeout(Duration::from_secs(90), rx) => response.ok().and_then(|r| r.ok()).ok_or(WebError::bad("Authentication prompt expired")), + }; + self.prompts.lock().await.remove(&id); + result + } + pub async fn session(&self, owner: &str, id: &str) -> Result> { + self.sessions + .lock() + .await + .get(id) + .filter(|s| s.owner == owner && !s.cancel.is_cancelled()) + .cloned() + .ok_or(WebError( + axum::http::StatusCode::NOT_FOUND, + "Session not found".into(), + )) + } + 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(); + } + for session in self + .sessions + .lock() + .await + .values() + .filter(|s| s.owner == owner) + { + session.cancel.cancel(); + } + self.prompts + .lock() + .await + .retain(|_, prompt| prompt.owner != owner); + } +} + +struct PromptCleanup { + state: Arc, + id: String, +} +impl Drop for PromptCleanup { + fn drop(&mut self) { + let state = self.state.clone(); + let id = self.id.clone(); + crate::observability::spawn(async move { + state.prompts.lock().await.remove(&id); + }); + } +} diff --git a/src-tauri/crates/nyaterm-web/src/suggestions.rs b/src-tauri/crates/nyaterm-web/src/suggestions.rs new file mode 100644 index 00000000..d19d7371 --- /dev/null +++ b/src-tauri/crates/nyaterm-web/src/suggestions.rs @@ -0,0 +1,126 @@ +//! Persistent command suggestions. Web has no shell confirmation protocol, so +//! only explicit client submissions enter history, never terminal output. +use crate::{ + commands::{argument, text}, + error::Result, + state::State, +}; +use nyaterm_core::{config, core::history::CommandHistoryStore, utils::fuzzy}; +use serde_json::{Value, json}; + +pub fn supports(command: &str) -> bool { + if command == "import_quick_commands" { + return true; + } + matches!( + command, + "get_quick_commands" + | "save_quick_commands" + | "upsert_quick_command" + | "increment_quick_command_use_count" + | "fuzzy_search_commands" + | "fuzzy_search_candidates" + | "get_command_history" + | "fuzzy_search_history" + | "delete_command_history" + | "register_command_submission" + | "register_command_confirmation_candidate" + ) +} +fn limit(args: &Value) -> usize { + args["limit"].as_u64().unwrap_or(8).min(200) as usize +} +pub async fn command(state: &State, owner: &str, command: &str, args: &Value) -> Result { + if matches!( + command, + "register_command_submission" | "register_command_confirmation_candidate" + ) { + state.session(owner, text(args, "sessionId")?).await?; + if command == "register_command_confirmation_candidate" { + return Ok(json!(false)); + } + } + let _guard = state.mutation.lock().await; + Ok(match command { + "import_quick_commands" => { + let store = nyaterm_core::core::quick_commands::QuickCommandsStore::new(); + store.load_from_disk(&())?; + let content = text(args, "content")?; + if content.len() > 1024 * 1024 { + return Err(crate::error::WebError::bad("Import exceeds 1 MiB")); + } + let result = store.import_from_text(&(), content, argument(args, "source")?)?; + state.broadcast("quick-commands-changed", Value::Null).await; + json!(result) + } + "get_quick_commands" => json!(config::load_quick_commands(&())?), + "fuzzy_search_commands" => { + let cfg = config::load_quick_commands(&())?; + let items = cfg + .commands + .iter() + .map(|c| (c.label.as_str(), c.command.as_str())) + .collect::>(); + json!(fuzzy::fuzzy_search_items( + &items, + text(args, "pattern")?, + "quickCommand", + limit(args), + None, + None + )) + } + "fuzzy_search_candidates" => { + let items: Vec = argument(args, "items")?; + json!(fuzzy::fuzzy_search_candidates( + &items, + text(args, "pattern")?, + limit(args) + )) + } + "save_quick_commands" | "upsert_quick_command" | "increment_quick_command_use_count" => { + let store = nyaterm_core::core::quick_commands::QuickCommandsStore::new(); + store.load_from_disk(&())?; + match command { + "save_quick_commands" => store.save_all(&(), argument(args, "config")?)?, + "upsert_quick_command" => { + store.upsert( + &(), + argument(args, "command")?, + argument(args, "newCategory")?, + )?; + } + _ => store.increment_use_count(&(), text(args, "id")?)?, + } + state.broadcast("quick-commands-changed", Value::Null).await; + Value::Null + } + _ => { + let mut history = CommandHistoryStore::new(); + history.load()?; + match command { + "get_command_history" => json!(history.list()), + "fuzzy_search_history" => json!(history.search( + text(args, "pattern")?, + limit(args), + args["minCommandLength"].as_u64().map(|v| v as usize), + args["maxCommandLength"].as_u64().map(|v| v as usize) + )), + _ => { + let changed = if command == "delete_command_history" { + history.delete_command(text(args, "command")?) + } else { + history.add(text(args, "command")?.into()) + }; + if changed { + history.save()?; + state + .broadcast("command-history-changed", Value::Null) + .await; + } + Value::Null + } + } + } + }) +} diff --git a/src-tauri/crates/nyaterm-web/src/telnet.rs b/src-tauri/crates/nyaterm-web/src/telnet.rs new file mode 100644 index 00000000..0041e978 --- /dev/null +++ b/src-tauri/crates/nyaterm-web/src/telnet.rs @@ -0,0 +1,257 @@ +use crate::{ + error::{Result, WebError}, + session::{self, SessionMetadata, SessionProtocol, WebSession}, + state::State, +}; +use nyaterm_core::{ + config, + ssh::terminal::{Command, Output}, + telnet::*, + terminal_encoding::*, +}; +use serde_json::Value; +use std::{ + sync::{Arc, atomic::Ordering}, + time::Instant, +}; +use tokio::{ + io::{AsyncReadExt, AsyncWriteExt}, + sync::mpsc, +}; + +pub async fn create(state: Arc, owner: &str, args: Value) -> Result { + let (conn, saved) = session::connection(&args, "telnet")?; + let config::ConnectionType::Telnet { + host, + port, + username, + backspace_mode, + raw_tcp_cli, + enter_mode, + local_echo, + local_line_edit, + force_character_at_a_time, + send_naws, + send_sga, + auto_login, + encoding, + .. + } = conn.config + else { + return Err(WebError::bad("Connection is not Telnet")); + }; + let account = conn + .auth + .as_ref() + .map(|a| config::load_saved_account(&(), a.account_id.as_deref(), a.password_id.as_deref())) + .transpose()? + .flatten(); + let username = config::resolve_account_username(account.as_ref(), &username); + let password = match conn.auth.as_ref() { + Some(a) if a.mode == "password" => { + if saved { + match &a.password { + Some(secret) => Some(nyaterm_core::utils::crypto::decrypt(secret)?), + None if a.password_source.as_deref() != Some("connection") => { + config::decrypt_account_password(account.as_ref())? + } + _ => None, + } + } else { + match &a.password { + Some(password) => Some(password.clone()), + None => config::decrypt_account_password(account.as_ref())?, + } + } + } + _ => None, + }; + let encoding = if encoding.is_empty() || encoding.eq_ignore_ascii_case("global") { + config::load_app_settings(&())?.interaction.default_encoding + } else { + encoding + }; + let config = TelnetSessionConfig { + network: conn.network.clone(), + host: host.clone(), + port, + name: conn.name.clone(), + username: username.clone(), + password, + backspace_mode, + raw_tcp_cli, + enter_mode: TelnetEnterMode::from_config_value(&enter_mode), + local_echo, + local_line_edit, + force_character_at_a_time, + send_naws, + send_sga, + auto_login: auto_login.into(), + encoding, + }; + let cancel = state.login(owner).await?.cancel.child_token(); + let (session, commands, sender) = session::register( + &state, + owner, + &args, + SessionProtocol::Telnet, + SessionMetadata { + host, + port, + username, + name: conn.name, + connection_id: saved.then_some(conn.id), + sftp: None, + }, + cancel, + ) + .await?; + let id = session.id.clone(); + let startup = args["startupCommand"].clone(); + crate::observability::spawn(async move { + let result = async { + let transport = crate::network::open( + state.clone(), + session.owner.clone(), + session.cancel.clone(), + session.host.clone(), + session.port, + conn.network, + ) + .await?; + session.ready.store(true, Ordering::Release); + tracing::info!(event="session.connected", session_id=%session.id, operation="telnet", "Telnet session connected"); + if let Some(id) = &session.connection_id { + nyaterm_core::storage::mark_connection_used(id)?; + } + run( + transport.stream, + config, + commands, + sender.clone(), + &session, + startup, + ) + .await + } + .await; + let end = if let Err(e) = result { + tracing::warn!(event="session.connect_failed", session_id=%session.id, operation="telnet", reason=crate::observability::error_reason(&e.1), "Telnet session failed"); + state + .event( + &session.owner, + &format!("connection-error-{}", session.id), + serde_json::json!({"sessionId":session.id,"error":e.1}), + ) + .await; + Output::Failure(e.1) + } else { + Output::Closed + }; + let _ = tokio::time::timeout(std::time::Duration::from_secs(2), sender.send(end)).await; + session::finish(&state, &session).await; + }); + Ok(id) +} +async fn run( + mut stream: nyaterm_core::network::BoxedTransportStream, + config: TelnetSessionConfig, + mut input: mpsc::Receiver, + output: mpsc::Sender, + session: &Arc, + startup: Value, +) -> Result<()> { + let mut decoder = TelnetDecoder::default(); + let mut text_decoder = TerminalOutputDecoder::new(&config.encoding); + let mut editor = TelnetLineEditor::default(); + let mut auto_login = TelnetAutoLogin::new( + config.auto_login.clone(), + TelnetAutoLoginCredentials { + username: config.username.clone(), + password: config.password.clone(), + }, + config.enter_mode, + Instant::now(), + ); + let mut buf = [0u8; 16 * 1024]; + let mut startup_input = startup["command"] + .as_str() + .filter(|s| !s.trim().is_empty()) + .map(|command| normalize_enter_bytes(format!("{command}\r").as_bytes(), config.enter_mode)); + let startup_delay = std::time::Duration::from_millis( + startup["delayMs"] + .as_u64() + .or(startup["delay_ms"].as_u64()) + .unwrap_or(0) + .min(300_000), + ); + let mut startup_deadline = if auto_login.is_none() && startup_input.is_some() { + Some(tokio::time::Instant::now() + startup_delay) + } else { + None + }; + let mut login_tick = tokio::time::interval(std::time::Duration::from_millis(250)); + let transfer = async { + loop { + tokio::select! { + _ = async { tokio::time::sleep_until(startup_deadline.unwrap()).await }, if startup_deadline.is_some() => { + startup_deadline = None; + if let Some(data) = startup_input.take() { + stream.write_all(&escape_telnet_application_data(&encode_terminal_input(&data, &config.encoding), config.raw_tcp_cli)).await.map_err(|_| WebError::bad("Telnet startup write failed"))?; + } + }, + _ = login_tick.tick() => { + if let Some(login) = &mut auto_login { + if login.tick(Instant::now()).is_some() { startup_input = None; startup_deadline = None; } + } + }, + read = stream.read(&mut buf) => { + let n = read.map_err(|_| WebError::bad("Telnet read failed"))?; + if n == 0 { break; } + let mut replies = Vec::new(); + let visible = if config.raw_tcp_cli { buf[..n].to_vec() } else { decoder.decode(&buf[..n], &mut |command, option| { + replies.extend(negotiate_response(command, option, config.send_naws, config.send_sga)); + }) }; + if !replies.is_empty() { stream.write_all(&replies).await.map_err(|_| WebError::bad("Telnet negotiation failed"))?; } + let text = text_decoder.decode(&visible); + if let Some(login) = &mut auto_login { + for action in login.handle_text(&text, Instant::now()) { + match action { + TelnetAutoLoginAction::Send(bytes) => stream.write_all(&escape_telnet_application_data(&encode_terminal_input(&bytes, &config.encoding), config.raw_tcp_cli)).await.map_err(|_| WebError::bad("Telnet login failed"))?, + TelnetAutoLoginAction::Complete => if startup_input.is_some() { startup_deadline = Some(tokio::time::Instant::now() + startup_delay); }, + TelnetAutoLoginAction::Disable => { startup_input = None; startup_deadline = None; }, + } + } + } + if !text.is_empty() { output.send(Output::Data(text.into_bytes())).await.map_err(|_| WebError::bad("Terminal detached"))?; } + }, + command = input.recv() => match command { + Some(Command::Input(mut bytes)) => { + if let Some(login) = &mut auto_login { + if login.handle_user_input(false).is_some() { startup_input = None; startup_deadline = None; } + } + let chunks = if config.raw_tcp_cli && config.local_line_edit { + let edited = editor.process(&bytes, config.enter_mode); + if !edited.display.is_empty() { output.send(Output::Data(edited.display.into_bytes())).await.map_err(|_| WebError::bad("Terminal detached"))?; } + edited.writes + } else { + if config.backspace_mode == "ctrl_h" { for byte in &mut bytes { if *byte == 0x7f { *byte = 8; } } } + let data = normalize_enter_bytes(&bytes, config.enter_mode); + if config.local_echo { let text = local_echo_text(&data); if !text.is_empty() { output.send(Output::Data(text.into_bytes())).await.map_err(|_| WebError::bad("Terminal detached"))?; } } + split_write_chunks(&data, config.force_character_at_a_time) + }; + for chunk in chunks { stream.write_all(&escape_telnet_application_data(&encode_terminal_input(&chunk, &config.encoding), config.raw_tcp_cli)).await.map_err(|_| WebError::bad("Telnet write failed"))?; } + }, + Some(Command::RawInput(bytes)) => { + if let Some(login) = &mut auto_login { if login.handle_user_input(false).is_some() { startup_input = None; startup_deadline = None; } } + stream.write_all(&escape_telnet_application_data(&bytes, config.raw_tcp_cli)).await.map_err(|_| WebError::bad("Telnet write failed"))?; + }, + Some(Command::Resize(cols, rows)) => if let Some(bytes) = maybe_build_naws(cols as u16, rows as u16, &config) { stream.write_all(&bytes).await.map_err(|_| WebError::bad("Telnet resize failed"))?; }, + Some(Command::Close) | None => break, + } + } + } + Ok(()) + }; + tokio::select! { _ = session.cancel.cancelled() => Ok(()), result = transfer => result } +} diff --git a/src-tauri/crates/nyaterm-web/src/vnc.rs b/src-tauri/crates/nyaterm-web/src/vnc.rs new file mode 100644 index 00000000..9439d390 --- /dev/null +++ b/src-tauri/crates/nyaterm-web/src/vnc.rs @@ -0,0 +1,351 @@ +use crate::{ + auth::Owner, + error::{Result, WebError}, + session::{self, SessionMetadata, SessionProtocol}, + state::State, +}; +use axum::{ + Extension, + extract::{ + Path, State as ExtractState, WebSocketUpgrade, + ws::{Message, WebSocket}, + }, + response::Response, +}; +use nyaterm_core::{ + error::AppError, + vnc::{ + self, VncInputEvent, VncSessionManager, + runtime::{FrameChannel, VncContext}, + }, +}; +use serde_json::{Value, json}; +use std::{ + collections::HashSet, + sync::{ + Arc, Mutex as StdMutex, + atomic::{AtomicBool, Ordering}, + }, + time::{Duration, Instant}, +}; +use tokio::sync::{broadcast, mpsc}; +use tracing::Instrument; + +pub struct WebVnc { + pub manager: Arc, + pub context: VncContext, + pub pending_keys: Arc>>, + pub state: Arc>, + events: broadcast::Sender<(String, Value)>, +} +pub async fn create(state: Arc, owner: &str, args: Value) -> Result { + let (mut conn, saved) = session::connection(&args, "vnc")?; + nyaterm_core::services::validate_vnc_config(&conn)?; + let login = state.login(owner).await?; + let cancel = login.cancel.child_token(); + // Only persisted secrets need decryption. Encrypt transient input through the same path. + if !saved { + if let Some(auth) = &mut conn.auth { + if let Some(password) = &auth.password { + auth.password = Some(nyaterm_core::utils::crypto::encrypt(password)?); + } + } + } + let mut config = vnc::config_from_connection(&(), conn, "main".into())?; + let pending = Arc::new(StdMutex::new(HashSet::new())); + let latest_state = Arc::new(StdMutex::new("connecting".into())); + let (events, _) = broadcast::channel(64); + let event_state = state.clone(); + let event_owner = owner.to_owned(); + let event_pending = pending.clone(); + let event_sender = events.clone(); + let event_latest_state = latest_state.clone(); + let transport_state = state.clone(); + let transport_owner = owner.to_owned(); + let transport_cancel = cancel.clone(); + let event_span = tracing::Span::current(); + let transport_span = tracing::Span::current(); + let context = VncContext { + events: Arc::new(move |_, event, payload| { + let _entered = event_span.enter(); + if event.starts_with("vnc-state-") { + if let Some(state) = payload["state"].as_str() { + *event_latest_state.lock().unwrap() = state.into(); + tracing::info!( + event = "vnc.state_changed", + session_id = event.trim_start_matches("vnc-state-"), + reason = state, + "VNC state changed" + ); + } + } + if let Some(id) = payload["requestId"].as_str() { + let mut pending = event_pending.lock().unwrap(); + if event == "vnc-server-key-verify" { + pending.insert(id.into()); + } else if event == "vnc-server-key-verify-resolved" { + pending.remove(id); + } + } + if event.starts_with("vnc-state-") || event.starts_with("vnc-clipboard-") { + let _ = event_sender.send((event.to_owned(), payload)); + return Ok(()); + } + let state = event_state.clone(); + let owner = event_owner.clone(); + let event = event.to_owned(); + crate::observability::spawn(async move { + state.event(&owner, &event, payload).await; + }); + Ok(()) + }), + transport: Arc::new(move |host, port, network, _| { + let state = transport_state.clone(); + let owner = transport_owner.clone(); + let cancel = transport_cancel.clone(); + let span = transport_span.clone(); + Box::pin( + async move { + crate::network::open(state, owner, cancel, host, port, network) + .await + .map_err(|e| AppError::Channel(e.1)) + } + .instrument(span), + ) + }), + }; + let web = Arc::new(WebVnc { + manager: Arc::new(VncSessionManager::new()), + context, + pending_keys: pending, + state: latest_state, + events, + }); + let metadata = SessionMetadata { + host: config.host.clone(), + port: config.port, + username: config.username.clone(), + name: config.name.clone(), + connection_id: saved.then_some(config.connection_id.clone()), + sftp: None, + }; + let (session, _, _) = session::register( + &state, + owner, + &args, + SessionProtocol::Vnc(web.clone()), + metadata, + cancel, + ) + .await?; + config.session_id = session.id.clone(); + if let Err(e) = web + .manager + .create_session(web.context.clone(), config) + .await + { + state.sessions.lock().await.remove(&session.id); + return Err(e.into()); + } + session.ready.store(true, Ordering::Release); + if let Some(id) = &session.connection_id { + nyaterm_core::storage::mark_connection_used(id)?; + } + let id = session.id.clone(); + crate::observability::spawn(async move { + session.cancel.cancelled().await; + let _ = web.manager.close(&web.context, &session.id).await; + session::finish(&state, &session).await; + }); + Ok(id) +} +pub async fn command( + state: &Arc, + owner: &str, + command: &str, + args: &Value, +) -> Result { + if command == "respond_vnc_server_key" { + let id = args["requestId"] + .as_str() + .ok_or(WebError::bad("Missing key request id"))?; + let sessions = state + .sessions + .lock() + .await + .values() + .filter(|s| s.owner == owner) + .cloned() + .collect::>(); + for session in sessions { + if let SessionProtocol::Vnc(web) = &session.protocol { + if web.pending_keys.lock().unwrap().contains(id) { + web.manager + .respond_server_key( + id, + args["accepted"] + .as_bool() + .ok_or(WebError::bad("Missing key response"))?, + ) + .await?; + return Ok(Value::Null); + } + } + } + return Err(WebError::forbidden()); + } + let session = state + .session( + owner, + args["sessionId"] + .as_str() + .ok_or(WebError::bad("Missing session id"))?, + ) + .await?; + let SessionProtocol::Vnc(web) = &session.protocol else { + return Err(WebError::bad("Not a VNC session")); + }; + match command { + "vnc_input_batch" => { + web.manager + .send_input(&session.id, serde_json::from_value(args["events"].clone())?) + .await? + } + "vnc_set_clipboard_text" => { + web.manager + .set_clipboard_text( + &session.id, + args["text"] + .as_str() + .ok_or(WebError::bad("Missing clipboard text"))? + .into(), + ) + .await? + } + "vnc_reconnect" => { + web.manager + .reconnect(web.context.clone(), &session.id) + .await? + } + "close_vnc_session" => session.cancel.cancel(), + _ => return Err(WebError::unsupported()), + } + Ok(Value::Null) +} +pub async fn ws_route( + ExtractState(state): ExtractState>, + Extension(owner): Extension, + Path(id): Path, + upgrade: WebSocketUpgrade, +) -> Result { + let session = state.session(&owner.0, &id).await?; + if !matches!(session.protocol, SessionProtocol::Vnc(_)) { + return Err(WebError::bad("Not a VNC session")); + } + if session + .attached + .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) + .is_err() + { + return Err(WebError( + axum::http::StatusCode::CONFLICT, + "VNC already attached".into(), + )); + } + Ok(upgrade + .max_message_size(2 * 1024 * 1024) + .max_frame_size(2 * 1024 * 1024) + .max_write_buffer_size(140 * 1024 * 1024) + .on_failed_upgrade({ + let session = session.clone(); + move |_| { + session.attached.store(false, Ordering::Release); + } + }) + .on_upgrade({ + let span = tracing::Span::current(); + let guard = crate::observability::StreamLogGuard::new( + "vnc.disconnected", + Some(session.id.clone()), + ); + move |socket| { + async move { + let _guard = guard; + tracing::info!(event="vnc.attached", session_id=%session.id, "Web stream attached"); + socket_loop(session, socket).await; + }.instrument(span) + } + })) +} +async fn socket_loop(session: Arc, mut socket: WebSocket) { + let SessionProtocol::Vnc(web) = &session.protocol else { + return; + }; + let (tx, mut frames) = mpsc::channel(2); + let overflow = Arc::new(AtomicBool::new(false)); + let overflow_sink = overflow.clone(); + let sink = FrameChannel(Arc::new(move |frame| { + tx.try_send(frame).map_err(|_| { + overflow_sink.store(true, Ordering::Release); + AppError::Channel("VNC consumer needs a full refresh".into()) + }) + })); + let mut events = web.events.subscribe(); + let _ = web + .manager + .attach_frame_channel(&web.context, &session.id, sink.clone()) + .await; + let mut heartbeat = tokio::time::interval(Duration::from_secs(15)); + let mut refresh = tokio::time::interval(Duration::from_millis(32)); + let mut last_peer = Instant::now(); + loop { + let message = tokio::select! { + _ = session.cancel.cancelled() => break, + _ = refresh.tick() => { + if overflow.swap(false, Ordering::AcqRel) { + while frames.try_recv().is_ok() {} + let _ = web.manager.attach_frame_channel(&web.context, &session.id, sink.clone()).await; + } + continue; + }, + frame = frames.recv() => match frame { Some(frame) => Message::Binary(frame.into()), None => break }, + event = events.recv() => match event { + Ok((event, payload)) => Message::Text(json!({"type":"event","event":event,"payload":payload}).to_string().into()), + Err(broadcast::error::RecvError::Lagged(_)) => { let _ = web.manager.attach_frame_channel(&web.context, &session.id, sink.clone()).await; continue; }, + Err(_) => break, + }, + next = socket.recv() => { + match next { + Some(Ok(Message::Text(text))) => { + last_peer = Instant::now(); + let result = async { + let value: Value = serde_json::from_str(&text)?; + match value["type"].as_str() { + Some("input") => web.manager.send_input(&session.id, serde_json::from_value::>(value["events"].clone())?).await?, + Some("clipboard") => web.manager.set_clipboard_text(&session.id, value["text"].as_str().ok_or(WebError::bad("Missing clipboard"))?.into()).await?, + Some("close") => session.cancel.cancel(), + _ => return Err(WebError::bad("Invalid VNC message")), + } + Ok::<(), WebError>(()) + }.await; + if let Err(e) = result { Message::Text(json!({"type":"error","error":e.1}).to_string().into()) } else { continue; } + }, + Some(Ok(Message::Ping(data))) => { last_peer = Instant::now(); Message::Pong(data) }, + Some(Ok(Message::Pong(_))) => { last_peer = Instant::now(); continue; }, + _ => break, + } + }, + _ = heartbeat.tick() => { if last_peer.elapsed() > Duration::from_secs(45) { break; } Message::Ping(Vec::new().into()) }, + }; + if !matches!( + tokio::time::timeout(Duration::from_secs(5), socket.send(message)).await, + Ok(Ok(())) + ) { + break; + } + } + let _ = web.manager.detach_frame_channel(&session.id).await; + *session.detached_at.lock().unwrap() = Instant::now(); + session.attached.store(false, Ordering::Release); + let _ = socket.send(Message::Close(None)).await; +} 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..023ccc41 --- /dev/null +++ b/src-tauri/crates/nyaterm-web/tests/backups.rs @@ -0,0 +1,550 @@ +//! 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"; +const LEGACY_SHORT_PASSWORD: &str = "test"; +const LEGACY_LONG_PASSWORD: &str = "legacy-inline-password-with-symbols!"; +const AI_SECRET: &str = "integration-ai-proxy-password-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_connection_password_value", + "legacy-short", + LEGACY_SHORT_PASSWORD, + ), + ( + "get_connection_password_value", + "legacy-long", + LEGACY_LONG_PASSWORD, + ), + ("get_otp_secret_value", "otp", "JBSWY3DPEHPK3PXP"), + ] { + assert_eq!( + self.command(command, json!({"id":id})).await, + json!(expected) + ); + } + assert_eq!( + self.command( + "get_connection_password_value", + json!({"id":"legacy-empty"}) + ) + .await, + Value::Null + ); + } +} + +#[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())); + let mut settings = config::AppSettings::default(); + settings.ai.proxy.password = Some(crypto::encrypt(AI_SECRET).unwrap()); + config::save_app_settings(&(), &settings).unwrap(); + 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":"legacy-short","name":"legacy short","type":"ssh","host":"private.example","auth":{"mode":"password","password":LEGACY_SHORT_PASSWORD}}, + {"id":"legacy-long","name":"legacy long","type":"ssh","host":"private.example","auth":{"mode":"password","password":LEGACY_LONG_PASSWORD}}, + {"id":"legacy-empty","name":"legacy empty","type":"ssh","host":"private.example","auth":{"mode":"password"}}, + {"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 mut desktop_snapshot = backup::build_backup("1.2.12").unwrap(); + // Desktop's codec can preserve Some("") even though storage normally clears it. + desktop_snapshot + .sessions + .connections + .iter_mut() + .find(|connection| connection.id == "legacy-empty") + .unwrap() + .auth + .as_mut() + .unwrap() + .password = Some(String::new()); + desktop_snapshot.payload_hash = calculate_payload_hash(&desktop_snapshot).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(); + assert_eq!( + decoded.settings.ai.proxy.password.as_deref(), + Some(AI_SECRET) + ); + 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; + // Authenticated archives must still reject tampered inner encrypted passwords + // and plaintext in credential types outside the inline-password compatibility path. + for plaintext_key in [false, true] { + let mut invalid = desktop_snapshot.clone(); + if plaintext_key { + invalid.keys.keys[0].key = Some("invalid-plaintext-private-key".into()); + } else { + let token = invalid + .sessions + .connections + .iter_mut() + .find(|connection| connection.id == "connection") + .unwrap() + .auth + .as_mut() + .unwrap() + .password + .as_mut() + .unwrap(); + let mut bytes = STANDARD.decode(&*token).unwrap(); + bytes[12] ^= 1; + *token = STANDARD.encode(bytes); + } + invalid.payload_hash = calculate_payload_hash(&invalid).unwrap(); + let bytes = backup_crypto::encrypt_snapshot_bytes( + &encode_portable_snapshot(&invalid).unwrap(), + DESKTOP_PASSWORD, + ) + .unwrap(); + assert_eq!(target.import(&bytes, DESKTOP_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, + LEGACY_LONG_PASSWORD, + AI_SECRET, + "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/crates/nyaterm-web/tests/bootstrap.rs b/src-tauri/crates/nyaterm-web/tests/bootstrap.rs new file mode 100644 index 00000000..be2044ed --- /dev/null +++ b/src-tauri/crates/nyaterm-web/tests/bootstrap.rs @@ -0,0 +1,116 @@ +use base64::{Engine, engine::general_purpose::STANDARD}; +use serde_json::json; +use std::{ + path::Path, + process::{Child, Command, Stdio}, + time::Duration, +}; +struct Process(Child); +impl Drop for Process { + fn drop(&mut self) { + let _ = self.0.kill(); + let _ = self.0.wait(); + } +} +fn command(data: &Path, dist: &Path) -> Command { + let mut cmd = Command::new(env!("CARGO_BIN_EXE_nyaterm-web")); + cmd.env_remove("NYATERM_WEB_PASSWORD") + .env_remove("NYATERM_WEB_PASSWORD_FILE") + .env_remove("NYATERM_WEB_ENCRYPTION_KEY") + .env_remove("NYATERM_WEB_ENCRYPTION_KEY_FILE") + .env("NYATERM_WEB_DATA_DIR", data) + .env("NYATERM_WEB_DIST", dist) + .stdout(Stdio::null()) + .stderr(Stdio::piped()); + cmd +} +#[tokio::test] +async fn bootstrap_uses_external_secret_files_and_rejects_wrong_key() { + let temporary = tempfile::tempdir().unwrap(); + let data = temporary.path().join("data"); + let dist = temporary.path().join("dist"); + std::fs::create_dir_all(&dist).unwrap(); + std::fs::write(dist.join("index.html"), "NyaTerm Web").unwrap(); + let password = "test-login-secret-at-least-32-characters"; + let key = STANDARD.encode([19u8; 32]); + let password_file = temporary.path().join("password"); + let key_file = temporary.path().join("encryption"); + std::fs::write(&password_file, password).unwrap(); + std::fs::write(&key_file, &key).unwrap(); + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let host = listener.local_addr().unwrap(); + drop(listener); + let origin = format!("http://{host}"); + let mut process = Process( + command(&data, &dist) + .env("NYATERM_WEB_PASSWORD_FILE", &password_file) + .env("NYATERM_WEB_ENCRYPTION_KEY_FILE", &key_file) + .env("NYATERM_WEB_PUBLIC_URL", format!("{origin}/terminal/")) + .env("NYATERM_WEB_BIND", host.to_string()) + .spawn() + .unwrap(), + ); + let client = reqwest::Client::builder().no_proxy().build().unwrap(); + let mut ready = false; + for _ in 0..100 { + if let Ok(response) = client.get(format!("{origin}/terminal/")).send().await { + let status = response.status(); + let body = response.text().await.unwrap(); + assert_eq!(status, 200, "Unexpected static response: {body}"); + assert!(body.contains("NyaTerm")); + ready = true; + break; + } + if let Some(status) = process.0.try_wait().unwrap() { + panic!("Server exited during startup: {status}"); + } + tokio::time::sleep(Duration::from_millis(50)).await; + } + assert!(ready, "Server did not start"); + let response = client + .post(format!("{origin}/terminal/api/auth/login")) + .header("origin", &origin) + .header("x-nyaterm-request", "1") + .json(&json!({"password":password})) + .send() + .await + .unwrap(); + assert_eq!(response.status(), 200); + let cookie = response.headers()["set-cookie"] + .to_str() + .unwrap() + .split(';') + .next() + .unwrap() + .to_owned(); + let csrf = response.json::().await.unwrap()["csrf"] + .as_str() + .unwrap() + .to_owned(); + assert_eq!( + client + .post(format!("{origin}/terminal/api/commands/list_sessions")) + .header("origin", &origin) + .header("cookie", cookie) + .header("x-nyaterm-csrf", csrf) + .json(&json!({})) + .send() + .await + .unwrap() + .status(), + 200 + ); + process.0.kill().unwrap(); + process.0.wait().unwrap(); + std::fs::write(&key_file, STANDARD.encode([20u8; 32])).unwrap(); + let output = command(&data, &dist) + .env("NYATERM_WEB_PASSWORD_FILE", &password_file) + .env("NYATERM_WEB_ENCRYPTION_KEY_FILE", &key_file) + .env("NYATERM_WEB_PUBLIC_URL", &origin) + .output() + .unwrap(); + assert!(!output.status.success()); + let logs = String::from_utf8_lossy(&output.stderr); + assert!(!logs.contains(password)); + assert!(!logs.contains(&key)); +} diff --git a/src-tauri/crates/nyaterm-web/tests/commands.rs b/src-tauri/crates/nyaterm-web/tests/commands.rs new file mode 100644 index 00000000..6c92e902 --- /dev/null +++ b/src-tauri/crates/nyaterm-web/tests/commands.rs @@ -0,0 +1,329 @@ +use axum::{ + Router, + body::{Body, to_bytes}, + http::{Request, StatusCode}, +}; +use nyaterm_core::{config, storage, utils::crypto}; +use nyaterm_web::{ + auth, + state::{Login, State}, +}; +use serde_json::{Value, json}; +use std::{ + collections::HashMap, + sync::Arc, + time::{Duration, Instant}, +}; +use tokio::sync::{Mutex, broadcast}; +use tokio_util::sync::CancellationToken; +use tower::ServiceExt; + +const PASSWORD: &str = "web-password-at-least-32-characters"; +async fn call(app: &Router, command: &str, args: Value) -> (StatusCode, Value) { + let request = Request::builder() + .method("POST") + .uri(format!("/api/commands/{command}")) + .header("host", "localhost:8080") + .header("origin", "http://localhost:8080") + .header("cookie", format!("{}={}", auth::COOKIE, "o".repeat(43))) + .header("x-nyaterm-csrf", "csrf") + .header("content-type", "application/json") + .body(Body::from(args.to_string())) + .unwrap(); + let response = app.clone().oneshot(request).await.unwrap(); + let status = response.status(); + let bytes = to_bytes(response.into_body(), 8 * 1024 * 1024) + .await + .unwrap(); + (status, serde_json::from_slice(&bytes).unwrap()) +} +async fn ok(app: &Router, command: &str, args: Value) -> Value { + let (status, value) = call(app, command, args).await; + assert_eq!(status, StatusCode::OK, "{command}: {value}"); + value +} +#[tokio::test] +async fn persistent_web_commands_preserve_contracts_and_verify_real_passwords() { + let data = tempfile::tempdir().unwrap(); + storage::init(data.path()).unwrap(); + crypto::set_server_key_material([41; 32]); + let (events, _) = broadcast::channel(256); + let cancel = CancellationToken::new(); + let login = Arc::new(Login { + csrf: "csrf".into(), + expires: Instant::now() + Duration::from_secs(3600), + events, + cancel: cancel.child_token(), + }); + let state = Arc::new(State { + origin: "http://localhost:8080".into(), + host: "localhost:8080".into(), + base_path: String::new(), + secure_cookie: false, + password_hash: auth::digest(PASSWORD), + logins: Mutex::new(HashMap::from([("o".repeat(43), login)])), + sessions: Mutex::new(HashMap::new()), + prompts: Mutex::new(HashMap::new()), + ai_streams: Mutex::new(HashMap::new()), + login_attempts: Mutex::new(Vec::new()), + mutation: Mutex::new(()), + shutdown: cancel, + }); + let mut events = state + .login(&"o".repeat(43)) + .await + .unwrap() + .events + .subscribe(); + let app = nyaterm_web::router(state.clone(), data.path().into()); + assert_eq!( + ok(&app, "get_quick_commands", json!({})).await, + json!({"commands":[],"categories":[]}) + ); + ok(&app,"upsert_quick_command",json!({"command":{"id":"q1","label":"List files","command":"ls -la","category_id":"tools"},"newCategory":{"id":"tools","name":"Tools"}})).await; + assert_eq!(events.recv().await.unwrap().event, "quick-commands-changed"); + ok( + &app, + "increment_quick_command_use_count", + json!({"id":"q1"}), + ) + .await; + let quick = ok(&app, "get_quick_commands", json!({})).await; + assert_eq!(quick["commands"][0]["use_count"], 1); + assert_eq!(quick["categories"][0]["id"], "tools"); + let found = ok( + &app, + "fuzzy_search_commands", + json!({"pattern":"List","limit":8}), + ) + .await; + assert_eq!(found[0]["command"], "ls -la"); + assert_eq!(found[0]["source"], "quickCommand"); + ok(&app,"import_quick_commands",json!({"source":"nyaterm_json","content":"[{\"label\":\"Uptime\",\"command\":\"uptime\"}]"})).await; + assert_eq!(config::load_quick_commands(&()).unwrap().commands.len(), 2); + assert_eq!( + call( + &app, + "import_quick_commands", + json!({"source":"nyaterm_json","filePath":"C:/server-secret"}) + ) + .await + .0, + StatusCode::BAD_REQUEST + ); + assert_eq!( + call(&app, "save_quick_commands", json!({"config":[]})) + .await + .0, + StatusCode::BAD_REQUEST + ); + let found = ok( + &app, + "fuzzy_search_candidates", + json!({"pattern":"prod","limit":1,"items":[{"id":"a","value":"a","display":"production"}]}), + ) + .await; + assert_eq!(found[0]["id"], "a"); + assert_eq!( + call( + &app, + "register_command_submission", + json!({"sessionId":"not-owned","command":"secret"}) + ) + .await + .0, + StatusCode::NOT_FOUND + ); + assert_eq!(ok(&app, "get_command_history", json!({})).await, json!([])); + + assert_eq!( + ok(&app, "verify_master_password", json!({"password":"wrong"})).await, + false + ); + assert_eq!( + ok(&app, "verify_master_password", json!({"password":PASSWORD})).await, + true + ); + let settings = ok(&app, "get_app_settings", json!({})).await; + assert_eq!(settings["security"]["master_password"], "__SET__"); + ok(&app, "save_app_settings", json!({"settings":settings})).await; + assert!( + config::load_app_settings(&()) + .unwrap() + .security + .master_password + .is_none() + ); + ok(&app, "save_app_language", json!({"language":"ko"})).await; + assert_eq!( + config::load_app_settings(&()) + .unwrap() + .ui + .language + .as_deref(), + Some("ko") + ); + for (id, parent) in [ + ("root", Value::Null), + ("child", json!("root")), + ("keep", Value::Null), + ] { + ok( + &app, + "save_group", + json!({"group":{"id":id,"name":id,"parent_id":parent}}), + ) + .await; + } + for (id, group) in [("gone", "child"), ("kept", "keep")] { + ok(&app,"save_connection",json!({"connection":{"id":id,"name":id,"type":"ssh","host":"example.test","port":22,"username":"test","group_id":group}})).await; + } + ok(&app,"reorder_items",json!({"connections":[{"id":"kept","sort_order":5}],"groups":[{"id":"keep","sort_order":7}]})).await; + ok(&app, "delete_group", json!({"id":"root"})).await; + let connections = config::load_config(&()).unwrap(); + assert_eq!(connections.connections.len(), 1); + assert_eq!(connections.groups.len(), 1); + assert_eq!(connections.connections[0].sort_order, 5); + assert_eq!(connections.groups[0].sort_order, 7); + storage::replace_known_host_for_host("example.test", "example.test ssh-ed25519 AAAA").unwrap(); + ok(&app, "clear_known_hosts", json!({})).await; + assert!(storage::list_known_hosts().unwrap().is_empty()); + for id in ["a", "b"] { + ok( + &app, + "save_credential", + json!({"entry":{"id":id,"name":id,"username":"test","password":"secret"}}), + ) + .await; + } + ok( + &app, + "reorder_credentials", + json!({"updates":[{"id":"b","sort_order":0},{"id":"a","sort_order":1}]}), + ) + .await; + assert_eq!( + config::load_credentials(&()).unwrap().credentials[0].id, + "b" + ); + + let entry = json!({"id":"otp","otp_type":"hotp","issuer":"RFC4226","username":"test","secret":"GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ","algorithm":"SHA1","digits":6,"period":30,"counter":0}); + ok(&app, "save_otp_entry", json!({"entry":entry})).await; + let otp = ok(&app, "get_otp_entries", json!({})).await; + assert_eq!(otp[0]["secret"], Value::Null); + assert_eq!(otp[0]["has_secret"], true); + assert_ne!( + config::load_otp_entries(&()).unwrap().entries[0] + .secret + .as_deref(), + entry["secret"].as_str() + ); + assert_eq!( + ok(&app, "generate_otp_code", json!({"id":"otp"})).await["code"], + "755224" + ); + assert_eq!( + ok(&app, "generate_otp_code", json!({"id":"otp"})).await["code"], + "287082" + ); + assert_eq!(config::load_otp_entries(&()).unwrap().entries[0].counter, 2); + let mut bad = entry.clone(); + bad["period"] = json!(0); + assert_eq!( + call(&app, "save_otp_entry", json!({"entry":bad})).await.0, + StatusCode::BAD_REQUEST + ); + let parsed = ok( + &app, + "parse_otp_uri", + json!({"uri":"otpauth://totp/Test?secret=GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ"}), + ) + .await; + assert_eq!(parsed["otp_type"], "totp"); + + let folder = ok(&app, "create_note_folder", json!({"name":"Folder"})).await; + let note = ok( + &app, + "create_note", + json!({"parentId":folder["id"],"title":"Title","markdown":"original"}), + ) + .await; + let saved=ok(&app,"update_note",json!({"noteId":note["id"],"title":"Changed","markdown":"new","expectedRevision":note["revision"]})).await; + assert!(saved["revision"].as_u64().unwrap() > note["revision"].as_u64().unwrap()); + let (status,error)=call(&app,"update_note",json!({"noteId":note["id"],"title":"Stale","markdown":"lost","expectedRevision":note["revision"]})).await; + assert_eq!(status, StatusCode::CONFLICT); + assert_eq!(error["error"], "Revision conflict"); + assert_eq!( + ok(&app, "get_note", json!({"noteId":note["id"]})).await["markdown"], + "new" + ); + assert_eq!( + ok(&app, "get_notes_export", json!({})).await["notes"] + .as_array() + .unwrap() + .len(), + 1 + ); + ok( + &app, + "delete_note_node", + json!({"nodeKind":"folder","nodeId":folder["id"]}), + ) + .await; + assert!( + ok(&app, "list_note_tree", json!({})).await["notes"] + .as_array() + .unwrap() + .is_empty() + ); + let audit = ok( + &app, + "append_ai_audit", + json!({"request":{"action":"insert","generatedCommand":"echo password=hidden"}}), + ) + .await; + assert_eq!(audit["generatedCommand"], "echo password=[REDACTED]"); + + let mut settings: config::AppSettings = + storage::load_settings_doc(storage::SettingsDocKey::AppSettings).unwrap(); + settings.security.master_password = + Some(crypto::encrypt_settings_secret("actual-master").unwrap()); + storage::save_settings_doc(storage::SettingsDocKey::AppSettings, &settings).unwrap(); + assert_eq!( + ok(&app, "verify_master_password", json!({"password":PASSWORD})).await, + false + ); + assert_eq!( + ok( + &app, + "verify_master_password", + json!({"password":"actual-master"}) + ) + .await, + true + ); + assert_eq!( + ok( + &app, + "verify_master_password", + json!({"password":"__SET__"}) + ) + .await, + false + ); + for _ in 0..15 { + let _ = call(&app, "verify_master_password", json!({"password":"wrong"})).await; + } + assert_eq!( + call( + &app, + "verify_master_password", + json!({"password":"actual-master"}) + ) + .await + .0, + StatusCode::TOO_MANY_REQUESTS + ); + ok(&app, "clear_all_connections", json!({})).await; + assert!(config::load_config(&()).unwrap().connections.is_empty()); +} diff --git a/src-tauri/crates/nyaterm-web/tests/protocols.rs b/src-tauri/crates/nyaterm-web/tests/protocols.rs new file mode 100644 index 00000000..305415ee --- /dev/null +++ b/src-tauri/crates/nyaterm-web/tests/protocols.rs @@ -0,0 +1,1230 @@ +use axum::{ + Router, + body::{Body, to_bytes}, + http::{Request, StatusCode}, +}; +use futures_util::{SinkExt, StreamExt}; +use nyaterm_web::{auth, state::State}; +use serde_json::{Value, json}; +use std::{collections::HashMap, sync::Arc, time::Duration}; +use tokio::{ + io::{AsyncReadExt, AsyncWriteExt}, + net::{TcpListener, TcpStream}, + sync::{Mutex, mpsc}, +}; +use tokio_tungstenite::{ + connect_async, + tungstenite::{Message, client::IntoClientRequest}, +}; +use tokio_util::sync::CancellationToken; +use tower::ServiceExt; +fn state(host: String, base_path: &str) -> Arc { + Arc::new(State { + origin: format!("http://{host}"), + host, + base_path: base_path.into(), + secure_cookie: false, + password_hash: auth::digest("login-password-at-least-32-characters"), + logins: Mutex::new(HashMap::new()), + sessions: Mutex::new(HashMap::new()), + prompts: Mutex::new(HashMap::new()), + ai_streams: Mutex::new(HashMap::new()), + login_attempts: Mutex::new(vec![]), + mutation: Mutex::new(()), + shutdown: CancellationToken::new(), + }) +} +async fn request( + app: &Router, + state: &State, + path: &str, + body: Option, + owner: Option<&str>, + csrf: Option<&str>, +) -> (StatusCode, axum::http::HeaderMap, Value) { + let mut builder = Request::builder() + .uri(format!("/nyaterm/api/{path}")) + .header("host", &state.host) + .header("origin", &state.origin) + .header("x-nyaterm-request", "1"); + if let Some(owner) = owner { + builder = builder.header("cookie", format!("{}={owner}", auth::COOKIE)); + } + if let Some(csrf) = csrf { + builder = builder.header("x-nyaterm-csrf", csrf); + } + if body.is_some() { + builder = builder + .method("POST") + .header("content-type", "application/json"); + } + let response = app + .clone() + .oneshot( + builder + .body( + body.map(|v| Body::from(v.to_string())) + .unwrap_or(Body::empty()), + ) + .unwrap(), + ) + .await + .unwrap(); + let status = response.status(); + let headers = response.headers().clone(); + let bytes = to_bytes(response.into_body(), 8 * 1024 * 1024) + .await + .unwrap(); + let value = serde_json::from_slice(&bytes).unwrap_or(Value::Null); + (status, headers, value) +} +async fn login(app: &Router, state: &State) -> (String, String) { + let (status, headers, value) = request( + app, + state, + "auth/login", + Some(json!({"password":"login-password-at-least-32-characters"})), + None, + None, + ) + .await; + assert_eq!(status, StatusCode::OK); + let token = headers["set-cookie"] + .to_str() + .unwrap() + .split(';') + .next() + .unwrap() + .split('=') + .nth(1) + .unwrap() + .to_owned(); + (token, value["csrf"].as_str().unwrap().into()) +} +async fn ws( + host: &str, + state: &State, + id: &str, + owner: &str, +) -> tokio_tungstenite::WebSocketStream> { + let mut r = format!("ws://{host}/nyaterm/api/sessions/{id}/terminal") + .into_client_request() + .unwrap(); + r.headers_mut() + .insert("origin", state.origin.parse().unwrap()); + r.headers_mut().insert( + "cookie", + format!("{}={owner}", auth::COOKIE).parse().unwrap(), + ); + connect_async(r).await.unwrap().0 +} +async fn binary( + socket: &mut tokio_tungstenite::WebSocketStream< + tokio_tungstenite::MaybeTlsStream, + >, +) -> Vec { + loop { + match tokio::time::timeout(Duration::from_secs(20), socket.next()) + .await + .unwrap() + .unwrap() + .unwrap() + { + Message::Binary(b) => return b.to_vec(), + Message::Ping(b) => socket.send(Message::Pong(b)).await.unwrap(), + other => panic!("Expected bytes, got {other:?}"), + } + } +} + +type Socket = tokio_tungstenite::WebSocketStream>; +async fn vnc_ws(host: &str, state: &State, id: &str, owner: &str) -> Socket { + let mut r = format!("ws://{host}/nyaterm/api/sessions/{id}/vnc") + .into_client_request() + .unwrap(); + r.headers_mut() + .insert("origin", state.origin.parse().unwrap()); + r.headers_mut().insert( + "cookie", + format!("{}={owner}", auth::COOKIE).parse().unwrap(), + ); + connect_async(r).await.unwrap().0 +} +async fn rejected_ws( + host: &str, + state: &State, + id: &str, + endpoint: &str, + owner: &str, + expected: u16, +) { + let mut r = format!("ws://{host}/nyaterm/api/sessions/{id}/{endpoint}") + .into_client_request() + .unwrap(); + r.headers_mut() + .insert("origin", state.origin.parse().unwrap()); + r.headers_mut().insert( + "cookie", + format!("{}={owner}", auth::COOKIE).parse().unwrap(), + ); + match connect_async(r).await { + Err(tokio_tungstenite::tungstenite::Error::Http(response)) => { + assert_eq!(response.status().as_u16(), expected) + } + _ => panic!("Expected a rejected WebSocket upgrade"), + } +} +async fn next_message(socket: &mut Socket) -> Message { + loop { + let message = tokio::time::timeout(Duration::from_secs(5), socket.next()) + .await + .unwrap() + .unwrap() + .unwrap(); + match message { + Message::Ping(b) => socket.send(Message::Pong(b)).await.unwrap(), + other => return other, + } + } +} +async fn frame(socket: &mut Socket) -> Vec { + loop { + if let Message::Binary(bytes) = next_message(socket).await { + return bytes.to_vec(); + } + } +} +async fn command( + app: &Router, + state: &State, + owner: &str, + csrf: &str, + name: &str, + args: Value, +) -> Value { + let (status, _, value) = request( + app, + state, + &format!("commands/{name}"), + Some(args), + Some(owner), + Some(csrf), + ) + .await; + assert_eq!(status, StatusCode::OK, "{name}: {value}"); + value +} +async fn create(app: &Router, state: &State, owner: &str, csrf: &str, args: Value) -> String { + let (status, _, value) = + request(app, state, "sessions", Some(args), Some(owner), Some(csrf)).await; + assert_eq!(status, StatusCode::OK, "{value}"); + value["session_id"].as_str().unwrap().into() +} +async fn wait_removed(state: &State, id: &str) { + tokio::time::timeout(Duration::from_secs(5), async { + while state.sessions.lock().await.contains_key(id) { + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .unwrap(); +} +async fn echo() -> (u16, tokio::task::JoinHandle<()>) { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + ( + port, + tokio::spawn(async move { + loop { + let (mut stream, _) = listener.accept().await.unwrap(); + tokio::spawn(async move { + let mut buf = [0; 1024]; + while let Ok(n) = stream.read(&mut buf).await { + if n == 0 { + break; + } + if stream.write_all(&buf[..n]).await.is_err() { + break; + } + } + }); + } + }), + ) +} +async fn proxy(http: bool, authenticated: bool) -> (u16, tokio::task::JoinHandle<()>) { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + ( + port, + tokio::spawn(async move { + loop { + let (mut stream, _) = listener.accept().await.unwrap(); + tokio::spawn(async move { + let (host, port) = if http { + let mut bytes = Vec::new(); + while !bytes.ends_with(b"\r\n\r\n") { + bytes.push(stream.read_u8().await.unwrap()); + } + let request = String::from_utf8(bytes).unwrap(); + assert_eq!( + request.to_lowercase().contains( + "proxy-authorization: basic cHJveHk6c2VjcmV0" + .to_lowercase() + .as_str() + ), + authenticated + ); + let target = request.split_whitespace().nth(1).unwrap(); + let (host, port) = target.rsplit_once(':').unwrap(); + let target = (host.to_owned(), port.parse::().unwrap()); + stream + .write_all(b"HTTP/1.1 200 Connection established\r\n\r\n") + .await + .unwrap(); + target + } else { + assert_eq!(stream.read_u8().await.unwrap(), 5); + let count = stream.read_u8().await.unwrap(); + let mut methods = vec![0; count as usize]; + stream.read_exact(&mut methods).await.unwrap(); + let method = if authenticated { 2 } else { 0 }; + assert!(methods.contains(&method)); + stream.write_all(&[5, method]).await.unwrap(); + if authenticated { + assert_eq!(stream.read_u8().await.unwrap(), 1); + let len = stream.read_u8().await.unwrap(); + let mut user = vec![0; len as usize]; + stream.read_exact(&mut user).await.unwrap(); + let len = stream.read_u8().await.unwrap(); + let mut password = vec![0; len as usize]; + stream.read_exact(&mut password).await.unwrap(); + assert_eq!(user, b"proxy"); + assert_eq!(password, b"secret"); + stream.write_all(&[1, 0]).await.unwrap(); + } + assert_eq!(stream.read_u8().await.unwrap(), 5); + assert_eq!(stream.read_u8().await.unwrap(), 1); + assert_eq!(stream.read_u8().await.unwrap(), 0); + let host = match stream.read_u8().await.unwrap() { + 1 => { + let mut ip = [0; 4]; + stream.read_exact(&mut ip).await.unwrap(); + std::net::Ipv4Addr::from(ip).to_string() + } + 3 => { + let len = stream.read_u8().await.unwrap(); + let mut name = vec![0; len as usize]; + stream.read_exact(&mut name).await.unwrap(); + String::from_utf8(name).unwrap() + } + _ => panic!("unsupported address"), + }; + let port = stream.read_u16().await.unwrap(); + stream + .write_all(&[5, 0, 0, 1, 127, 0, 0, 1, 0, 0]) + .await + .unwrap(); + (host, port) + }; + let mut target = TcpStream::connect((host.as_str(), port)).await.unwrap(); + let _ = tokio::io::copy_bidirectional(&mut stream, &mut target).await; + }); + } + }), + ) +} +async fn rfb() -> ( + u16, + mpsc::UnboundedReceiver>, + tokio::task::JoinHandle<()>, +) { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + let (tx, rx) = mpsc::unbounded_channel(); + ( + port, + rx, + tokio::spawn(async move { + loop { + let (mut stream, _) = listener.accept().await.unwrap(); + let tx = tx.clone(); + tokio::spawn(async move { + stream.write_all(b"RFB 003.008\n").await.unwrap(); + let mut version = [0; 12]; + if stream.read_exact(&mut version).await.is_err() { + // A cancelled/replaced connection may stop mid-handshake. + return; + } + stream.write_all(&[1, 1]).await.unwrap(); + assert_eq!(stream.read_u8().await.unwrap(), 1); + stream.write_all(&0_u32.to_be_bytes()).await.unwrap(); + let shared = stream.read_u8().await.unwrap(); + tx.send(vec![99, shared]).unwrap(); + stream + .write_all(&[ + 0, 2, 0, 1, 32, 24, 0, 1, 0, 255, 0, 255, 0, 255, 0, 8, 16, 0, 0, 0, 0, + 0, 0, 4, + ]) + .await + .unwrap(); + stream.write_all(b"test").await.unwrap(); + let mut sent = false; + loop { + let Ok(kind) = stream.read_u8().await else { + break; + }; + let size = match kind { + 0 => 19, + 2 => 3, + 3 => 9, + 4 => 7, + 5 => 5, + 6 => 7, + _ => panic!("Unknown RFB input {kind}"), + }; + let mut packet = vec![kind]; + packet.resize(size + 1, 0); + if stream.read_exact(&mut packet[1..]).await.is_err() { + break; + } + if kind == 2 { + let count = u16::from_be_bytes([packet[2], packet[3]]); + let mut encodings = vec![0; count as usize * 4]; + stream.read_exact(&mut encodings).await.unwrap(); + } + if kind == 6 { + let count = + u32::from_be_bytes(packet[4..8].try_into().unwrap()) as usize; + let mut text = vec![0; count]; + stream.read_exact(&mut text).await.unwrap(); + packet.extend(&text); + if text == b"patch" { + stream + .write_all(&[ + 0, 0, 0, 1, 0, 1, 0, 0, 0, 1, 0, 1, 0, 0, 0, 0, 70, 80, 90, + 0, + ]) + .await + .unwrap(); + } + if text == b"resize" { + let mut resized = vec![0, 0, 0, 2, 0, 0, 0, 0, 0, 3, 0, 1]; + resized.extend((-223_i32).to_be_bytes()); + resized.extend([ + 0, 0, 0, 0, 0, 3, 0, 1, 0, 0, 0, 0, 1, 2, 3, 0, 4, 5, 6, 0, 7, + 8, 9, 0, + ]); + stream.write_all(&resized).await.unwrap(); + } + } + if kind == 3 && !sent { + // Two-pixel raw rectangle in the client's requested RGBA format. + let update = [ + 0, 0, 0, 1, 0, 0, 0, 0, 0, 2, 0, 1, 0, 0, 0, 0, 10, 20, 30, 0, 40, + 50, 60, 0, + ]; + stream.write_all(&update).await.unwrap(); + stream.write_all(&[3, 0, 0, 0, 0, 0, 0, 6]).await.unwrap(); + stream.write_all(b"remote").await.unwrap(); + sent = true; + } + if matches!(kind, 4 | 5 | 6) { + let _ = tx.send(packet); + } + } + }); + } + }), + ) +} + +#[derive(Clone)] +struct Jump; +impl russh::server::Server for Jump { + type Handler = Self; + fn new_client(&mut self, _: Option) -> Self { + Self + } +} +impl russh::server::Handler for Jump { + type Error = russh::Error; + async fn auth_password( + &mut self, + username: &str, + password: &str, + ) -> Result { + Ok(if username == "jump" && password == "jump-password" { + russh::server::Auth::Accept + } else { + russh::server::Auth::reject() + }) + } + async fn channel_open_direct_tcpip( + &mut self, + channel: russh::Channel, + host: &str, + port: u32, + _: &str, + _: u32, + reply: russh::server::ChannelOpenHandle, + _: &mut russh::server::Session, + ) -> Result<(), Self::Error> { + let mut target = TcpStream::connect((host, port as u16)).await?; + reply.accept().await; + tokio::spawn(async move { + let mut stream = channel.into_stream(); + let _ = tokio::io::copy_bidirectional(&mut stream, &mut target).await; + }); + Ok(()) + } +} +async fn jump() -> (u16, tokio::task::JoinHandle<()>) { + use russh::{keys::PublicKeyBase64, server::Server as _}; + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + let key = + russh::keys::PrivateKey::random(&mut rand_new::rng(), russh::keys::Algorithm::Ed25519) + .unwrap(); + let host_id = format!("[127.0.0.1]:{port}"); + nyaterm_core::storage::replace_known_host_for_host( + &host_id, + &format!( + "{host_id} {} {}", + key.algorithm(), + key.public_key().public_key_base64() + ), + ) + .unwrap(); + let config = Arc::new(russh::server::Config { + keys: vec![key], + auth_rejection_time: Duration::ZERO, + auth_rejection_time_initial: Some(Duration::ZERO), + ..Default::default() + }); + ( + port, + tokio::spawn(async move { + Jump.run_on_socket(config, &listener).await.unwrap(); + }), + ) +} + +#[tokio::test] +async fn web_telnet_vnc_proxy_and_lifecycle() { + let data = tempfile::tempdir().unwrap(); + nyaterm_core::storage::init(data.path()).unwrap(); + nyaterm_core::utils::crypto::set_server_key_material([41; 32]); + let dist = tempfile::tempdir().unwrap(); + std::fs::write(dist.path().join("index.html"), "test").unwrap(); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let host = listener.local_addr().unwrap().to_string(); + let state = state(host.clone(), "/nyaterm"); + let app = nyaterm_web::router(state.clone(), dist.path().into()); + let serving = tokio::spawn(axum::serve(listener, app.clone()).into_future()); + let (owner, csrf) = login(&app, &state).await; + let (other, other_csrf) = login(&app, &state).await; + let (port, echo_task) = echo().await; + let saved = command(&app,&state,&owner,&csrf,"save_connection",json!({"connection":{"id":"","name":"GBK Telnet","type":"telnet","host":"127.0.0.1","port":port,"encoding":"GBK","enter_mode":"crlf","auto_login":{"enabled":false}}})).await; + let id = create( + &app, + &state, + &owner, + &csrf, + json!({"type":"telnet","connectionId":saved,"recordingScopeId":"telnet-pane"}), + ) + .await; + let info = command( + &app, + &state, + &owner, + &csrf, + "get_session_info", + json!({"sessionId":id}), + ) + .await; + assert_eq!(info["session_type"], "Telnet"); + assert_eq!(info["workspace_pane_id"], "telnet-pane"); + assert_eq!(info["sftp_available"], false); + rejected_ws(&host, &state, &id, "vnc", &owner, 400).await; + let (status, _, _) = request( + &app, + &state, + &format!("commands/get_session_info"), + Some(json!({"sessionId":id})), + Some(&other), + Some(&other_csrf), + ) + .await; + assert_eq!(status, StatusCode::NOT_FOUND); + let (status, _, _) = request( + &app, + &state, + "commands/sftp_read_dir", + Some(json!({"sessionId":id,"path":"/"})), + Some(&owner), + Some(&csrf), + ) + .await; + assert_ne!(status, StatusCode::OK); + let mut socket = ws(&host, &state, &id, &owner).await; + socket + .send(Message::Text( + json!({"type":"input","data":"你好\r"}).to_string().into(), + )) + .await + .unwrap(); + assert_eq!(binary(&mut socket).await, "你好\r\n".as_bytes()); + // Raw bytes are not re-encoded as UTF-8 before the connection codec. + socket + .send(Message::Binary(vec![0xc4, 0xe3].into())) + .await + .unwrap(); + assert_eq!(binary(&mut socket).await, "你".as_bytes()); + socket.close(None).await.unwrap(); + tokio::time::timeout(Duration::from_secs(3), async { + while state.sessions.lock().await[&id] + .attached + .load(std::sync::atomic::Ordering::Acquire) + { + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .unwrap(); + let mut restored = ws(&host, &state, &id, &owner).await; + restored + .send(Message::Text( + json!({"type":"input","data":"restored"}).to_string().into(), + )) + .await + .unwrap(); + assert_eq!(binary(&mut restored).await, b"restored"); + command( + &app, + &state, + &owner, + &csrf, + "close_session", + json!({"sessionId":id}), + ) + .await; + wait_removed(&state, &id).await; + + // Automatic login gates the startup command and NAWS uses escaped dimensions. + let login_listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let login_port = login_listener.local_addr().unwrap().port(); + let login_server = tokio::spawn(async move { + let (mut stream, _) = login_listener.accept().await.unwrap(); + stream.write_all(&[255, 253, 31]).await.unwrap(); + stream.write_all(b"login:").await.unwrap(); + let mut negotiation = [0; 3]; + stream.read_exact(&mut negotiation).await.unwrap(); + assert_eq!(negotiation, [255, 251, 31]); + let mut user = [0; 5]; + stream.read_exact(&mut user).await.unwrap(); + assert_eq!(&user, b"test\r"); + stream.write_all(b"Password:").await.unwrap(); + let mut password = [0; 7]; + stream.read_exact(&mut password).await.unwrap(); + assert_eq!(&password, b"secret\r"); + stream.write_all(b"\r\n$ ").await.unwrap(); + let started = tokio::time::Instant::now(); + let mut startup = [0; 7]; + stream.read_exact(&mut startup).await.unwrap(); + assert_eq!(&startup, b"whoami\r"); + assert!(started.elapsed() >= Duration::from_millis(70)); + stream.write_all(b"READY").await.unwrap(); + let mut naws = [0; 11]; + stream.read_exact(&mut naws).await.unwrap(); + assert_eq!(naws, [255, 250, 31, 0, 255, 255, 1, 255, 255, 255, 240]); + }); + let id = create(&app,&state,&owner,&csrf,json!({"type":"telnet","host":"127.0.0.1","port":login_port,"username":"test","auth":{"mode":"password","password":"secret"},"auto_login":{"success_prompt_regex":"\\$"},"startupCommand":{"command":"whoami","delay_ms":80}})).await; + let mut socket = ws(&host, &state, &id, &owner).await; + let mut text = Vec::new(); + while !text.ends_with(b"READY") { + text.extend(binary(&mut socket).await); + } + socket + .send(Message::Text( + json!({"type":"resize","cols":255,"rows":511}) + .to_string() + .into(), + )) + .await + .unwrap(); + login_server.await.unwrap(); + wait_removed(&state, &id).await; + // Raw TCP line editing echoes locally and sends only the final edited line. + let line_listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let line_port = line_listener.local_addr().unwrap().port(); + let line_server = tokio::spawn(async move { + let (mut stream, _) = line_listener.accept().await.unwrap(); + let mut line = [0; 4]; + stream.read_exact(&mut line).await.unwrap(); + assert_eq!(&line, b"ab\r\n"); + stream.write_all(b"DONE").await.unwrap(); + }); + let id = create(&app,&state,&owner,&csrf,json!({"type":"telnet","host":"127.0.0.1","port":line_port,"raw_tcp_cli":true,"local_line_edit":true,"enter_mode":"crlf"})).await; + let mut socket = ws(&host, &state, &id, &owner).await; + socket + .send(Message::Text( + json!({"type":"input","data":"abX\u{7f}\r"}) + .to_string() + .into(), + )) + .await + .unwrap(); + let mut text = Vec::new(); + while !text.ends_with(b"DONE") { + text.extend(binary(&mut socket).await); + } + assert!(text.starts_with(b"abX")); + line_server.await.unwrap(); + wait_removed(&state, &id).await; + for http in [false, true] { + for authenticated in [false, true] { + let (proxy_port, task) = proxy(http, authenticated).await; + let proxy_id = command(&app,&state,&owner,&csrf,"save_proxy",json!({"proxy":{"id":"","name":"fixture","protocol":if http {"http"} else {"socks5"},"host":"127.0.0.1","port":proxy_port,"username":authenticated.then_some("proxy"),"password":authenticated.then_some("secret")}})).await; + let proxies = command(&app, &state, &owner, &csrf, "get_proxies", json!({})).await; + assert!( + proxies + .as_array() + .unwrap() + .iter() + .all(|p| p["password"].is_null()) + ); + let id = create(&app,&state,&owner,&csrf,json!({"type":"telnet","host":"127.0.0.1","port":port,"raw_tcp_cli":true,"network":{"proxy_id":proxy_id}})).await; + let mut socket = ws(&host, &state, &id, &owner).await; + socket + .send(Message::Binary(vec![0xff, b'x'].into())) + .await + .unwrap(); + // GBK is not configured here: input bytes survive on the wire, echo x proves routing. + let output = binary(&mut socket).await; + assert!(output.ends_with(b"x")); + command( + &app, + &state, + &owner, + &csrf, + "close_session", + json!({"sessionId":id}), + ) + .await; + wait_removed(&state, &id).await; + task.abort(); + } + } + + let group = command( + &app, + &state, + &owner, + &csrf, + "save_proxy_group", + json!({"group":{"id":"","name":"fixtures"}}), + ) + .await; + let credential_proxy = command(&app,&state,&owner,&csrf,"save_proxy",json!({"proxy":{"id":"","name":"before edit","protocol":"http","host":"127.0.0.1","port":1,"username":"proxy","password":"secret","group_id":group}})).await; + command(&app,&state,&owner,&csrf,"save_proxy",json!({"proxy":{"id":credential_proxy,"name":"after edit","protocol":"http","host":"127.0.0.1","port":1,"username":"proxy","group_id":group}})).await; + assert_eq!( + command( + &app, + &state, + &owner, + &csrf, + "get_proxy_password", + json!({"proxyId":credential_proxy}) + ) + .await, + "secret" + ); + command( + &app, + &state, + &owner, + &csrf, + "set_proxy_group", + json!({"proxyId":credential_proxy,"groupId":null}), + ) + .await; + command( + &app, + &state, + &owner, + &csrf, + "delete_proxy_group", + json!({"groupId":group}), + ) + .await; + assert!( + command(&app, &state, &owner, &csrf, "get_proxy_groups", json!({})) + .await + .as_array() + .unwrap() + .is_empty() + ); + command( + &app, + &state, + &owner, + &csrf, + "delete_proxy", + json!({"proxyId":credential_proxy}), + ) + .await; + // Jump B uses Jump A's own authenticated proxy; the target's bad proxy is + // intentionally ignored, matching desktop route priority. + let (jump_a_port, jump_a_task) = jump().await; + let (jump_b_port, jump_b_task) = jump().await; + let (jump_proxy_port, jump_proxy_task) = proxy(false, true).await; + let proxy_id = command(&app,&state,&owner,&csrf,"save_proxy",json!({"proxy":{"id":"","name":"jump proxy","protocol":"socks5","host":"127.0.0.1","port":jump_proxy_port,"username":"proxy","password":"secret"}})).await; + let a = command(&app,&state,&owner,&csrf,"save_connection",json!({"connection":{"id":"","name":"Jump A","type":"ssh","host":"127.0.0.1","port":jump_a_port,"username":"jump","encoding":"GBK","auth":{"mode":"password","password":"jump-password"},"network":{"proxy_id":proxy_id}}})).await; + let b = command(&app,&state,&owner,&csrf,"save_connection",json!({"connection":{"id":"","name":"Jump B","type":"ssh","host":"127.0.0.1","port":jump_b_port,"username":"jump","auth":{"mode":"password","password":"jump-password"},"network":{"proxy_jump_id":a}}})).await; + let telnet_route = command(&app,&state,&owner,&csrf,"save_connection",json!({"connection":{"id":"","name":"Saved routed Telnet","type":"telnet","host":"127.0.0.1","port":port,"network":{"proxy_jump_id":b}}})).await; + assert_eq!( + nyaterm_core::config::load_connection_by_id(&(), telnet_route.as_str().unwrap()) + .unwrap() + .network + .unwrap() + .proxy_jump_id + .as_deref(), + b.as_str() + ); + let id = create(&app,&state,&owner,&csrf,json!({"type":"telnet","host":"127.0.0.1","port":port,"network":{"proxy_jump_id":b,"proxy_id":"missing-ignored"}})).await; + let mut socket = ws(&host, &state, &id, &owner).await; + socket + .send(Message::Text( + json!({"type":"input","data":"nested jumps"}) + .to_string() + .into(), + )) + .await + .unwrap(); + assert_eq!(binary(&mut socket).await, b"nested jumps"); + command( + &app, + &state, + &owner, + &csrf, + "close_session", + json!({"sessionId":id}), + ) + .await; + wait_removed(&state, &id).await; + let mut events = state.login(&owner).await.unwrap().events.subscribe(); + // Cycle detection happens before any TCP connection or authentication. + let (status,_,_) = request(&app,&state,"commands/save_connection",Some(json!({"connection":{"id":a,"name":"Jump A","type":"ssh","host":"127.0.0.1","port":jump_a_port,"username":"jump","auth":{"mode":"password"},"network":{"proxy_jump_id":b}}})),Some(&owner),Some(&csrf)).await; + assert_eq!(status, StatusCode::BAD_REQUEST); + // Imported/on-disk cycles must also be rejected when opening a route. + let mut saved = nyaterm_core::config::load_config(&()).unwrap(); + saved + .connections + .iter_mut() + .find(|c| c.id == a.as_str().unwrap()) + .unwrap() + .network = Some(nyaterm_core::config::ConnectionNetwork { + proxy_id: None, + proxy_jump_id: Some(b.as_str().unwrap().into()), + }); + nyaterm_core::config::save_config(&(), &saved).unwrap(); + let id = create( + &app, + &state, + &owner, + &csrf, + json!({"type":"telnet","host":"127.0.0.1","port":port,"network":{"proxy_jump_id":b}}), + ) + .await; + let error = tokio::time::timeout(Duration::from_secs(3), async { + loop { + let event = events.recv().await.unwrap(); + if event.event == format!("connection-error-{id}") { + break event.payload; + } + } + }) + .await + .unwrap(); + assert!(error["error"].as_str().unwrap().contains("cycle")); + wait_removed(&state, &id).await; + + for http in [false, true] { + let denied = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let denied_port = denied.local_addr().unwrap().port(); + let denial = tokio::spawn(async move { + let (mut stream, _) = denied.accept().await.unwrap(); + if http { + let mut request = Vec::new(); + while !request.ends_with(b"\r\n\r\n") { + request.push(stream.read_u8().await.unwrap()); + } + stream + .write_all(b"HTTP/1.1 407 Proxy Authentication Required\r\n\r\n") + .await + .unwrap(); + } else { + assert_eq!(stream.read_u8().await.unwrap(), 5); + let count = stream.read_u8().await.unwrap(); + let mut methods = vec![0; count as usize]; + stream.read_exact(&mut methods).await.unwrap(); + stream.write_all(&[5, 2]).await.unwrap(); + assert_eq!(stream.read_u8().await.unwrap(), 1); + let count = stream.read_u8().await.unwrap(); + let mut username = vec![0; count as usize]; + stream.read_exact(&mut username).await.unwrap(); + let count = stream.read_u8().await.unwrap(); + let mut password = vec![0; count as usize]; + stream.read_exact(&mut password).await.unwrap(); + stream.write_all(&[1, 1]).await.unwrap(); + } + }); + let proxy_id = command(&app,&state,&owner,&csrf,"save_proxy",json!({"proxy":{"id":"","name":"reject auth","protocol":if http {"http"} else {"socks5"},"host":"127.0.0.1","port":denied_port,"username":"proxy","password":"wrong"}})).await; + let id = create( + &app, + &state, + &owner, + &csrf, + json!({"type":"telnet","host":"127.0.0.1","port":port,"network":{"proxy_id":proxy_id}}), + ) + .await; + let error = tokio::time::timeout(Duration::from_secs(3), async { + loop { + let event = events.recv().await.unwrap(); + if event.event == format!("connection-error-{id}") { + break event.payload; + } + } + }) + .await + .unwrap(); + assert!(error["error"].as_str().unwrap().contains("proxy")); + wait_removed(&state, &id).await; + denial.await.unwrap(); + } + let mut persisted = nyaterm_core::config::load_config(&()).unwrap(); + for index in 0..9 { + persisted.connections.push(serde_json::from_value(json!({"id":format!("depth-{index}"),"name":"depth","type":"ssh","host":"127.0.0.1","port":jump_a_port,"username":"jump","network":if index < 8 {json!({"proxy_jump_id":format!("depth-{}",index+1)})} else {Value::Null}})).unwrap()); + } + nyaterm_core::config::save_config(&(), &persisted).unwrap(); + let id = create(&app,&state,&owner,&csrf,json!({"type":"telnet","host":"127.0.0.1","port":port,"network":{"proxy_jump_id":"depth-0"}})).await; + let error = tokio::time::timeout(Duration::from_secs(3), async { + loop { + let event = events.recv().await.unwrap(); + if event.event == format!("connection-error-{id}") { + break event.payload; + } + } + }) + .await + .unwrap(); + assert!(error["error"].as_str().unwrap().contains("depth")); + wait_removed(&state, &id).await; + // Missing/decryption errors must fail the selected route, never use direct TCP. + for proxy_id in ["not-found", "broken-secret"] { + if proxy_id == "broken-secret" { + let proxies = vec![serde_json::from_value(json!({"id":proxy_id,"name":"bad","protocol":"socks5","host":"127.0.0.1","port":jump_proxy_port,"username":"proxy","password":"not-encrypted"})).unwrap()]; + nyaterm_core::config::save_proxies(&(), &proxies).unwrap(); + } + let id = create( + &app, + &state, + &owner, + &csrf, + json!({"type":"telnet","host":"127.0.0.1","port":port,"network":{"proxy_id":proxy_id}}), + ) + .await; + let error = tokio::time::timeout(Duration::from_secs(3), async { + loop { + let event = events.recv().await.unwrap(); + if event.event == format!("connection-error-{id}") { + break event.payload; + } + } + }) + .await + .unwrap(); + assert!( + error["error"] + .as_str() + .unwrap() + .contains(if proxy_id == "not-found" { + "configuration" + } else { + "decrypt" + }) + ); + wait_removed(&state, &id).await; + } + jump_a_task.abort(); + jump_b_task.abort(); + jump_proxy_task.abort(); + let (vnc_port, mut input, rfb_task) = rfb().await; + let saved_vnc = command(&app,&state,&owner,&csrf,"save_connection",json!({"connection":{"id":"","name":"VNC fixture","type":"vnc","host":"127.0.0.1","port":vnc_port,"security":{"mode":"none"},"reconnect":{"enabled":false}}})).await; + let id = create( + &app, + &state, + &owner, + &csrf, + json!({"type":"vnc","connectionId":saved_vnc,"recordingScopeId":"vnc-pane"}), + ) + .await; + let info = command( + &app, + &state, + &owner, + &csrf, + "get_session_info", + json!({"sessionId":id}), + ) + .await; + assert_eq!(info["session_type"], "VNC"); + assert_eq!(info["workspace_pane_id"], "vnc-pane"); + assert_eq!(info["terminal_available"], false); + rejected_ws(&host, &state, &id, "terminal", &owner, 400).await; + rejected_ws(&host, &state, &id, "vnc", &other, 404).await; + let mut socket = vnc_ws(&host, &state, &id, &owner).await; + let bytes = frame(&mut socket).await; + assert_eq!(u32::from_le_bytes(bytes[8..12].try_into().unwrap()), 2); + assert_eq!(bytes.len(), 52); + assert_eq!(&bytes[44..47], &[10, 20, 30]); + assert_eq!( + tokio::time::timeout(Duration::from_secs(3), input.recv()) + .await + .unwrap() + .unwrap(), + [99, 1] + ); + socket.send(Message::Text(json!({"type":"input","events":[{"type":"key","keysym":65,"pressed":true},{"type":"pointer","x":1,"y":0,"buttonMask":1}]}).to_string().into())).await.unwrap(); + assert_eq!( + tokio::time::timeout(Duration::from_secs(3), input.recv()) + .await + .unwrap() + .unwrap(), + [4, 1, 0, 0, 0, 0, 0, 65] + ); + assert_eq!( + tokio::time::timeout(Duration::from_secs(3), input.recv()) + .await + .unwrap() + .unwrap(), + [5, 1, 0, 1, 0, 0] + ); + socket + .send(Message::Text( + json!({"type":"clipboard","text":"local"}) + .to_string() + .into(), + )) + .await + .unwrap(); + assert_eq!( + tokio::time::timeout(Duration::from_secs(3), input.recv()) + .await + .unwrap() + .unwrap(), + [6, 0, 0, 0, 0, 0, 0, 5, b'l', b'o', b'c', b'a', b'l'] + ); + + socket + .send(Message::Text( + json!({"type":"clipboard","text":"patch"}) + .to_string() + .into(), + )) + .await + .unwrap(); + input.recv().await.unwrap(); + let patch = frame(&mut socket).await; + assert_eq!(u32::from_le_bytes(patch[16..20].try_into().unwrap()), 1); + assert_eq!(&patch[44..47], &[70, 80, 90]); + socket + .send(Message::Text( + json!({"type":"clipboard","text":"resize"}) + .to_string() + .into(), + )) + .await + .unwrap(); + input.recv().await.unwrap(); + let resized = frame(&mut socket).await; + assert_eq!(u32::from_le_bytes(resized[8..12].try_into().unwrap()), 3); + assert_eq!(resized.len(), 56); + socket.close(None).await.unwrap(); + tokio::time::timeout(Duration::from_secs(3), async { + while state.sessions.lock().await[&id] + .attached + .load(std::sync::atomic::Ordering::Acquire) + { + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .unwrap(); + let mut socket = vnc_ws(&host, &state, &id, &owner).await; + assert_eq!(frame(&mut socket).await.len(), 56); + command( + &app, + &state, + &owner, + &csrf, + "vnc_reconnect", + json!({"sessionId":id}), + ) + .await; + loop { + let packet = tokio::time::timeout(Duration::from_secs(3), input.recv()) + .await + .unwrap() + .unwrap(); + if packet == [99, 1] { + break; + } + // Key releases are best effort while the old RFB connection closes. + assert_eq!(packet, [4, 0, 0, 0, 0, 0, 0, 65]); + } + assert_eq!(frame(&mut socket).await.len(), 52); + command( + &app, + &state, + &owner, + &csrf, + "close_vnc_session", + json!({"sessionId":id}), + ) + .await; + wait_removed(&state, &id).await; + let readonly = create(&app,&state,&owner,&csrf,json!({"type":"vnc","config":{"name":"Readonly","host":"127.0.0.1","port":vnc_port,"security":{"mode":"none"},"view_only":true,"reconnect":{"enabled":false}}})).await; + let mut socket = vnc_ws(&host, &state, &readonly, &owner).await; + frame(&mut socket).await; + let (status, _, _) = request( + &app, + &state, + "commands/vnc_set_clipboard_text", + Some(json!({"sessionId":readonly,"text":"denied"})), + Some(&owner), + Some(&csrf), + ) + .await; + assert_eq!(status, StatusCode::BAD_REQUEST); + let (status, _, _) = request( + &app, + &state, + "commands/vnc_input_batch", + Some(json!({"sessionId":readonly,"events":[{"type":"key","keysym":65,"pressed":true}]})), + Some(&owner), + Some(&csrf), + ) + .await; + assert_eq!(status, StatusCode::BAD_REQUEST); + assert_eq!(input.recv().await.unwrap(), [99, 1]); + assert!( + tokio::time::timeout(Duration::from_millis(100), input.recv()) + .await + .is_err() + ); + command( + &app, + &state, + &owner, + &csrf, + "close_vnc_session", + json!({"sessionId":readonly}), + ) + .await; + wait_removed(&state, &readonly).await; + + let stalled = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let stalled_port = stalled.local_addr().unwrap().port(); + let (accepted, accepted_rx) = tokio::sync::oneshot::channel(); + let stalled_task = tokio::spawn(async move { + let (mut stream, _) = stalled.accept().await.unwrap(); + let mut bytes = [0; 512]; + let count = stream.read(&mut bytes).await.unwrap(); + assert!(count > 0); + accepted.send(()).unwrap(); + assert_eq!(stream.read(&mut bytes).await.unwrap(), 0); + }); + let proxy_id = command(&app,&state,&owner,&csrf,"save_proxy",json!({"proxy":{"id":"","name":"stalled","protocol":"http","host":"127.0.0.1","port":stalled_port}})).await; + let cancelled = create(&app,&state,&owner,&csrf,json!({"type":"telnet","host":"127.0.0.1","port":port,"createRequestId":"cancel-network","network":{"proxy_id":proxy_id}})).await; + tokio::time::timeout(Duration::from_secs(3), accepted_rx) + .await + .unwrap() + .unwrap(); + command( + &app, + &state, + &owner, + &csrf, + "cancel_session_creation", + json!({"createRequestId":"cancel-network"}), + ) + .await; + wait_removed(&state, &cancelled).await; + stalled_task.await.unwrap(); + let mut limited = Vec::new(); + for _ in 0..16 { + limited.push( + create( + &app, + &state, + &other, + &other_csrf, + json!({"type":"telnet","host":"127.0.0.1","port":port}), + ) + .await, + ); + } + let (status, _, _) = request( + &app, + &state, + "sessions", + Some(json!({"type":"vnc","host":"127.0.0.1","port":vnc_port,"security":{"mode":"none"}})), + Some(&other), + Some(&other_csrf), + ) + .await; + assert_eq!(status, StatusCode::BAD_REQUEST); + // Login cancellation releases sessions created up to the shared limit. + state.close_owner(&other).await; + for id in limited { + wait_removed(&state, &id).await; + } + let detached = create( + &app, + &state, + &owner, + &csrf, + json!({"type":"telnet","host":"127.0.0.1","port":port}), + ) + .await; + let (status, _, _) = request( + &app, + &state, + "auth/logout", + Some(json!({})), + Some(&owner), + Some(&csrf), + ) + .await; + assert_eq!(status, StatusCode::OK); + wait_removed(&state, &detached).await; + let (owner, csrf) = login(&app, &state).await; + let terminal = create( + &app, + &state, + &owner, + &csrf, + json!({"type":"telnet","host":"127.0.0.1","port":port}), + ) + .await; + let desktop = create(&app,&state,&owner,&csrf,json!({"type":"vnc","host":"127.0.0.1","port":vnc_port,"security":{"mode":"none"},"reconnect":{"enabled":false}})).await; + state.shutdown.cancel(); + wait_removed(&state, &terminal).await; + wait_removed(&state, &desktop).await; + assert!(state.sessions.lock().await.is_empty()); + echo_task.abort(); + rfb_task.abort(); + serving.abort(); +} diff --git a/src-tauri/crates/nyaterm-web/tests/transport.rs b/src-tauri/crates/nyaterm-web/tests/transport.rs new file mode 100644 index 00000000..4c5f3c3e --- /dev/null +++ b/src-tauri/crates/nyaterm-web/tests/transport.rs @@ -0,0 +1,1932 @@ +use axum::{ + Router, + body::{Body, to_bytes}, + http::{Request, StatusCode}, +}; +use futures_util::{SinkExt, StreamExt}; +use nyaterm_web::{auth, state::State}; +use russh::{ + Channel, ChannelId, Pty, + server::{self, Server as _, Session}, +}; +use serde_json::{Value, json}; +use std::{ + collections::HashMap, + sync::Arc, + time::{Duration, Instant}, +}; +use tokio::{ + net::TcpListener, + sync::{Mutex, mpsc}, +}; +use tokio_tungstenite::{ + connect_async, + tungstenite::{Message, client::IntoClientRequest}, +}; +use tokio_util::sync::CancellationToken; +use tower::ServiceExt; + +struct Echo { + output_cancel: CancellationToken, + output_task: Option>, + resize: mpsc::UnboundedSender<(u32, u32)>, + channels: HashMap>, + sftp_channels: std::collections::HashSet, + files: Arc>>>, + attributes: Arc>>, + posix_rename: Arc, +} +impl Clone for Echo { + fn clone(&self) -> Self { + Self { + output_cancel: CancellationToken::new(), + output_task: None, + resize: self.resize.clone(), + channels: HashMap::new(), + sftp_channels: Default::default(), + files: self.files.clone(), + attributes: self.attributes.clone(), + posix_rename: self.posix_rename.clone(), + } + } +} +impl server::Server for Echo { + type Handler = Self; + fn new_client(&mut self, _: Option) -> Self { + self.clone() + } +} +impl server::Handler for Echo { + type Error = russh::Error; + async fn auth_password( + &mut self, + user: &str, + password: &str, + ) -> Result { + Ok(if user == "test" && password == "ssh-secret" { + server::Auth::Accept + } else { + server::Auth::reject() + }) + } + async fn channel_open_session( + &mut self, + channel: Channel, + reply: server::ChannelOpenHandle, + _: &mut Session, + ) -> Result<(), Self::Error> { + self.channels.insert(channel.id(), channel); + reply.accept().await; + Ok(()) + } + async fn subsystem_request( + &mut self, + id: ChannelId, + name: &str, + s: &mut Session, + ) -> Result<(), Self::Error> { + assert_eq!(name, "sftp"); + self.sftp_channels.insert(id); + s.channel_success(id)?; + let channel = self.channels.remove(&id).unwrap(); + russh_sftp::server::run( + channel.into_stream(), + MemoryFiles { + files: self.files.clone(), + attributes: self.attributes.clone(), + posix_rename: self.posix_rename.clone(), + listed: false, + }, + ) + .await; + Ok(()) + } + async fn pty_request( + &mut self, + id: ChannelId, + _: &str, + _: u32, + _: u32, + _: u32, + _: u32, + _: &[(Pty, u32)], + s: &mut Session, + ) -> Result<(), Self::Error> { + s.channel_success(id)?; + Ok(()) + } + async fn shell_request(&mut self, id: ChannelId, s: &mut Session) -> Result<(), Self::Error> { + s.channel_success(id)?; + let mut channel = self.channels.remove(&id).unwrap(); + tokio::spawn(async move { + // Handler callbacks implement the echo, but russh also queues data + // and window notifications on the channel. Drain them so continuous + // output cannot fill this queue and stall the SSH session loop. + while channel.wait().await.is_some() {} + }); + Ok(()) + } + async fn exec_request( + &mut self, + id: ChannelId, + data: &[u8], + s: &mut Session, + ) -> Result<(), Self::Error> { + s.channel_success(id)?; + if !data.starts_with(b"kill -") { + s.data( + id, + b"PROCESS\t42\t1\troot\tSs\t0.4\t1.2\t1234\t5678\t01:02\tsshd\t/usr/sbin/sshd -D\n" + .to_vec(), + )?; + } + s.exit_status_request(id, 0)?; + s.eof(id)?; + s.close(id)?; + Ok(()) + } + async fn window_change_request( + &mut self, + _: ChannelId, + cols: u32, + rows: u32, + _: u32, + _: u32, + _: &mut Session, + ) -> Result<(), Self::Error> { + let _ = self.resize.send((cols, rows)); + Ok(()) + } + async fn data( + &mut self, + id: ChannelId, + data: &[u8], + s: &mut Session, + ) -> Result<(), Self::Error> { + if self.sftp_channels.contains(&id) { + return Ok(()); + } + if data == b"start-continuous-output" { + let h = s.handle(); + let cancel = self.output_cancel.clone(); + self.output_task = Some(tokio::spawn(async move { + loop { + tokio::select! { + _ = cancel.cancelled() => break, + result = h.data(id, vec![b'y'; 32768]) => { + if result.is_err() { break; } + } + } + } + })); + } else if data == b"stop-continuous-output" { + self.output_cancel.cancel(); + let task = self.output_task.take(); + let h = s.handle(); + tokio::spawn(async move { + if let Some(task) = task { + task.await.unwrap(); + } + // Queue the acknowledgement after all continuous output. Using + // Session::data here would overtake data queued via the handle. + h.data(id, b"continuous-output-stopped".to_vec()) + .await + .unwrap(); + }); + } else if data == b"large-and-close" { + let h = s.handle(); + tokio::spawn(async move { + let bytes = vec![b'x'; 3 * 1024 * 1024]; + for chunk in bytes.chunks(32768) { + h.data(id, chunk.to_vec()).await.unwrap(); + } + h.data(id, b"final-tail".to_vec()).await.unwrap(); + h.eof(id).await.unwrap(); + h.close(id).await.unwrap(); + }); + } else { + s.data(id, data.to_vec())?; + } + Ok(()) + } +} +use russh_sftp::protocol::{self as sf, StatusCode as SfCode}; +struct MemoryFiles { + files: Arc>>>, + attributes: Arc>>, + posix_rename: Arc, + listed: bool, +} +fn success(id: u32) -> sf::Status { + sf::Status { + id, + status_code: SfCode::Ok, + error_message: String::new(), + language_tag: String::new(), + } +} +fn attrs(size: usize) -> sf::FileAttributes { + sf::FileAttributes { + size: Some(size as u64), + permissions: Some(0o100644), + uid: Some(1000), + gid: Some(1000), + mtime: Some(1700000000), + atime: Some(1700000000), + ..Default::default() + } +} +impl russh_sftp::server::Handler for MemoryFiles { + type Error = SfCode; + fn unimplemented(&self) -> SfCode { + SfCode::OpUnsupported + } + async fn open( + &mut self, + id: u32, + filename: String, + pflags: sf::OpenFlags, + _: sf::FileAttributes, + ) -> Result { + let mut files = self.files.lock().unwrap(); + if pflags.contains(sf::OpenFlags::CREATE) { + files.entry(filename.clone()).or_default(); + } + if !files.contains_key(&filename) { + return Err(SfCode::NoSuchFile); + } + if pflags.contains(sf::OpenFlags::TRUNCATE) { + files.insert(filename.clone(), vec![]); + } + Ok(sf::Handle { + id, + handle: filename, + }) + } + async fn close(&mut self, id: u32, _: String) -> Result { + Ok(success(id)) + } + async fn read( + &mut self, + id: u32, + handle: String, + offset: u64, + len: u32, + ) -> Result { + let files = self.files.lock().unwrap(); + let file = files.get(&handle).ok_or(SfCode::NoSuchFile)?; + let offset = offset as usize; + if offset >= file.len() { + return Err(SfCode::Eof); + } + Ok(sf::Data { + id, + data: file[offset..(offset + len as usize).min(file.len())].to_vec(), + }) + } + async fn write( + &mut self, + id: u32, + handle: String, + offset: u64, + data: Vec, + ) -> Result { + let mut files = self.files.lock().unwrap(); + let file = files.get_mut(&handle).ok_or(SfCode::NoSuchFile)?; + let end = offset as usize + data.len(); + file.resize(file.len().max(end), 0); + file[offset as usize..end].copy_from_slice(&data); + Ok(success(id)) + } + async fn stat(&mut self, id: u32, path: String) -> Result { + if path == "/denied.bin" { + return Err(SfCode::PermissionDenied); + } + let files = self.files.lock().unwrap(); + let file = files.get(&path).ok_or(SfCode::NoSuchFile)?; + Ok(sf::Attrs { + id, + attrs: self + .attributes + .lock() + .unwrap() + .get(&path) + .cloned() + .unwrap_or_else(|| attrs(file.len())), + }) + } + async fn lstat(&mut self, id: u32, path: String) -> Result { + self.stat(id, path).await + } + async fn fstat(&mut self, id: u32, path: String) -> Result { + self.stat(id, path).await + } + async fn rename(&mut self, id: u32, old: String, new: String) -> Result { + let mut files = self.files.lock().unwrap(); + if files.contains_key(&new) { + return Err(SfCode::Failure); + } + let bytes = files.remove(&old).ok_or(SfCode::NoSuchFile)?; + files.insert(new.clone(), bytes); + let mut attributes = self.attributes.lock().unwrap(); + if let Some(value) = attributes.remove(&old) { + attributes.insert(new, value); + } + Ok(success(id)) + } + async fn remove(&mut self, id: u32, path: String) -> Result { + self.files.lock().unwrap().remove(&path); + self.attributes.lock().unwrap().remove(&path); + Ok(success(id)) + } + async fn setstat( + &mut self, + id: u32, + path: String, + update: sf::FileAttributes, + ) -> Result { + assert!( + update.size.is_none(), + "Attribute updates must not truncate files" + ); + assert!( + update.atime.is_none() && update.mtime.is_none(), + "Attribute updates must preserve timestamps" + ); + if !self.files.lock().unwrap().contains_key(&path) { + return Err(SfCode::NoSuchFile); + } + let mut attributes = self.attributes.lock().unwrap(); + let current = attributes.entry(path).or_insert_with(|| attrs(0)); + if let Some(mode) = update.permissions { + current.permissions = + Some(current.permissions.unwrap_or(0o100644) & 0o170000 | mode & 0o7777); + } + if update.uid.is_some() { + current.uid = update.uid; + } + if update.gid.is_some() { + current.gid = update.gid; + } + Ok(success(id)) + } + async fn mkdir( + &mut self, + id: u32, + path: String, + _: sf::FileAttributes, + ) -> Result { + self.files.lock().unwrap().insert(path.clone(), vec![]); + let mut value = attrs(0); + value.permissions = Some(0o40755); + self.attributes.lock().unwrap().insert(path, value); + Ok(success(id)) + } + async fn symlink( + &mut self, + id: u32, + target: String, + link: String, + ) -> Result { + // The OpenSSH wire convention sends target before link name. + let mut files = self.files.lock().unwrap(); + if files.contains_key(&link) { + return Err(SfCode::Failure); + } + files.insert(link.clone(), target.into_bytes()); + let mut value = attrs(0); + value.permissions = Some(0o120777); + self.attributes.lock().unwrap().insert(link, value); + Ok(success(id)) + } + async fn readlink(&mut self, id: u32, path: String) -> Result { + let files = self.files.lock().unwrap(); + let target = files.get(&path).ok_or(SfCode::NoSuchFile)?; + Ok(sf::Name { + id, + files: vec![sf::File::dummy(String::from_utf8(target.clone()).unwrap())], + }) + } + async fn extended( + &mut self, + id: u32, + request: String, + data: Vec, + ) -> Result { + if request != "posix-rename@openssh.com" + || !self.posix_rename.load(std::sync::atomic::Ordering::Relaxed) + { + return Err(SfCode::OpUnsupported); + } + let mut bytes = data.as_slice(); + let mut paths = vec![]; + for _ in 0..2 { + let len = u32::from_be_bytes(bytes[..4].try_into().unwrap()) as usize; + paths.push(String::from_utf8(bytes[4..4 + len].to_vec()).unwrap()); + bytes = &bytes[4 + len..]; + } + assert!(bytes.is_empty()); + let mut files = self.files.lock().unwrap(); + let bytes = files.remove(&paths[0]).ok_or(SfCode::NoSuchFile)?; + files.insert(paths[1].clone(), bytes); + let mut attributes = self.attributes.lock().unwrap(); + if let Some(value) = attributes.remove(&paths[0]) { + attributes.insert(paths[1].clone(), value); + } + Ok(sf::Packet::Status(success(id))) + } + async fn realpath(&mut self, id: u32, _: String) -> Result { + Ok(sf::Name { + id, + files: vec![sf::File::dummy("/")], + }) + } + async fn opendir(&mut self, id: u32, _: String) -> Result { + self.listed = false; + Ok(sf::Handle { + id, + handle: "directory".into(), + }) + } + async fn readdir(&mut self, id: u32, _: String) -> Result { + if self.listed { + return Err(SfCode::Eof); + } + self.listed = true; + Ok(sf::Name { + id, + files: self + .files + .lock() + .unwrap() + .iter() + .map(|(name, bytes)| { + sf::File::new(name.trim_start_matches('/'), attrs(bytes.len())) + }) + .collect(), + }) + } +} +fn state(host: String, base_path: &str) -> Arc { + Arc::new(State { + origin: format!("http://{host}"), + host, + base_path: base_path.into(), + secure_cookie: false, + password_hash: auth::digest("login-password-at-least-32-characters"), + logins: Mutex::new(HashMap::new()), + sessions: Mutex::new(HashMap::new()), + prompts: Mutex::new(HashMap::new()), + ai_streams: Mutex::new(HashMap::new()), + login_attempts: Mutex::new(vec![]), + mutation: Mutex::new(()), + shutdown: CancellationToken::new(), + }) +} +async fn upload_file( + app: &Router, + state: &State, + id: &str, + path: &str, + owner: &str, + csrf: &str, + body: Body, +) -> (StatusCode, Value) { + let response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri(format!("/nyaterm/api/sessions/{id}/upload?path={path}")) + .header("host", &state.host) + .header("origin", &state.origin) + .header("cookie", format!("{}={owner}", auth::COOKIE)) + .header("x-nyaterm-csrf", csrf) + .body(body) + .unwrap(), + ) + .await + .unwrap(); + let status = response.status(); + let bytes = to_bytes(response.into_body(), 4096).await.unwrap(); + (status, serde_json::from_slice(&bytes).unwrap()) +} +async fn wait_clean(files: &Arc>>>) { + let deadline = Instant::now() + Duration::from_secs(5); + while files.lock().unwrap().keys().any(|p| p.contains(".part")) { + assert!(Instant::now() < deadline, "Temporary upload files leaked"); + tokio::time::sleep(Duration::from_millis(10)).await; + } +} +async fn request( + app: &Router, + state: &State, + path: &str, + body: Option, + owner: Option<&str>, + csrf: Option<&str>, +) -> (StatusCode, axum::http::HeaderMap, Value) { + let mut builder = Request::builder() + .uri(format!("/nyaterm/api/{path}")) + .header("host", &state.host) + .header("origin", &state.origin) + .header("x-nyaterm-request", "1"); + if let Some(owner) = owner { + builder = builder.header("cookie", format!("{}={owner}", auth::COOKIE)); + } + if let Some(csrf) = csrf { + builder = builder.header("x-nyaterm-csrf", csrf); + } + if body.is_some() { + builder = builder + .method("POST") + .header("content-type", "application/json"); + } + let response = app + .clone() + .oneshot( + builder + .body( + body.map(|v| Body::from(v.to_string())) + .unwrap_or(Body::empty()), + ) + .unwrap(), + ) + .await + .unwrap(); + let status = response.status(); + let headers = response.headers().clone(); + let bytes = to_bytes(response.into_body(), 8 * 1024 * 1024) + .await + .unwrap(); + let value = serde_json::from_slice(&bytes).unwrap_or(Value::Null); + (status, headers, value) +} +async fn login(app: &Router, state: &State) -> (String, String) { + let (status, headers, value) = request( + app, + state, + "auth/login", + Some(json!({"password":"login-password-at-least-32-characters"})), + None, + None, + ) + .await; + assert_eq!(status, StatusCode::OK); + let token = headers["set-cookie"] + .to_str() + .unwrap() + .split(';') + .next() + .unwrap() + .split('=') + .nth(1) + .unwrap() + .to_owned(); + (token, value["csrf"].as_str().unwrap().into()) +} +async fn ws( + host: &str, + state: &State, + id: &str, + owner: &str, +) -> tokio_tungstenite::WebSocketStream> { + let mut r = format!("ws://{host}/nyaterm/api/sessions/{id}/terminal") + .into_client_request() + .unwrap(); + r.headers_mut() + .insert("origin", state.origin.parse().unwrap()); + r.headers_mut().insert( + "cookie", + format!("{}={owner}", auth::COOKIE).parse().unwrap(), + ); + connect_async(r).await.unwrap().0 +} +async fn binary( + socket: &mut tokio_tungstenite::WebSocketStream< + tokio_tungstenite::MaybeTlsStream, + >, +) -> Vec { + loop { + match tokio::time::timeout(Duration::from_secs(20), socket.next()) + .await + .unwrap() + .unwrap() + .unwrap() + { + Message::Binary(b) => return b.to_vec(), + Message::Ping(b) => socket.send(Message::Pong(b)).await.unwrap(), + other => panic!("Expected bytes, got {other:?}"), + } + } +} + +#[tokio::test] +async fn authenticated_ssh_vertical_slice_and_security() { + let data = tempfile::tempdir().unwrap(); + nyaterm_core::storage::init(data.path()).unwrap(); + nyaterm_core::utils::crypto::set_server_key_material([37; 32]); + let dist = tempfile::tempdir().unwrap(); + std::fs::write(dist.path().join("index.html"), "NyaTerm").unwrap(); + let web = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let host = web.local_addr().unwrap().to_string(); + let state = state(host.clone(), "/nyaterm"); + let app = nyaterm_web::router(state.clone(), dist.path().into()); + let serving = tokio::spawn(axum::serve(web, app.clone()).into_future()); + let (resize, mut resized) = mpsc::unbounded_channel(); + let ssh = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = ssh.local_addr().unwrap().port(); + let config = Arc::new(server::Config { + keys: vec![ + russh::keys::PrivateKey::random(&mut rand_new::rng(), russh::keys::Algorithm::Ed25519) + .unwrap(), + ], + auth_rejection_time: Duration::ZERO, + auth_rejection_time_initial: Some(Duration::ZERO), + // Exercise receiver backpressure even on platforms with fast reconnects. + channel_buffer_size: 4, + ..Default::default() + }); + let files = Arc::new(std::sync::Mutex::new(HashMap::new())); + let ssh_files = files.clone(); + let attributes = Arc::new(std::sync::Mutex::new(HashMap::new())); + let ssh_attributes = attributes.clone(); + let posix_rename = Arc::new(std::sync::atomic::AtomicBool::new(true)); + let ssh_posix_rename = posix_rename.clone(); + let ssh_task = tokio::spawn(async move { + Echo { + output_cancel: CancellationToken::new(), + output_task: None, + resize, + files: ssh_files, + attributes: ssh_attributes, + posix_rename: ssh_posix_rename, + channels: HashMap::new(), + sftp_channels: Default::default(), + } + .run_on_socket(config, &ssh) + .await + .unwrap(); + }); + assert_eq!( + request( + &app, + &state, + "commands/list_sessions", + Some(json!({})), + None, + None + ) + .await + .0, + StatusCode::UNAUTHORIZED + ); + let (owner, csrf) = login(&app, &state).await; + let (other, other_csrf) = login(&app, &state).await; + assert_eq!( + request( + &app, + &state, + "commands/list_sessions", + Some(json!({})), + Some(&owner), + None + ) + .await + .0, + StatusCode::FORBIDDEN + ); + let bad = Request::builder() + .uri("/nyaterm/") + .header("host", "evil.example") + .body(Body::empty()) + .unwrap(); + assert_eq!( + app.clone().oneshot(bad).await.unwrap().status(), + StatusCode::FORBIDDEN + ); + let static_response = app + .clone() + .oneshot( + Request::builder() + .uri("/nyaterm/settings") + .header("host", &host) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(static_response.status(), StatusCode::OK); + assert!( + static_response + .headers() + .contains_key("content-security-policy") + ); + let mut events = state.login(&owner).await.unwrap().events.subscribe(); + let args = json!({"config":{"encoding":"global","host":"127.0.0.1","port":port,"username":"test","auth":{"type":"password","password":"ssh-secret"}}}); + let (_, _, value) = request( + &app, + &state, + "sessions", + Some(args.clone()), + Some(&owner), + Some(&csrf), + ) + .await; + let id = value["session_id"].as_str().unwrap(); + // WebSocket ownership and exact Origin must be enforced during upgrade. + for (token, origin, expected) in [ + (&other, state.origin.as_str(), 404), + (&owner, "https://evil.example", 403), + ] { + let mut upgrade = format!("ws://{host}/nyaterm/api/sessions/{id}/terminal") + .into_client_request() + .unwrap(); + upgrade + .headers_mut() + .insert("origin", origin.parse().unwrap()); + upgrade.headers_mut().insert( + "cookie", + format!("{}={token}", auth::COOKIE).parse().unwrap(), + ); + let error = connect_async(upgrade).await.unwrap_err(); + match error { + tokio_tungstenite::tungstenite::Error::Http(response) => { + assert_eq!(response.status().as_u16(), expected) + } + _ => panic!("Expected rejected upgrade"), + } + } + let mut socket = ws(&host, &state, id, &owner).await; + let prompt = tokio::time::timeout(Duration::from_secs(10), events.recv()) + .await + .unwrap() + .unwrap(); + assert_eq!(prompt.event, "host-key-verify"); + assert_eq!(prompt.payload["isKeyChanged"], false); + let reply = json!({"requestId":prompt.payload["requestId"],"accepted":true}); + assert_eq!( + request( + &app, + &state, + "commands/respond_host_key_verify", + Some(reply.clone()), + Some(&other), + Some(&other_csrf) + ) + .await + .0, + StatusCode::FORBIDDEN + ); + assert_eq!( + request( + &app, + &state, + "commands/respond_host_key_verify", + Some(reply), + Some(&owner), + Some(&csrf) + ) + .await + .0, + StatusCode::OK + ); + assert_eq!( + request( + &app, + &state, + "commands/get_session_info", + Some(json!({"sessionId":id})), + Some(&other), + Some(&other_csrf) + ) + .await + .0, + StatusCode::NOT_FOUND + ); + socket + .send(Message::Binary( + b"hello \xe4\xbd\xa0\xe5\xa5\xbd".to_vec().into(), + )) + .await + .unwrap(); + assert_eq!(binary(&mut socket).await, b"hello \xe4\xbd\xa0\xe5\xa5\xbd"); + socket + .send(Message::Text( + json!({"type":"resize","cols":132,"rows":43}) + .to_string() + .into(), + )) + .await + .unwrap(); + assert_eq!( + tokio::time::timeout(Duration::from_secs(10), resized.recv()) + .await + .unwrap(), + Some((132, 43)) + ); + // Stream a body above the JSON limit through the real SFTP subsystem. + let payload = vec![71u8; 5 * 1024 * 1024]; + let upload = Request::builder() + .method("POST") + .uri(format!("/nyaterm/api/sessions/{id}/upload?path=/large.bin")) + .header("host", &host) + .header("origin", &state.origin) + .header("cookie", format!("{}={owner}", auth::COOKIE)) + .header("x-nyaterm-csrf", &csrf) + .header("content-length", payload.len()) + .body(Body::from(payload.clone())) + .unwrap(); + let response = app.clone().oneshot(upload).await.unwrap(); + let status = response.status(); + let response_body = to_bytes(response.into_body(), 1024).await.unwrap(); + assert_eq!( + status, + StatusCode::OK, + "{}", + String::from_utf8_lossy(&response_body) + ); + assert_eq!(files.lock().unwrap().get("/large.bin"), Some(&payload)); + let download = Request::builder() + .uri(format!( + "/nyaterm/api/sessions/{id}/download?path=/large.bin" + )) + .header("host", &host) + .header("cookie", format!("{}={owner}", auth::COOKIE)) + .body(Body::empty()) + .unwrap(); + let response = app.clone().oneshot(download).await.unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let bytes = to_bytes(response.into_body(), 6 * 1024 * 1024) + .await + .unwrap(); + assert_eq!(&bytes[..], payload); + let (_, _, entries) = request( + &app, + &state, + "commands/list_remote_dir", + Some(json!({"sessionId":id,"path":"/"})), + Some(&owner), + Some(&csrf), + ) + .await; + assert_eq!(entries[0]["name"], "large.bin"); + assert_eq!(entries[0]["mtime"], 1700000000); + assert_eq!(entries[0]["owner"], "1000"); + // Binary previews honor caller limits and session ownership. + files + .lock() + .unwrap() + .insert("/preview.png".into(), vec![0, 255, 128, 10]); + let (status, _, preview) = request( + &app, + &state, + "commands/read_remote_file_bytes", + Some(json!({"sessionId":id,"path":"/preview.png","maxBytes":4})), + Some(&owner), + Some(&csrf), + ) + .await; + assert_eq!(status, StatusCode::OK); + assert_eq!(preview["contentBytes"], json!([0, 255, 128, 10])); + assert_eq!( + request( + &app, + &state, + "commands/read_remote_file_bytes", + Some(json!({"sessionId":id,"path":"/large.bin","maxBytes":4})), + Some(&owner), + Some(&csrf) + ) + .await + .0, + StatusCode::BAD_REQUEST + ); + assert_eq!( + request( + &app, + &state, + "commands/read_remote_file_bytes", + Some(json!({"sessionId":id,"path":"/large.bin","maxBytes":6*1024*1024})), + Some(&other), + Some(&other_csrf) + ) + .await + .0, + StatusCode::NOT_FOUND + ); + // Copy endpoint paths are destination directories, not destination files. + let copy = json!({"request":{"source":{"sessionId":id,"kind":"remote","path":"/large.bin"},"target":{"sessionId":id,"kind":"remote","path":"/"},"fileName":"copied.bin","isDirectory":false,"duplicateStrategyOverride":"skip"}}); + assert_eq!( + request( + &app, + &state, + "commands/copy_file_entry", + Some(copy.clone()), + Some(&owner), + Some(&csrf) + ) + .await + .2, + "copied" + ); + assert_eq!(files.lock().unwrap().get("/copied.bin"), Some(&payload)); + assert_eq!( + request( + &app, + &state, + "commands/copy_file_entry", + Some(copy), + Some(&owner), + Some(&csrf) + ) + .await + .2, + "skipped" + ); + let move_entry = json!({"request":{"source":{"sessionId":id,"kind":"remote","path":"/copied.bin"},"target":{"sessionId":id,"kind":"remote","path":"/"},"fileName":"moved.bin","isDirectory":false}}); + assert_eq!( + request( + &app, + &state, + "commands/move_file_entry", + Some(move_entry), + Some(&owner), + Some(&csrf) + ) + .await + .2, + "copied" + ); + assert!(!files.lock().unwrap().contains_key("/copied.bin")); + assert_eq!(files.lock().unwrap().get("/moved.bin"), Some(&payload)); + assert_eq!( + request( + &app, + &state, + "commands/find_missing_remote_entries", + Some(json!({"sessionId":id,"paths":["/large.bin","/missing.bin"]})), + Some(&owner), + Some(&csrf) + ) + .await + .2, + json!(["/missing.bin"]) + ); + // Attribute updates preserve the unspecified ownership field and file type. + assert_eq!( + request( + &app, + &state, + "commands/create_remote_file", + Some(json!({"sessionId":id,"path":"/private.txt","mode":"0600"})), + Some(&owner), + Some(&csrf) + ) + .await + .0, + StatusCode::OK + ); + assert_eq!( + attributes.lock().unwrap()["/private.txt"].permissions, + Some(0o100600) + ); + assert_eq!(request(&app,&state,"commands/update_remote_file_attributes",Some(json!({"sessionId":id,"path":"/private.txt","update":{"owner":"1234","mode":"0640"}})),Some(&owner),Some(&csrf)).await.0,StatusCode::OK); + { + let values = attributes.lock().unwrap(); + let value = &values["/private.txt"]; + assert_eq!(value.uid, Some(1234)); + assert_eq!(value.gid, Some(1000)); + assert_eq!(value.permissions, Some(0o100640)); + } + assert_eq!( + request( + &app, + &state, + "commands/create_remote_dir", + Some(json!({"sessionId":id,"path":"/private-dir","mode":"0700"})), + Some(&owner), + Some(&csrf) + ) + .await + .0, + StatusCode::OK + ); + assert_eq!( + attributes.lock().unwrap()["/private-dir"].permissions, + Some(0o40700) + ); + assert_eq!( + request( + &app, + &state, + "commands/chmod_remote_file", + Some(json!({"sessionId":id,"path":"/private.txt","mode":"9999"})), + Some(&owner), + Some(&csrf) + ) + .await + .0, + StatusCode::BAD_REQUEST + ); + assert_eq!(request(&app,&state,"commands/update_remote_file_attributes",Some(json!({"sessionId":id,"path":"/private.txt","update":{"recursive":true,"mode":"0777"}})),Some(&owner),Some(&csrf)).await.0,StatusCode::BAD_REQUEST); + assert_eq!( + attributes.lock().unwrap()["/private.txt"].permissions, + Some(0o100640) + ); + // OpenSSH link order and atomic replacement are exercised over the wire. + assert_eq!( + request( + &app, + &state, + "commands/create_remote_symlink", + Some(json!({"sessionId":id,"linkPath":"/link","targetPath":"/private.txt"})), + Some(&owner), + Some(&csrf) + ) + .await + .0, + StatusCode::OK + ); + assert_eq!( + request( + &app, + &state, + "commands/get_file_properties", + Some(json!({"sessionId":id,"path":"/link"})), + Some(&owner), + Some(&csrf) + ) + .await + .2["symlink_target"], + "/private.txt" + ); + assert_eq!( + request( + &app, + &state, + "commands/update_remote_symlink_target", + Some(json!({"sessionId":id,"path":"/link","targetPath":"/preview.png"})), + Some(&owner), + Some(&csrf) + ) + .await + .0, + StatusCode::OK + ); + assert_eq!(files.lock().unwrap()["/link"], b"/preview.png"); + posix_rename.store(false, std::sync::atomic::Ordering::Relaxed); + assert_ne!( + request( + &app, + &state, + "commands/update_remote_symlink_target", + Some(json!({"sessionId":id,"path":"/link","targetPath":"/large.bin"})), + Some(&owner), + Some(&csrf) + ) + .await + .0, + StatusCode::OK + ); + assert_eq!(files.lock().unwrap()["/link"], b"/preview.png"); + assert!(!files.lock().unwrap().keys().any(|p| p.ends_with(".link"))); + posix_rename.store(true, std::sync::atomic::Ordering::Relaxed); + // Monitoring uses private exec channels and enforces session ownership. + let (status, _, processes) = request( + &app, + &state, + "commands/get_remote_processes", + Some(json!({"sessionId":id})), + Some(&owner), + Some(&csrf), + ) + .await; + assert_eq!(status, StatusCode::OK); + assert_eq!(processes[0]["pid"], 42); + assert_eq!( + request( + &app, + &state, + "commands/get_remote_processes", + Some(json!({"sessionId":id})), + Some(&other), + Some(&other_csrf) + ) + .await + .0, + StatusCode::NOT_FOUND + ); + assert_eq!( + request( + &app, + &state, + "commands/signal_remote_process", + Some(json!({"sessionId":id,"pid":42,"signal":"TERM; touch /bad"})), + Some(&owner), + Some(&csrf) + ) + .await + .0, + StatusCode::BAD_REQUEST + ); + assert_eq!( + request( + &app, + &state, + "commands/signal_remote_process", + Some(json!({"sessionId":id,"pid":42,"signal":"TERM"})), + Some(&owner), + Some(&csrf) + ) + .await + .0, + StatusCode::OK + ); + socket + .send(Message::Binary(b"exec-is-private".to_vec().into())) + .await + .unwrap(); + assert_eq!(binary(&mut socket).await, b"exec-is-private"); + assert_eq!( + request( + &app, + &state, + "commands/register_command_submission", + Some(json!({"sessionId":id,"command":" uptime "})), + Some(&owner), + Some(&csrf) + ) + .await + .0, + StatusCode::OK + ); + assert_eq!( + request( + &app, + &state, + "commands/get_command_history", + Some(json!({})), + Some(&owner), + Some(&csrf) + ) + .await + .2, + json!(["uptime"]) + ); + assert_eq!( + request( + &app, + &state, + "commands/register_command_confirmation_candidate", + Some(json!({"sessionId":id,"command":"whoami"})), + Some(&owner), + Some(&csrf) + ) + .await + .2, + false + ); + assert_eq!( + request( + &app, + &state, + "commands/register_command_submission", + Some(json!({"sessionId":id,"command":"should-not-record"})), + Some(&other), + Some(&other_csrf) + ) + .await + .0, + StatusCode::NOT_FOUND + ); + assert_eq!( + request( + &app, + &state, + "commands/fuzzy_search_history", + Some(json!({"pattern":"upt","limit":8})), + Some(&owner), + Some(&csrf) + ) + .await + .2[0]["command"], + "uptime" + ); + assert_eq!( + request( + &app, + &state, + "commands/delete_command_history", + Some(json!({"command":"uptime"})), + Some(&owner), + Some(&csrf) + ) + .await + .0, + StatusCode::OK + ); + // Real SFTP conflict semantics: ordinary rename may never replace an existing file. + let mut upload_settings = nyaterm_core::config::load_app_settings(&()).unwrap(); + let original_strategy = upload_settings.transfer.duplicate_strategy.clone(); + files + .lock() + .unwrap() + .insert("/collision.txt".into(), b"original".to_vec()); + upload_settings.transfer.duplicate_strategy = "skip".into(); + nyaterm_core::config::save_app_settings(&(), &upload_settings).unwrap(); + // A skipped body must not be polled, even if it would fail. + let bad_body = Body::from_stream(futures_util::stream::iter(vec![ + Err::(std::io::Error::other("must not read")), + ])); + let (status, result) = + upload_file(&app, &state, id, "/collision.txt", &owner, &csrf, bad_body).await; + assert_eq!(status, StatusCode::OK); + assert_eq!( + result, + json!({"bytes":0,"status":"skipped","path":"/collision.txt"}) + ); + assert_eq!(files.lock().unwrap()["/collision.txt"], b"original"); + upload_settings.transfer.duplicate_strategy = "rename".into(); + nyaterm_core::config::save_app_settings(&(), &upload_settings).unwrap(); + let (status, renamed) = upload_file( + &app, + &state, + id, + "/collision.txt", + &owner, + &csrf, + Body::from("renamed"), + ) + .await; + assert_eq!(status, StatusCode::OK); + let renamed_path = renamed["path"].as_str().unwrap(); + assert!(renamed_path.starts_with("/collision.txt.")); + uuid::Uuid::parse_str(renamed_path.trim_start_matches("/collision.txt.")).unwrap(); + assert_eq!(renamed["status"], "completed"); + assert_eq!(files.lock().unwrap()[renamed_path], b"renamed"); + upload_settings.transfer.duplicate_strategy = "overwrite".into(); + nyaterm_core::config::save_app_settings(&(), &upload_settings).unwrap(); + assert_eq!( + upload_file( + &app, + &state, + id, + "/collision.txt", + &owner, + &csrf, + Body::from("replaced") + ) + .await + .0, + StatusCode::OK + ); + assert_eq!(files.lock().unwrap()["/collision.txt"], b"replaced"); + posix_rename.store(false, std::sync::atomic::Ordering::Relaxed); + let (status, result) = upload_file( + &app, + &state, + id, + "/collision.txt", + &owner, + &csrf, + Body::from("unsafe"), + ) + .await; + assert_eq!(status, StatusCode::BAD_REQUEST); + assert!(result["error"].as_str().unwrap().contains("Atomic")); + assert_eq!(files.lock().unwrap()["/collision.txt"], b"replaced"); + posix_rename.store(true, std::sync::atomic::Ordering::Relaxed); + wait_clean(&files).await; + files.lock().unwrap().insert("/upload-dir".into(), vec![]); + let mut directory_attrs = attrs(0); + directory_attrs.permissions = Some(0o40755); + attributes + .lock() + .unwrap() + .insert("/upload-dir".into(), directory_attrs); + assert_eq!( + upload_file( + &app, + &state, + id, + "/upload-dir", + &owner, + &csrf, + Body::from("unsafe") + ) + .await + .0, + StatusCode::BAD_REQUEST + ); + assert_eq!( + upload_file( + &app, + &state, + id, + "/denied.bin", + &owner, + &csrf, + Body::from("unsafe") + ) + .await + .0, + StatusCode::BAD_REQUEST + ); + assert!(!files.lock().unwrap().contains_key("/denied.bin")); + assert_eq!( + upload_file( + &app, + &state, + id, + "/not-owned", + &other, + &other_csrf, + Body::from("unsafe") + ) + .await + .0, + StatusCode::NOT_FOUND + ); + upload_settings.transfer.duplicate_strategy = "ask".into(); + nyaterm_core::config::save_app_settings(&(), &upload_settings).unwrap(); + let mut other_events = state.login(&other).await.unwrap().events.subscribe(); + for choice in ["skip", "overwrite"] { + while events.try_recv().is_ok() {} + let upload_app = app.clone(); + let state_for_upload = state.clone(); + let (upload_id, upload_owner, upload_csrf) = (id.to_string(), owner.clone(), csrf.clone()); + let pending = tokio::spawn(async move { + upload_file( + &upload_app, + &state_for_upload, + &upload_id, + "/collision.txt", + &upload_owner, + &upload_csrf, + Body::from("asked"), + ) + .await + }); + let prompt = tokio::time::timeout(Duration::from_secs(5), async { + loop { + let event = events.recv().await.unwrap(); + if event.event == "transfer-duplicate-request" { + break event; + } + } + }) + .await + .unwrap(); + assert!( + other_events.try_recv().is_err(), + "Conflict prompts must be owner-only" + ); + let reply = json!({"requestId":prompt.payload["requestId"],"action":choice}); + assert_eq!( + request( + &app, + &state, + "commands/respond_transfer_duplicate", + Some(reply.clone()), + Some(&other), + Some(&other_csrf) + ) + .await + .0, + StatusCode::FORBIDDEN + ); + assert_eq!( + request( + &app, + &state, + "commands/respond_transfer_duplicate", + Some(reply), + Some(&owner), + Some(&csrf) + ) + .await + .0, + StatusCode::OK + ); + let (status, result) = pending.await.unwrap(); + assert_eq!(status, StatusCode::OK); + assert_eq!( + result["status"], + if choice == "skip" { + "skipped" + } else { + "completed" + } + ); + } + assert_eq!(files.lock().unwrap()["/collision.txt"], b"asked"); + // Disconnect while waiting for a conflict must dispose the owner prompt. + while events.try_recv().is_ok() {} + let (upload_app, upload_state, upload_id, upload_owner, upload_csrf) = ( + app.clone(), + state.clone(), + id.to_owned(), + owner.clone(), + csrf.clone(), + ); + let disconnected = tokio::spawn(async move { + upload_file( + &upload_app, + &upload_state, + &upload_id, + "/collision.txt", + &upload_owner, + &upload_csrf, + Body::from("disconnect"), + ) + .await + }); + tokio::time::timeout(Duration::from_secs(5), async { + loop { + if events.recv().await.unwrap().event == "transfer-duplicate-request" { + break; + } + } + }) + .await + .unwrap(); + disconnected.abort(); + let _ = disconnected.await; + tokio::time::timeout(Duration::from_secs(5), async { + while !state.prompts.lock().await.is_empty() { + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .unwrap(); + // Explicit session closure cancels a blocked body and cleans its temporary file. + let (_, _, spare) = request( + &app, + &state, + "sessions", + Some(args.clone()), + Some(&owner), + Some(&csrf), + ) + .await; + let spare_id = spare["session_id"].as_str().unwrap(); + let mut spare_socket = ws(&host, &state, spare_id, &owner).await; + spare_socket + .send(Message::Binary(b"ready".to_vec().into())) + .await + .unwrap(); + assert_eq!(binary(&mut spare_socket).await, b"ready"); + let (upload_app, upload_state, upload_id, upload_owner, upload_csrf) = ( + app.clone(), + state.clone(), + spare_id.to_owned(), + owner.clone(), + csrf.clone(), + ); + let body = Body::from_stream( + futures_util::stream::once(async { + Ok::<_, std::io::Error>(axum::body::Bytes::from_static(b"partial")) + }) + .chain(futures_util::stream::pending()), + ); + let cancelled = tokio::spawn(async move { + upload_file( + &upload_app, + &upload_state, + &upload_id, + "/cancelled.bin", + &upload_owner, + &upload_csrf, + body, + ) + .await + }); + tokio::time::timeout(Duration::from_secs(5), async { + while !files + .lock() + .unwrap() + .keys() + .any(|p| p.starts_with("/cancelled.bin.") && p.ends_with(".part")) + { + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .unwrap(); + assert_eq!( + request( + &app, + &state, + "commands/close_session", + Some(json!({"sessionId":spare_id})), + Some(&owner), + Some(&csrf) + ) + .await + .0, + StatusCode::OK + ); + assert_eq!( + tokio::time::timeout(Duration::from_secs(5), cancelled) + .await + .unwrap() + .unwrap() + .0, + StatusCode::BAD_REQUEST + ); + wait_clean(&files).await; + assert!(!files.lock().unwrap().contains_key("/cancelled.bin")); + upload_settings.transfer.duplicate_strategy = original_strategy; + nyaterm_core::config::save_app_settings(&(), &upload_settings).unwrap(); + wait_clean(&files).await; + let body = Body::from_stream(futures_util::stream::iter(vec![ + Ok(axum::body::Bytes::from_static(b"partial")), + Err(std::io::Error::other("test interrupted upload")), + ])); + let upload = Request::builder() + .method("POST") + .uri(format!( + "/nyaterm/api/sessions/{id}/upload?path=/interrupted.bin" + )) + .header("host", &host) + .header("origin", &state.origin) + .header("cookie", format!("{}={owner}", auth::COOKIE)) + .header("x-nyaterm-csrf", &csrf) + .body(body) + .unwrap(); + assert_eq!( + app.clone().oneshot(upload).await.unwrap().status(), + StatusCode::BAD_REQUEST + ); + let deadline = Instant::now() + Duration::from_secs(5); + while files.lock().unwrap().keys().any(|p| p.contains(".part")) { + assert!(Instant::now() < deadline); + tokio::time::sleep(Duration::from_millis(10)).await; + } + assert!(!files.lock().unwrap().contains_key("/interrupted.bin")); + // Credential plaintext is only returned by the explicit authenticated getter. + let (_,_,credential)=request(&app,&state,"commands/save_credential",Some(json!({"entry":{"id":"","name":"Test","username":"test","password":"credential-secret"}})),Some(&owner),Some(&csrf)).await; + let (_, _, list) = request( + &app, + &state, + "commands/get_saved_credentials", + Some(json!({})), + Some(&owner), + Some(&csrf), + ) + .await; + assert_eq!(list[0]["password"], Value::Null); + assert_eq!(list[0]["has_password"], true); + let stored = nyaterm_core::config::load_credentials(&()).unwrap(); + assert_ne!( + stored.credentials[0].password.as_deref(), + Some("credential-secret") + ); + let (_, _, plain) = request( + &app, + &state, + "commands/get_saved_credential_password", + Some(json!({"id":credential})), + Some(&owner), + Some(&csrf), + ) + .await; + assert_eq!(plain, "credential-secret"); + // Abrupt transport loss during continuous SSH output must detach, not + // cancel the remote session. Keep the reaper active throughout recovery. + let reaper = tokio::spawn(nyaterm_web::reap(state.clone())); + let session = state.session(&owner, id).await.unwrap(); + socket + .send(Message::Binary(b"start-continuous-output".to_vec().into())) + .await + .unwrap(); + for _ in 0..3 { + assert!(binary(&mut socket).await.iter().all(|byte| *byte == b'y')); + drop(socket); // No WebSocket close handshake: simulate a lost network. + tokio::time::timeout(Duration::from_secs(15), async { + while session.attached.load(std::sync::atomic::Ordering::Acquire) { + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("Lost WebSocket must release the attachment"); + assert!(!session.cancel.is_cancelled(), "Disconnect cancelled SSH"); + let current = state.session(&owner, id).await.unwrap(); + assert!(Arc::ptr_eq(¤t, &session)); + assert!(session.detached_at.lock().unwrap().elapsed() < Duration::from_secs(30)); + socket = ws(&host, &state, id, &owner).await; + } + socket + .send(Message::Binary(b"stop-continuous-output".to_vec().into())) + .await + .unwrap(); + tokio::time::timeout(Duration::from_secs(15), async { + while binary(&mut socket).await != b"continuous-output-stopped" {} + }) + .await + .expect("Input must still work while the remote is producing output"); + reaper.abort(); + socket.close(None).await.unwrap(); + drop(socket); + tokio::time::sleep(Duration::from_millis(100)).await; + let mut socket = ws(&host, &state, id, &owner).await; + socket + .send(Message::Binary(b"after-reattach".to_vec().into())) + .await + .unwrap(); + assert_eq!(binary(&mut socket).await, b"after-reattach"); + socket + .send(Message::Binary(b"large-and-close".to_vec().into())) + .await + .unwrap(); + let mut output = Vec::new(); + loop { + match tokio::time::timeout(Duration::from_secs(20), socket.next()) + .await + .unwrap() + .unwrap() + .unwrap() + { + Message::Binary(b) => output.extend_from_slice(&b), + Message::Text(t) => { + assert_eq!(serde_json::from_str::(&t).unwrap()["type"], "closed"); + break; + } + Message::Ping(b) => socket.send(Message::Pong(b)).await.unwrap(), + _ => {} + } + } + assert_eq!(output.len(), 3 * 1024 * 1024 + 10); + assert!(output.ends_with(b"final-tail")); + let deadline = Instant::now() + Duration::from_secs(10); + while !state.sessions.lock().await.is_empty() { + assert!(Instant::now() < deadline); + tokio::time::sleep(Duration::from_millis(10)).await; + } + // The existing saved-connection shape must resolve encrypted auth and honor SFTP disabling. + let (status, _, saved) = request(&app,&state,"commands/save_connection",Some(json!({"connection":{"id":"","name":"Saved fixture","type":"ssh","host":"127.0.0.1","port":port,"username":"test","auth":{"mode":"password","password":"ssh-secret"},"sftp":{"enabled":false},"terminal_type":"vt100"}})),Some(&owner),Some(&csrf)).await; + assert_eq!(status, StatusCode::OK); + let (_, _, listed) = request( + &app, + &state, + "commands/get_saved_connections", + Some(json!({})), + Some(&owner), + Some(&csrf), + ) + .await; + assert_eq!(listed[0]["auth"]["password"], Value::Null); + assert_eq!(listed[0]["auth"]["has_password"], true); + let (status, _, created) = request( + &app, + &state, + "sessions", + Some(json!({"connectionId":saved})), + Some(&owner), + Some(&csrf), + ) + .await; + assert_eq!(status, StatusCode::OK); + let saved_id = created["session_id"].as_str().unwrap(); + let mut saved_socket = ws(&host, &state, saved_id, &owner).await; + saved_socket + .send(Message::Binary(b"saved-connection".to_vec().into())) + .await + .unwrap(); + assert_eq!(binary(&mut saved_socket).await, b"saved-connection"); + let (_, _, info) = request( + &app, + &state, + "commands/get_session_info", + Some(json!({"sessionId":saved_id})), + Some(&owner), + Some(&csrf), + ) + .await; + assert_eq!(info["sftp_available"], false); + assert_eq!( + request( + &app, + &state, + "commands/get_home_dir", + Some(json!({"sessionId":saved_id})), + Some(&owner), + Some(&csrf) + ) + .await + .0, + StatusCode::NOT_IMPLEMENTED + ); + assert_eq!( + request( + &app, + &state, + "commands/close_session", + Some(json!({"sessionId":saved_id})), + Some(&owner), + Some(&csrf) + ) + .await + .0, + StatusCode::OK + ); + let deadline = Instant::now() + Duration::from_secs(10); + while state.sessions.lock().await.contains_key(saved_id) { + assert!(Instant::now() < deadline); + tokio::time::sleep(Duration::from_millis(10)).await; + } + // Known-host replacement still requires approval; rejection closes the session. + nyaterm_core::storage::replace_known_host_for_host( + &format!("[127.0.0.1]:{port}"), + &format!("[127.0.0.1]:{port} ssh-ed25519 AAAABOGUS"), + ) + .unwrap(); + while events.try_recv().is_ok() {} + let (_, _, value) = request( + &app, + &state, + "sessions", + Some(args), + Some(&owner), + Some(&csrf), + ) + .await; + let id = value["session_id"].as_str().unwrap(); + let prompt = tokio::time::timeout(Duration::from_secs(10), events.recv()) + .await + .unwrap() + .unwrap(); + assert_eq!(prompt.payload["isKeyChanged"], true); + request( + &app, + &state, + "commands/respond_host_key_verify", + Some(json!({"requestId":prompt.payload["requestId"],"accepted":false})), + Some(&owner), + Some(&csrf), + ) + .await; + // Cancellation while a host-key prompt is pending releases the request and SSH task. + let deadline = Instant::now() + Duration::from_secs(10); + while state.sessions.lock().await.contains_key(id) { + assert!(Instant::now() < deadline); + tokio::time::sleep(Duration::from_millis(10)).await; + } + while events.try_recv().is_ok() {} + let (_,_,pending) = request(&app,&state,"sessions",Some(json!({"createRequestId":"cancel-fixture","config":{"host":"127.0.0.1","port":port,"username":"test","auth":{"type":"password","password":"ssh-secret"}}})),Some(&owner),Some(&csrf)).await; + let prompt = tokio::time::timeout(Duration::from_secs(10), events.recv()) + .await + .unwrap() + .unwrap(); + assert_eq!(prompt.event, "host-key-verify"); + assert_eq!( + request( + &app, + &state, + "commands/cancel_session_creation", + Some(json!({"createRequestId":"cancel-fixture"})), + Some(&owner), + Some(&csrf) + ) + .await + .0, + StatusCode::OK + ); + let deadline = Instant::now() + Duration::from_secs(10); + while state + .sessions + .lock() + .await + .contains_key(pending["session_id"].as_str().unwrap()) + { + assert!(Instant::now() < deadline); + tokio::time::sleep(Duration::from_millis(10)).await; + } + let prompt_id = prompt.payload["requestId"].as_str().unwrap(); + assert!(!state.prompts.lock().await.contains_key(prompt_id)); + // The browser AI path uses the existing provider stream and mandatory redaction. + let provider = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let provider_url = format!("http://{}/v1/", provider.local_addr().unwrap()); + let (observed, mut observed_requests) = mpsc::unbounded_channel::(); + let provider_app=Router::new().route("/v1/chat/completions",axum::routing::post(move |headers:axum::http::HeaderMap,axum::Json(body):axum::Json|{let observed=observed.clone();async move {assert_eq!(headers["authorization"],"Bearer ai-provider-secret");let _=observed.send(body);([("content-type","text/event-stream")],"data: {\"id\":\"reply\",\"object\":\"chat.completion.chunk\",\"model\":\"test-model\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"Hello from shared AI\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"reply\",\"object\":\"chat.completion.chunk\",\"model\":\"test-model\",\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"stop\"}]}\n\ndata: [DONE]\n\n")}})); + let provider_task = tokio::spawn(axum::serve(provider, provider_app).into_future()); + let mut settings = nyaterm_core::config::load_app_settings(&()).unwrap(); + settings.ai.enabled = true; + settings.ai.record_history = true; + settings.ai.provider_credentials=vec![serde_json::from_value(json!({"id":"test-provider","name":"Test","provider_kind":"openai_compatible","base_url":provider_url,"api_key":"ai-provider-secret","enabled":true})).unwrap()]; + settings.ai.models=vec![serde_json::from_value(json!({"id":"test-provider:test-model","name":"test-model","provider_kind":"openai_compatible","credential_id":"test-provider","enabled":true,"source":"manual"})).unwrap()]; + settings.ai.default_model_id = Some("test-provider:test-model".into()); + settings.ai = nyaterm_core::config::encrypt_ai_settings(settings.ai).unwrap(); + settings.cloud_sync = + nyaterm_core::config::encrypt_cloud_sync_settings(settings.cloud_sync).unwrap(); + nyaterm_core::config::save_app_settings(&(), &settings).unwrap(); + while events.try_recv().is_ok() {} + let forbidden_context = json!({"request":{"action":"explain_output","userInput":"Explain","context":{},"mode":"ask","targetContexts":[{"target":{"terminalSessionId":"not-owned","label":"Other","sessionType":"SSH"},"context":{}}]}}); + assert_eq!( + request( + &app, + &state, + "commands/start_ai_chat_stream", + Some(forbidden_context), + Some(&owner), + Some(&csrf) + ) + .await + .0, + StatusCode::NOT_FOUND + ); + let ai_request = json!({"request":{"streamId":"test-ai-stream","sessionId":"test-ai-history","action":"explain_output","userInput":"Please explain password=do-not-send-this-secret","context":{},"targetContexts":[{"context":{"terminalOutput":"password=target-context-secret"}}],"mode":"ask"}}); + assert_eq!( + request( + &app, + &state, + "commands/start_ai_chat_stream", + Some(ai_request), + Some(&owner), + Some(&csrf) + ) + .await + .0, + StatusCode::OK + ); + let observed = tokio::time::timeout(Duration::from_secs(10), observed_requests.recv()) + .await + .unwrap() + .unwrap(); + assert!(!observed.to_string().contains("do-not-send-this-secret")); + assert!(!observed.to_string().contains("target-context-secret")); + let deadline = Instant::now() + Duration::from_secs(10); + loop { + assert!(Instant::now() < deadline); + let event = tokio::time::timeout(Duration::from_secs(10), events.recv()) + .await + .unwrap() + .unwrap(); + if event.event == "ai-stream-test-ai-stream" && event.payload["type"] == "done" { + assert!( + event.payload["message"]["content"] + .as_str() + .unwrap() + .contains("Hello from shared AI") + ); + break; + } + if event.event == "ai-stream-test-ai-stream" { + assert_ne!(event.payload["type"], "error", "{}", event.payload); + } + } + let (_, _, history) = request( + &app, + &state, + "commands/get_ai_messages", + Some(json!({"sessionId":"test-ai-history"})), + Some(&owner), + Some(&csrf), + ) + .await; + assert_eq!(history.as_array().unwrap().len(), 2); + assert!(!history.to_string().contains("do-not-send-this-secret")); + assert_eq!( + request( + &app, + &state, + "commands/clear_ai_history", + Some(json!({})), + Some(&owner), + Some(&csrf) + ) + .await + .0, + StatusCode::OK + ); + assert!( + nyaterm_core::core::ai::history::get_ai_sessions(&()) + .unwrap() + .is_empty() + ); + provider_task.abort(); + nyaterm_core::utils::crypto::verify_master_key_token().unwrap(); + nyaterm_core::utils::crypto::set_server_key_material([38; 32]); + assert!(nyaterm_core::utils::crypto::verify_master_key_token().is_err()); + nyaterm_core::utils::crypto::set_server_key_material([37; 32]); + nyaterm_core::utils::crypto::verify_master_key_token().unwrap(); + assert_eq!( + request( + &app, + &state, + "auth/logout", + Some(json!({})), + Some(&owner), + Some(&csrf) + ) + .await + .0, + StatusCode::OK + ); + assert!(state.login(&owner).await.is_err()); + assert!(state.session(&owner, id).await.is_err()); + state.shutdown.cancel(); + serving.abort(); + ssh_task.abort(); +} diff --git a/src-tauri/src/cmd/ai.rs b/src-tauri/src/cmd/ai.rs index 0919ef41..0bddbe51 100644 --- a/src-tauri/src/cmd/ai.rs +++ b/src-tauri/src/cmd/ai.rs @@ -109,71 +109,7 @@ pub fn reveal_ai_provider_api_key(credential_id: String) -> AppResult { crypto::decrypt(encrypted_key) } -fn merge_model_discoveries( - settings: &config::AiSettings, - discoveries: Vec, -) -> Vec { - let old_by_id = settings - .models - .iter() - .map(|model| (model.id.as_str(), model)) - .collect::>(); - let now = time::OffsetDateTime::now_utc() - .format(&time::format_description::well_known::Rfc3339) - .unwrap_or_else(|_| String::new()); - let mut seen = std::collections::HashSet::new(); - let mut merged = Vec::new(); - - for item in discoveries { - if !seen.insert(item.id.clone()) { - continue; - } - if let Some(old) = old_by_id.get(item.id.as_str()) { - let mut model = (*old).clone(); - model.last_seen_at = Some(now.clone()); - merged.push(model); - } else { - merged.push(config::AiModelConfigItem { - id: item.id, - name: item.name, - backend: item.backend, - provider_kind: item.provider_kind, - credential_id: item.credential_id, - enabled: false, - source: item.source, - last_seen_at: Some(now.clone()), - supported_reasoning_efforts: None, - }); - } - } - - for old in &settings.models { - if !seen.contains(&old.id) { - merged.push(old.clone()); - } - } - - merged.sort_by(|left, right| left.name.cmp(&right.name)); - merged -} - -fn update_default_model_id( - settings: &config::AiSettings, - models: &[config::AiModelConfigItem], -) -> Option { - if let Some(default_model_id) = settings.default_model_id.as_deref() { - if models - .iter() - .any(|model| model.enabled && model.id == default_model_id) - { - return Some(default_model_id.to_string()); - } - } - models - .iter() - .find(|model| model.enabled) - .map(|model| model.id.clone()) -} +use nyaterm_core::core::ai::catalog::{merge_model_discoveries, update_default_model_id}; #[tauri::command] pub fn cancel_ai_chat_stream(stream_id: String) -> AppResult<()> { diff --git a/src-tauri/src/cmd/connection.rs b/src-tauri/src/cmd/connection.rs index a0052f8b..be3788d1 100644 --- a/src-tauri/src/cmd/connection.rs +++ b/src-tauri/src/cmd/connection.rs @@ -6,7 +6,11 @@ use crate::core::{QuickCommandsImportResult, QuickCommandsImportSource, QuickCom use crate::error::{AppError, AppResult}; use crate::utils::crypto; use base64::Engine; -use std::collections::HashSet; +#[cfg(test)] +use nyaterm_core::services::{ + normalize_connection_for_save, validate_proxy_jump_config, validate_rdp_config, + validate_sftp_settings_config, validate_vnc_config, +}; use std::io::Cursor; use std::path::Path; use std::sync::Arc; @@ -169,11 +173,6 @@ fn is_supported_connection_icon_format(format: image::ImageFormat) -> bool { ) } -fn normalize_connection_for_save(connection: &mut SavedConnection) { - config::migrate_legacy_ssh_agent_settings(connection); - config::migrate_legacy_asset_tags(connection); -} - fn validate_ssh_agent_forwarding_identity_inputs( forwarding_config: &config::SshAgentForwardingConfig, ) -> AppResult<()> { @@ -198,99 +197,12 @@ pub async fn get_ssh_agent_forwarding_identities( } #[tauri::command] -pub fn save_connection( - app: tauri::AppHandle, - mut connection: SavedConnection, -) -> AppResult { - let mut cfg = config::load_config(&app)?; - - if connection.id.is_empty() { - connection.id = uuid::Uuid::new_v4().to_string(); - } - let target_id = connection.id.clone(); - let existing = cfg.connections.iter().find(|c| c.id == target_id).cloned(); - - normalize_connection_for_save(&mut connection); - - config::validate_ssh_agent_settings(&connection.config)?; - validate_proxy_jump_config(&connection, &cfg.connections)?; +pub fn save_connection(app: tauri::AppHandle, connection: SavedConnection) -> AppResult { validate_local_terminal_config(&connection)?; - validate_ssh_algorithm_config(&connection)?; - validate_sftp_settings_config(&connection)?; - validate_rdp_config(&connection)?; - validate_vnc_config(&connection)?; - - if let Some(ref mut auth) = connection.auth { - // account_id: Some("") means explicitly cleared, None means preserve existing - match auth.account_id.as_deref() { - Some("") => auth.account_id = None, - None => { - auth.account_id = existing - .as_ref() - .and_then(|entry| entry.auth.as_ref()) - .and_then(|entry| entry.account_id.clone()); - } - _ => {} - } - - // password_source: Some("") means legacy/default behavior, None means preserve existing - match auth.password_source.as_deref() { - Some("") => auth.password_source = None, - None => { - auth.password_source = existing - .as_ref() - .and_then(|entry| entry.auth.as_ref()) - .and_then(|entry| entry.password_source.clone()); - } - _ => {} - } - - // password_id: Some("") means explicitly cleared, None means preserve existing - match auth.password_id.as_deref() { - Some("") => auth.password_id = None, - None => { - auth.password_id = existing - .as_ref() - .and_then(|e| e.auth.as_ref()) - .and_then(|a| a.password_id.clone()); - } - _ => {} - } - - if matches!( - connection.config, - config::ConnectionType::Ssh { .. } | config::ConnectionType::Telnet { .. } - ) && auth.account_id.as_deref().is_some_and(|id| !id.is_empty()) - { - auth.password_id = None; - } - - // password: non-empty = encrypt new value, "" = explicitly clear, None = preserve - auth.password = match auth.password.as_deref() { - Some(plain) if !plain.is_empty() => Some(crypto::encrypt(plain)?), - Some("") => None, - None => existing - .as_ref() - .and_then(|e| e.auth.as_ref()) - .and_then(|a| a.password.clone()), - _ => None, - }; - auth.has_password = false; - } - - if let Some(existing_connection) = existing.as_ref() { - connection.asset = existing_connection.asset.clone(); - } - - if let Some(ex) = cfg.connections.iter_mut().find(|c| c.id == target_id) { - *ex = connection; - } else { - cfg.connections.push(connection); - } - config::save_config(&app, &cfg)?; + let id = nyaterm_core::services::save_connection(&app, connection)?; let _ = app.emit("connections-changed", ()); - schedule_cloud_sync_notify(app.clone()); - Ok(target_id) + schedule_cloud_sync_notify(app); + Ok(id) } fn update_connection_icon_in_config( @@ -446,37 +358,6 @@ pub fn update_connection_icon( Ok(()) } -fn validate_ssh_algorithm_config(connection: &SavedConnection) -> AppResult<()> { - if !matches!(connection.config, config::ConnectionType::Ssh { .. }) { - return Ok(()); - } - - let Some(preferences) = connection.ssh_algorithms.as_ref() else { - return Ok(()); - }; - - crate::core::ssh::validate_ssh_algorithm_preferences(preferences) -} - -fn validate_sftp_settings_config(connection: &SavedConnection) -> AppResult<()> { - if !matches!(connection.config, config::ConnectionType::Ssh { .. }) { - return Ok(()); - } - - let timeout_ms = connection.sftp.shell_detection_timeout_ms; - if !(config::MIN_SFTP_SHELL_DETECTION_TIMEOUT_MS..=config::MAX_SFTP_SHELL_DETECTION_TIMEOUT_MS) - .contains(&timeout_ms) - { - return Err(AppError::Config(format!( - "SFTP shell detection timeout must be between {} and {} ms", - config::MIN_SFTP_SHELL_DETECTION_TIMEOUT_MS, - config::MAX_SFTP_SHELL_DETECTION_TIMEOUT_MS - ))); - } - - Ok(()) -} - fn validate_local_terminal_config(connection: &SavedConnection) -> AppResult<()> { let config::ConnectionType::LocalTerminal { shell_path, @@ -508,104 +389,6 @@ fn validate_local_terminal_config(connection: &SavedConnection) -> AppResult<()> Ok(()) } -fn validate_rdp_config(connection: &SavedConnection) -> AppResult<()> { - let config::ConnectionType::Rdp { - host, - port, - username, - security, - display, - clipboard, - reconnect, - .. - } = &connection.config - else { - return Ok(()); - }; - - if host.trim().is_empty() { - return Err(AppError::Config("RDP host is required".to_string())); - } - if *port == 0 { - return Err(AppError::Config( - "RDP port must be between 1 and 65535".to_string(), - )); - } - if username.trim().is_empty() { - return Err(AppError::Config("RDP username is required".to_string())); - } - if !matches!( - security.certificate_policy.as_str(), - "strict" | "prompt" | "accept-temporarily" - ) { - return Err(AppError::Config( - "RDP certificate policy is invalid".to_string(), - )); - } - if !matches!(display.mode.as_str(), "fit-window" | "fixed" | "native") { - return Err(AppError::Config("RDP display mode is invalid".to_string())); - } - if !(640..=7680).contains(&display.width) || !(480..=4320).contains(&display.height) { - return Err(AppError::Config( - "RDP display size is outside the supported range".to_string(), - )); - } - if !matches!(display.color_depth, 16 | 24 | 32) { - return Err(AppError::Config("RDP color depth is invalid".to_string())); - } - if !matches!( - clipboard.mode.as_str(), - "disabled" | "text-only" | "text-and-files" - ) { - return Err(AppError::Config( - "RDP clipboard mode is invalid".to_string(), - )); - } - if reconnect.max_attempts > 20 { - return Err(AppError::Config( - "RDP reconnect attempts must be 20 or fewer".to_string(), - )); - } - - Ok(()) -} - -fn validate_vnc_config(connection: &SavedConnection) -> AppResult<()> { - let config::ConnectionType::Vnc { - host, - port, - security, - display, - reconnect, - .. - } = &connection.config - else { - return Ok(()); - }; - - if host.trim().is_empty() { - return Err(AppError::Config("VNC host is required".to_string())); - } - if *port == 0 { - return Err(AppError::Config( - "VNC port must be between 1 and 65535".to_string(), - )); - } - if !matches!(security.mode.as_str(), "auto" | "vnc-auth" | "none") { - return Err(AppError::Config("VNC security mode is invalid".to_string())); - } - if !matches!(display.scale_mode.as_str(), "fit" | "actual" | "stretch") { - return Err(AppError::Config("VNC scale mode is invalid".to_string())); - } - if reconnect.max_attempts > 20 { - return Err(AppError::Config( - "VNC reconnect attempts must be 20 or fewer".to_string(), - )); - } - - Ok(()) -} - fn should_validate_shell_path(value: &str) -> bool { let path = Path::new(trim_wrapping_quotes(value)); path.is_absolute() || value.contains('\\') || value.contains('/') @@ -645,51 +428,9 @@ fn resolve_text_secret_input( .map_err(|e| AppError::Config(format!("failed to read {file_error_label} file: {e}"))) } -fn missing_private_key_passphrase_error(error: &russh::keys::Error) -> bool { - let message = error.to_string().to_lowercase(); - message.contains("encrypted") - || message.contains("passphrase") - || message.contains("password") - || message.contains("cipher") -} - -fn validate_private_key_content(content: &str, passphrase: Option<&str>) -> AppResult<()> { - let usable_passphrase = passphrase.filter(|value| !value.is_empty()); - match russh::keys::decode_secret_key(content, usable_passphrase) { - Ok(_) => Ok(()), - Err(error) - if usable_passphrase.is_none() && missing_private_key_passphrase_error(&error) => - { - Ok(()) - } - Err(error) => Err(AppError::Config(format!( - "invalid SSH private key: {error}" - ))), - } -} - -fn derive_public_key_for_copy(content: &str, passphrase: Option<&str>) -> AppResult { - let usable_passphrase = passphrase.filter(|value| !value.is_empty()); - if let Ok(private_key) = russh::keys::decode_secret_key(content, usable_passphrase) { - return private_key.public_key().to_openssh().map_err(|error| { - AppError::Config(format!("failed to encode SSH public key: {error}")) - }); - } - - // OpenSSH containers keep the public key outside the encrypted private payload. - let private_key = ssh_key::PrivateKey::from_openssh(content) - .map_err(|error| AppError::Config(format!("failed to derive SSH public key: {error}")))?; - private_key - .public_key() - .to_openssh() - .map_err(|error| AppError::Config(format!("failed to encode SSH public key: {error}"))) -} - -fn validate_certificate_content(content: &str) -> AppResult<()> { - russh::keys::Certificate::from_openssh(content) - .map(|_| ()) - .map_err(|error| AppError::Config(format!("invalid OpenSSH certificate: {error}"))) -} +use nyaterm_core::services::{ + derive_public_key_for_copy, validate_certificate_content, validate_private_key_content, +}; fn resolve_private_key_for_save( key: &SshKey, @@ -722,87 +463,6 @@ fn resolve_certificate_for_save( crypto::encrypt(&content).map(Some) } -fn validate_proxy_jump_config( - connection: &SavedConnection, - existing_connections: &[SavedConnection], -) -> AppResult<()> { - let proxy_jump_id = connection - .network - .as_ref() - .and_then(|network| network.proxy_jump_id.as_deref()); - - let Some(proxy_jump_id) = proxy_jump_id else { - return Ok(()); - }; - - if !matches!( - connection.config, - config::ConnectionType::Ssh { .. } - | config::ConnectionType::Rdp { .. } - | config::ConnectionType::Vnc { .. } - ) { - return Err(AppError::Config( - "ProxyJump is only supported for SSH, RDP, and VNC connections".to_string(), - )); - } - - let mut visited = HashSet::new(); - visited.insert(connection.id.as_str()); - let mut current_jump_id = proxy_jump_id; - - loop { - if !visited.insert(current_jump_id) { - if connection.id == current_jump_id { - return Err(AppError::Config( - "A connection cannot use itself as a jump host".to_string(), - )); - } - return Err(AppError::Config(format!( - "ProxyJump chain contains a cycle at '{}'", - current_jump_id - ))); - } - - let jump_connection = - find_connection_for_proxy_jump(connection, existing_connections, current_jump_id) - .ok_or_else(|| { - AppError::Config(format!("Jump host '{}' not found", current_jump_id)) - })?; - - if !matches!(jump_connection.config, config::ConnectionType::Ssh { .. }) { - return Err(AppError::Config( - "Only SSH connections can be used as jump hosts".to_string(), - )); - } - - let Some(next_jump_id) = jump_connection - .network - .as_ref() - .and_then(|network| network.proxy_jump_id.as_deref()) - else { - break; - }; - - current_jump_id = next_jump_id; - } - - Ok(()) -} - -fn find_connection_for_proxy_jump<'a>( - edited_connection: &'a SavedConnection, - existing_connections: &'a [SavedConnection], - id: &str, -) -> Option<&'a SavedConnection> { - if edited_connection.id == id { - return Some(edited_connection); - } - - existing_connections - .iter() - .find(|candidate| candidate.id == id) -} - #[cfg(test)] mod tests { use super::{ diff --git a/src-tauri/src/cmd/session.rs b/src-tauri/src/cmd/session.rs index 30395c13..7c4c1d8d 100644 --- a/src-tauri/src/cmd/session.rs +++ b/src-tauri/src/cmd/session.rs @@ -286,6 +286,8 @@ pub async fn create_telnet_session( create_request_id: Option, recording_scope_id: Option, startup_command: Option, + network: Option, + encoding: Option, ) -> AppResult { let pending_creation = state.begin_session_creation(create_request_id).await; let (guard, cancel_rx) = match pending_creation { @@ -330,6 +332,7 @@ pub async fn create_telnet_session( name: conn.name.clone(), username: config::resolve_account_username(account.as_ref(), &username), password: telnet_password, + network: conn.network.clone(), backspace_mode, raw_tcp_cli, enter_mode: core::TelnetEnterMode::from_config_value(&enter_mode), @@ -352,6 +355,8 @@ pub async fn create_telnet_session( host: host.ok_or_else(|| AppError::Config("host is required".to_string()))?, port: port.unwrap_or(23), name: name.unwrap_or_else(|| "Telnet".to_string()), + network, + encoding: encoding.unwrap_or_else(|| "UTF-8".into()), ..Default::default() } }; 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/cmd/vnc.rs b/src-tauri/src/cmd/vnc.rs index f878521f..c7b72206 100644 --- a/src-tauri/src/cmd/vnc.rs +++ b/src-tauri/src/cmd/vnc.rs @@ -42,6 +42,14 @@ pub async fn vnc_attach_frame_channel( .await } +#[tauri::command] +pub async fn vnc_detach_frame_channel( + state: tauri::State<'_, Arc>, + session_id: String, +) -> AppResult<()> { + state.detach_frame_channel(&session_id).await +} + #[tauri::command] pub async fn vnc_input_batch( state: tauri::State<'_, Arc>, diff --git a/src-tauri/src/config/mod.rs b/src-tauri/src/config/mod.rs index 5a787543..ced3d778 100644 --- a/src-tauri/src/config/mod.rs +++ b/src-tauri/src/config/mod.rs @@ -1,116 +1 @@ -//! Config persistence for sessions, UI, and quick commands. -//! -//! Stores typed entities and small singleton documents in `~/.nyaterm/nyaterm.redb`. -//! Credentials are AES-256-GCM encrypted in-place. - -mod cloud_sync; -mod connection; -mod credential; -mod key; -mod note; -mod otp; -mod password; -mod proxy; -mod quick_command; -mod settings; -mod tunnel; -mod ui; - -#[allow(unused_imports)] -pub use cloud_sync::{ - CloudConflictPreview, CloudSyncHistoryEntry, CloudSyncSettings, CloudSyncState, - CloudSyncStatus, GiteeSnippetSyncSettings, MASKED_SECRET_VALUE, S3SyncSettings, - WebdavSyncSettings, decrypt_cloud_sync_settings, encrypt_cloud_sync_settings, - load_cloud_sync_settings, load_cloud_sync_state, mask_cloud_sync_settings, - merge_masked_cloud_sync_settings, save_cloud_sync_state, -}; -#[allow(unused_imports)] -pub use connection::{ - AiExecutionProfile, AppConfig, AssetAccelerator, AssetAcceleratorType, AssetDeviceType, - AssetDisk, AssetDiskKind, AssetDiskPurpose, AssetMetadata, ConnectionAuth, - ConnectionCustomIcon, ConnectionNetwork, ConnectionRecordingSettings, ConnectionType, Group, - MAX_SFTP_SHELL_DETECTION_TIMEOUT_MS, MAX_SSH_AGENT_FORWARDING_ENDPOINTS, - MAX_SSH_AGENT_FORWARDING_IDENTITIES, MIN_SFTP_SHELL_DETECTION_TIMEOUT_MS, RdpClipboardSettings, - RdpDisplaySettings, RdpReconnectSettings, RdpSecuritySettings, SavedConnection, - SerialFlowControl, SerialModemUploadProtocol, SessionsConfig, SftpCwdFollowMode, SftpSettings, - SshAgentEndpoint, SshAgentForwardingConfig, SshAgentForwardingPolicy, - SshAgentForwardingSources, SshAlgorithmMode, SshAlgorithmPreferences, SshProfile, - SshRuntimeMode, SshTerminalType, TelnetAutoLoginConfig, VncClipboardSettings, - VncDisplaySettings, VncReconnectSettings, VncSecuritySettings, - connection_custom_icon_from_data_url, connection_custom_icon_id_for_data_url, - effective_cwd_follow_mode, effective_cwd_follow_mode_for_profile, - effective_cwd_follow_mode_for_runtime, is_connection_custom_icon_data_url, load_config, - load_connection_by_id, load_sessions, migrate_legacy_asset_tags, - migrate_legacy_ssh_agent_settings, normalize_connection_tags, resolve_connection_encoding, - resolve_ssh_terminal_type, save_config, save_sessions, ssh_agent_endpoint_key, - validate_ssh_agent_endpoint, validate_ssh_agent_endpoint_shape, - validate_ssh_agent_forwarding_config, validate_ssh_agent_forwarding_shape, - validate_ssh_agent_settings, -}; -#[allow(unused_imports)] -pub use credential::{ - CredentialsConfig, SavedCredential, load_credential_by_id, load_credentials, - reorder_credentials, save_credentials, upsert_credential, -}; -#[allow(unused_imports)] -pub use key::{ - KeysConfig, SshKey, decrypt_key_cert, decrypt_key_pem, load_key_by_id, load_keys, save_keys, -}; -pub(crate) use key::{ssh_key_change_epoch, ssh_key_read_guard}; -#[allow(unused_imports)] -pub use note::{ - DeleteNoteNodeResult, NoteDocument, NoteFolder, NoteNodeChange, NoteSummary, NoteTreePayload, - NoteUpdateResult, NotesChangedEvent, NotesSnapshot, -}; -#[allow(unused_imports)] -pub use otp::{OtpConfig, OtpEntry, load_otp_entries, load_otp_entry_by_id, save_otp_entries}; -#[allow(unused_imports)] -pub use password::{ - PasswordsConfig, SavedPassword, decrypt_account_password, effective_account_id, - load_password_by_id, load_passwords, load_saved_account, resolve_account_username, - save_passwords, -}; -#[allow(unused_imports)] -pub use proxy::{ - ProxyConfig, ProxyGroup, ProxyGroupsConfig, load_proxies, load_proxy_by_id, load_proxy_groups, - save_proxies, save_proxy_groups, -}; -#[allow(unused_imports)] -pub use quick_command::{ - QuickCommand, QuickCommandCategory, QuickCommandsConfig, load_quick_commands, - save_quick_commands, -}; -#[allow(unused_imports)] -pub use settings::{ - AI_REQUEST_USER_AGENT_DEFAULT, ActionLinksMatcherSettings, AgentCommandExecutionMode, - AiAgentKind, AiApiFormat, AiBackendKind, AiCustomActionConfig, AiMode, AiModelConfigItem, - AiModelSource, AiPermissionMode, AiProviderApiProtocol, AiProviderCredential, AiProviderKind, - AiProviderProfile, AiProxyMode, AiProxyProtocol, AiProxySettings, AiReasoningEffort, - AiSettings, AppSettings, AppearanceSettings, ClaudeCodeIntegrationSettings, - CodexIntegrationSettings, CodexThreadMode, DiagnosticsLogLevel, DiagnosticsSettings, - ExternalMcpSessionScope, ExternalMcpSettings, GeneralSettings, InteractionSettings, - KeywordHighlightRule, ProxySettings, RecordingSettings, RiskLevel, SearchEngine, - SearchSettings, SecuritySettings, TerminalColorsConfig, TerminalSettings, ThemeColorsConfig, - ThemeConfig, TransferSettings, TranslationSettings, ai_model_id_for_credential, - ai_model_id_for_provider, decrypt_ai_settings, encrypt_ai_settings, load_app_settings, - mask_ai_settings, merge_masked_ai_settings, normalize_ai_settings, save_app_settings, -}; -#[allow(unused_imports)] -pub use tunnel::{ - TunnelConfig, TunnelGroup, TunnelGroupsConfig, TunnelsConfig, load_tunnel_groups, load_tunnels, - save_tunnel_groups, save_tunnels, -}; -#[allow(unused_imports)] -pub use ui::{ActivityBarLayout, RestorablePaneNode, RestorableTab, UiConfig}; - -pub(crate) fn uuid_v4() -> String { - uuid::Uuid::new_v4().to_string() -} - -pub(crate) fn default_true() -> bool { - true -} - -pub(crate) fn default_false() -> bool { - false -} +pub use nyaterm_core::config::*; diff --git a/src-tauri/src/core/ai/agent.rs b/src-tauri/src/core/ai/agent.rs index 8afd2fb0..aa1b99ca 100644 --- a/src-tauri/src/core/ai/agent.rs +++ b/src-tauri/src/core/ai/agent.rs @@ -1122,7 +1122,7 @@ async fn run_agent_legacy_json_step( }; let stream_result = super::responses::run_responses_chat_messages_stream_without_text_deltas( - app, + &super::stream::DesktopAiSink(app), stream_id, &synthetic_request, settings, diff --git a/src-tauri/src/core/ai/history.rs b/src-tauri/src/core/ai/history.rs index 3ef7e6da..ed78c5b6 100644 --- a/src-tauri/src/core/ai/history.rs +++ b/src-tauri/src/core/ai/history.rs @@ -1,479 +1,5 @@ -use std::collections::HashSet; - -use serde::{Deserialize, Serialize}; -use tauri::AppHandle; - -use crate::config::{AiAgentKind, AiBackendKind}; -use crate::error::{AppError, AppResult}; -use crate::storage::{self, SettingsDocKey}; - -use super::types::{ - AiAuditLog, AiChatRequest, AiMessage, AiMessageRole, AiSession, AiSessionBackendMetadata, - AiSessionScope, AiSessionScopeType, AppendAiAuditRequest, now_rfc3339, uuid, -}; - -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -pub(super) struct AiHistoryFile { - #[serde(default)] - pub sessions: Vec, - #[serde(default)] - pub messages: Vec, -} - -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -struct AiAuditFile { - #[serde(default)] - logs: Vec, -} - -const AI_HISTORY_MAX_SESSIONS: usize = 200; -const AI_HISTORY_MAX_MESSAGES: usize = 2_000; -const AI_AUDIT_MAX_LOGS: usize = 2_000; - -fn normalize_session_scope(session: &mut AiSession) { - if session.agent_kind == AiAgentKind::Nyaterm - && let Some(metadata) = session.backend_metadata.as_ref() - && metadata.backend == AiBackendKind::Codex - { - session.agent_kind = AiAgentKind::Codex; - if session.external_session_id.is_none() { - session.external_session_id = metadata.external_thread_id.clone(); - } - } - - if session.scope.r#type == AiSessionScopeType::Unbound && session.scope.target_id.is_none() { - if let Some(connection_id) = session.connection_id.as_ref() { - if !connection_id.trim().is_empty() - && !session - .scope - .connection_ids - .iter() - .any(|value| value == connection_id) - { - session.scope.connection_ids.push(connection_id.clone()); - } - } - } -} - -fn normalize_history(history: &mut AiHistoryFile) { - for session in &mut history.sessions { - normalize_session_scope(session); - } -} - -pub(super) fn load_history(_app: &AppHandle) -> AppResult { - let mut history: AiHistoryFile = storage::load_settings_doc(SettingsDocKey::AiHistory)?; - normalize_history(&mut history); - Ok(history) -} - -pub(super) fn trim_history(history: &mut AiHistoryFile) { - history - .sessions - .sort_by(|left, right| right.updated_at.cmp(&left.updated_at)); - if history.sessions.len() > AI_HISTORY_MAX_SESSIONS { - history.sessions.truncate(AI_HISTORY_MAX_SESSIONS); - } - - let retained_sessions: HashSet<&str> = history - .sessions - .iter() - .map(|session| session.id.as_str()) - .collect(); - history - .messages - .retain(|message| retained_sessions.contains(message.session_id.as_str())); - - if history.messages.len() > AI_HISTORY_MAX_MESSAGES { - history - .messages - .sort_by(|left, right| left.created_at.cmp(&right.created_at)); - let remove_count = history.messages.len() - AI_HISTORY_MAX_MESSAGES; - history.messages.drain(0..remove_count); - } - - let sessions_with_messages: HashSet<&str> = history - .messages - .iter() - .map(|message| message.session_id.as_str()) - .collect(); - history - .sessions - .retain(|session| sessions_with_messages.contains(session.id.as_str())); -} - -pub(super) fn save_user_message( - app: &AppHandle, - session_id: &str, - request: &AiChatRequest, -) -> AppResult<()> { - tracing::debug!( - session_id = %session_id, - connection_id = ?request.connection_id, - action = ?request.action, - "Persisting AI user message" - ); - - let now = now_rfc3339(); - let title = request - .user_input - .chars() - .take(42) - .collect::() - .trim() - .to_string(); - let connection_id = request.connection_id.clone(); - let scope = request.owner_scope.clone(); - let user_input = request.user_input.clone(); - let session_id = session_id.to_string(); - - let _ = app; - storage::update_settings_doc::(SettingsDocKey::AiHistory, |history| { - if let Some(session) = history - .sessions - .iter_mut() - .find(|item| item.id == session_id) - { - session.updated_at = now.clone(); - } else { - history.sessions.push(AiSession { - id: session_id.clone(), - agent_kind: request.agent_kind.clone(), - scope, - connection_id, - title: if title.is_empty() { - "AI Session".to_string() - } else { - title - }, - created_at: now.clone(), - updated_at: now.clone(), - external_session_id: request.existing_external_session_id.clone(), - backend_metadata: None, - }); - } - history.messages.push(AiMessage { - id: format!("msg-{}", uuid()), - session_id, - role: AiMessageRole::User, - content: user_input, - created_at: now, - reasoning_content: None, - command_cards: vec![], - }); - trim_history(history); - Ok(()) - }) -} - -pub(super) fn validate_session_scope( - app: &AppHandle, - session_id: &str, - request: &AiChatRequest, -) -> AppResult<()> { - let history = load_history(app)?; - let Some(existing) = history.sessions.iter().find(|item| item.id == session_id) else { - return Ok(()); - }; - - if existing.agent_kind != request.agent_kind { - return Err(AppError::Config( - "AI session belongs to another agent type".to_string(), - )); - } - - if existing.scope.r#type == AiSessionScopeType::Unbound { - return Ok(()); - } - - if existing.scope.r#type != request.owner_scope.r#type - || existing.scope.target_id != request.owner_scope.target_id - { - return Err(AppError::Config( - "AI session belongs to another terminal scope".to_string(), - )); - } - - Ok(()) -} - -pub(super) fn append_message(app: &AppHandle, message: AiMessage) -> AppResult<()> { - tracing::debug!( - session_id = %message.session_id, - role = ?message.role, - content_len = message.content.len(), - command_card_count = message.command_cards.len(), - has_reasoning = message.reasoning_content.is_some(), - "Persisting AI message" - ); - - let _ = app; - storage::update_settings_doc::(SettingsDocKey::AiHistory, |history| { - if let Some(session) = history - .sessions - .iter_mut() - .find(|item| item.id == message.session_id) - { - session.updated_at = message.created_at.clone(); - } - history.messages.push(message); - trim_history(history); - Ok(()) - }) -} - -pub(super) fn get_session_backend_metadata( - app: &AppHandle, - session_id: &str, -) -> AppResult> { - Ok(load_history(app)? - .sessions - .into_iter() - .find(|session| session.id == session_id) - .and_then(|session| session.backend_metadata)) -} - -pub(super) fn set_session_external_session_id( - app: &AppHandle, - session_id: &str, - agent_kind: AiAgentKind, - external_session_id: String, -) -> AppResult<()> { - let _ = app; - let session_id = session_id.to_string(); - storage::update_settings_doc::(SettingsDocKey::AiHistory, |history| { - normalize_history(history); - if let Some(session) = history - .sessions - .iter_mut() - .find(|item| item.id == session_id) - { - session.agent_kind = agent_kind; - session.external_session_id = Some(external_session_id); - } - Ok(()) - }) -} - -pub(super) fn set_session_backend_metadata( - app: &AppHandle, - session_id: &str, - metadata: AiSessionBackendMetadata, -) -> AppResult<()> { - let _ = app; - let session_id = session_id.to_string(); - storage::update_settings_doc::(SettingsDocKey::AiHistory, |history| { - if let Some(session) = history - .sessions - .iter_mut() - .find(|item| item.id == session_id) - { - session.backend_metadata = Some(metadata); - } - Ok(()) - }) -} - -pub fn get_ai_sessions(app: &AppHandle) -> AppResult> { - let mut sessions = load_history(app)?.sessions; - sessions.sort_by(|left, right| right.updated_at.cmp(&left.updated_at)); - Ok(sessions) -} - -pub fn get_ai_messages(app: &AppHandle, session_id: String) -> AppResult> { - Ok(load_history(app)? - .messages - .into_iter() - .filter(|message| message.session_id == session_id) - .collect()) -} - -pub fn clear_ai_history(app: &AppHandle) -> AppResult<()> { - let _ = app; +pub use nyaterm_core::core::ai::history::*; +pub fn clear_ai_history(app: &tauri::AppHandle) -> crate::error::AppResult<()> { super::stream::cancel_all_chat_streams(); - storage::save_settings_doc(SettingsDocKey::AiHistory, &AiHistoryFile::default()) -} - -pub fn delete_ai_session(app: &AppHandle, session_id: String) -> AppResult<()> { - let _ = app; - storage::update_settings_doc::(SettingsDocKey::AiHistory, |history| { - history.sessions.retain(|s| s.id != session_id); - history.messages.retain(|m| m.session_id != session_id); - trim_history(history); - Ok(()) - }) -} - -pub fn rebind_ai_session( - app: &AppHandle, - session_id: String, - owner_scope: AiSessionScope, -) -> AppResult { - let _ = app; - storage::update_settings_doc::(SettingsDocKey::AiHistory, |history| { - normalize_history(history); - let Some(session) = history - .sessions - .iter_mut() - .find(|item| item.id == session_id) - else { - return Err(AppError::Config("AI session not found".to_string())); - }; - session.scope = owner_scope; - session.updated_at = now_rfc3339(); - Ok(session.clone()) - }) -} - -pub fn append_ai_audit(app: &AppHandle, request: AppendAiAuditRequest) -> AppResult { - tracing::info!( - connection_id = ?request.connection_id, - action = %request.action, - risk_level = ?request.risk_level, - inserted_to_terminal = request.inserted_to_terminal, - executed = request.executed, - blocked = request.blocked, - "Appending AI audit log" - ); - - let _ = app; - let log = AiAuditLog { - id: format!("audit-{}", uuid()), - connection_id: request.connection_id, - action: request.action, - user_input: request.user_input, - generated_command: request.generated_command, - risk_level: request.risk_level, - inserted_to_terminal: request.inserted_to_terminal, - executed: request.executed, - blocked: request.blocked, - source: request.source, - client: request.client, - capability: request.capability, - session_id: request.session_id, - permission_mode: request.permission_mode, - approval_decision: request.approval_decision, - success: request.success, - duration_ms: request.duration_ms, - error: request.error, - created_at: now_rfc3339(), - }; - storage::update_settings_doc::(SettingsDocKey::AiAudit, |file| { - file.logs.push(log.clone()); - if file.logs.len() > AI_AUDIT_MAX_LOGS { - let keep_from = file.logs.len().saturating_sub(AI_AUDIT_MAX_LOGS); - file.logs = file.logs.split_off(keep_from); - } - Ok(log) - }) -} - -pub fn get_ai_audit_logs(app: &AppHandle, limit: Option) -> AppResult> { - let _ = app; - let mut logs = storage::load_settings_doc::(SettingsDocKey::AiAudit)?.logs; - logs.sort_by(|left, right| right.created_at.cmp(&left.created_at)); - if let Some(limit) = limit { - logs.truncate(limit); - } - Ok(logs) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn old_history_without_reasoning_defaults_cleanly() { - let raw = r#"{"sessions":[],"messages":[{"id":"m1","sessionId":"s1","role":"assistant","content":"hello","createdAt":"2026-04-28T00:00:00Z","commandCards":[]}]}"#; - let history: AiHistoryFile = serde_json::from_str(raw).unwrap(); - assert_eq!(history.messages.len(), 1); - assert_eq!(history.messages[0].reasoning_content, None); - } - - #[test] - fn migrates_legacy_codex_thread_metadata_to_external_session() { - let mut session = AiSession { - id: "s1".to_string(), - agent_kind: AiAgentKind::Nyaterm, - scope: AiSessionScope::default(), - connection_id: None, - title: "legacy".to_string(), - created_at: "2026-04-28T00:00:00Z".to_string(), - updated_at: "2026-04-28T00:00:00Z".to_string(), - external_session_id: None, - backend_metadata: Some(AiSessionBackendMetadata { - backend: AiBackendKind::Codex, - external_thread_id: Some("thread-123".to_string()), - codex_terminal_tools_version: None, - }), - }; - - normalize_session_scope(&mut session); - - assert_eq!(session.agent_kind, AiAgentKind::Codex); - assert_eq!(session.external_session_id.as_deref(), Some("thread-123")); - } - - #[test] - fn trims_ai_history_to_session_and_message_limits() { - let mut history = AiHistoryFile::default(); - for session_idx in 0..220 { - let session_id = format!("s-{session_idx:03}"); - let updated_at = format!( - "2026-04-28T00:{:02}:{:02}Z", - session_idx / 60, - session_idx % 60 - ); - history.sessions.push(AiSession { - id: session_id.clone(), - agent_kind: AiAgentKind::Nyaterm, - scope: AiSessionScope::default(), - connection_id: None, - title: session_id.clone(), - created_at: updated_at.clone(), - updated_at, - external_session_id: None, - backend_metadata: None, - }); - for message_idx in 0..10 { - history.messages.push(AiMessage { - id: format!("m-{session_idx:03}-{message_idx:02}"), - session_id: session_id.clone(), - role: if message_idx % 2 == 0 { - AiMessageRole::User - } else { - AiMessageRole::Assistant - }, - content: "message".to_string(), - created_at: format!( - "2026-04-28T00:{:02}:{:02}.{:03}Z", - session_idx / 60, - session_idx % 60, - message_idx - ), - reasoning_content: None, - command_cards: vec![], - }); - } - } - - trim_history(&mut history); - - assert_eq!(history.sessions.len(), AI_HISTORY_MAX_SESSIONS); - assert_eq!(history.messages.len(), AI_HISTORY_MAX_MESSAGES); - let retained_sessions: HashSet<&str> = history - .sessions - .iter() - .map(|session| session.id.as_str()) - .collect(); - assert!(!retained_sessions.contains("s-000")); - assert!(retained_sessions.contains("s-219")); - assert!( - history - .messages - .iter() - .all(|message| retained_sessions.contains(message.session_id.as_str())) - ); - } + nyaterm_core::core::ai::history::clear_ai_history_storage(app) } diff --git a/src-tauri/src/core/ai/http.rs b/src-tauri/src/core/ai/http.rs index 84204e83..70b0cefe 100644 --- a/src-tauri/src/core/ai/http.rs +++ b/src-tauri/src/core/ai/http.rs @@ -1,93 +1 @@ -use genai::WebConfig; -use reqwest::{Client, ClientBuilder, NoProxy, Proxy, Url}; - -use crate::config::{AiProxyMode, AiProxyProtocol, AiProxySettings, AiSettings}; -use crate::error::{AppError, AppResult}; - -use super::model::ai_request_headers; - -/// Every built-in AI transport uses this builder. Keep timeouts at the request / -/// stream level: a total client timeout would interrupt long-running streams. -pub(super) fn build_http_client(settings: &AiSettings) -> AppResult { - let web_config = WebConfig::default().with_default_headers(ai_request_headers(settings)?); - let builder = web_config.apply_to_builder(Client::builder()); - apply_proxy(builder, &settings.proxy)? - .build() - .map_err(|_| AppError::Config("Failed to build AI HTTP client".to_string())) -} - -fn apply_proxy(builder: ClientBuilder, settings: &AiProxySettings) -> AppResult { - match settings.mode { - AiProxyMode::System => Ok(builder), - AiProxyMode::Direct => Ok(builder.no_proxy()), - AiProxyMode::Custom => { - let url = proxy_url(settings)?; - let username = settings - .username - .as_deref() - .filter(|value| !value.is_empty()); - let password = settings - .password - .as_deref() - .filter(|value| !value.is_empty()); - if password.is_some() && username.is_none() { - return Err(AppError::Config( - "AI proxy password requires a username".to_string(), - )); - } - if settings.protocol == AiProxyProtocol::Socks5 - && (username.is_some_and(|value| value.len() > 255) - || password.is_some_and(|value| value.len() > 255)) - { - return Err(AppError::Config( - "SOCKS5 proxy credentials must be at most 255 bytes".to_string(), - )); - } - let mut proxy = Proxy::all(url) - .map_err(|_| AppError::Config("Invalid AI proxy configuration".to_string()))? - .no_proxy(NoProxy::from_string(&settings.no_proxy)); - if let Some(username) = username { - proxy = proxy.basic_auth(username, password.unwrap_or_default()); - } - // Explicitly disable automatic proxies so bypassed requests connect - // directly, even when environment / system proxies are configured. - Ok(builder.no_proxy().proxy(proxy)) - } - } -} - -fn proxy_url(settings: &AiProxySettings) -> AppResult { - let invalid = || AppError::Config("Invalid AI proxy host or port".to_string()); - let host = settings.host.trim(); - if host.is_empty() - || settings.port == 0 - || host - .chars() - .any(|value| value.is_whitespace() || "/\\@?#%".contains(value)) - { - return Err(invalid()); - } - let unbracketed = host - .strip_prefix('[') - .and_then(|value| value.strip_suffix(']')) - .unwrap_or(host); - let host = if unbracketed.contains(':') { - let address = unbracketed - .parse::() - .map_err(|_| invalid())?; - format!("[{address}]") - } else { - host.to_string() - }; - let scheme = match settings.protocol { - AiProxyProtocol::Http => "http", - AiProxyProtocol::Socks5 => "socks5h", - }; - // Validate DNS names using a special URL scheme too: SOCKS URLs otherwise - // allow opaque hosts with characters that cannot be used as a DNS name. - Url::parse(&format!("http://{host}:{}", settings.port)).map_err(|_| invalid())?; - Url::parse(&format!("{scheme}://{host}:{}", settings.port)).map_err(|_| invalid()) -} - -#[cfg(test)] -mod tests; +// HTTP provider logic lives in nyaterm_core::core::ai::http. diff --git a/src-tauri/src/core/ai/model.rs b/src-tauri/src/core/ai/model.rs index 3ffd1034..9b41c913 100644 --- a/src-tauri/src/core/ai/model.rs +++ b/src-tauri/src/core/ai/model.rs @@ -1,1188 +1 @@ -use std::collections::BTreeMap; -use std::time::Duration; - -use genai::adapter::AdapterKind; -use genai::chat::{ChatMessage, ChatOptions, ChatRequest, ReasoningEffort}; -use genai::resolver::{AuthData, Endpoint, ServiceTargetResolver}; -use genai::{Client, ModelIden}; -use reqwest::header::{HeaderMap, HeaderValue, USER_AGENT}; -use serde_json::Value; - -use crate::config::{ - self, AI_REQUEST_USER_AGENT_DEFAULT, AiApiFormat, AiBackendKind, AiModelConfigItem, - AiModelSource, AiProviderApiProtocol, AiProviderCredential, AiProviderKind, AiReasoningEffort, - AiSettings, ai_model_id_for_credential, ai_model_id_for_provider, -}; -use crate::error::{AppError, AppResult}; -use crate::utils::url::{join_api_base_url, normalize_api_base_url}; - -use super::http::build_http_client; -use super::types::{AiChatRequest, AiModelDiscovery}; - -const MODEL_TEST_SYSTEM_PROMPT: &str = "You are NyaTerm's terminal AI connectivity check. Reply with OK only; do not suggest or run commands."; -const MODEL_TEST_USER_PROMPT: &str = "Confirm that this model can respond."; -const MODEL_TEST_MAX_OUTPUT_TOKENS: u32 = 64; - -#[derive(Debug, Clone)] -pub(super) struct ResolvedAiModel { - pub model_name: String, - pub provider_kind: AiProviderKind, - pub api_format: AiApiFormat, - pub credential: Option, -} - -pub(super) fn resolve_request_model_config( - settings: &AiSettings, - request: &AiChatRequest, -) -> AppResult { - request - .model_id - .as_deref() - .and_then(|id| { - settings - .models - .iter() - .find(|model| model.enabled && model.id == id) - }) - .or_else(|| { - settings.default_model_id.as_deref().and_then(|id| { - settings - .models - .iter() - .find(|model| model.enabled && model.id == id) - }) - }) - .or_else(|| settings.models.iter().find(|model| model.enabled)) - .cloned() - .ok_or_else(|| AppError::Config("No enabled AI model configured".to_string())) -} - -pub(super) fn build_chat_options(settings: &AiSettings) -> ChatOptions { - let mut options = ChatOptions::default() - .with_capture_reasoning_content(true) - .with_normalize_reasoning_content(true); - - if let Some(reasoning_effort) = genai_reasoning_effort(&settings.default_reasoning_effort) { - options = options.with_reasoning_effort(reasoning_effort); - } else if matches!(settings.default_reasoning_effort, AiReasoningEffort::Ultra) { - options = options.with_extra_body(serde_json::json!({ "reasoning_effort": "ultra" })); - } - - options -} - -fn genai_reasoning_effort(value: &AiReasoningEffort) -> Option { - match value { - AiReasoningEffort::Auto => None, - AiReasoningEffort::None => Some(ReasoningEffort::None), - AiReasoningEffort::Minimal => Some(ReasoningEffort::Minimal), - AiReasoningEffort::Low => Some(ReasoningEffort::Low), - AiReasoningEffort::Medium => Some(ReasoningEffort::Medium), - AiReasoningEffort::High => Some(ReasoningEffort::High), - AiReasoningEffort::XHigh => Some(ReasoningEffort::XHigh), - AiReasoningEffort::Max => Some(ReasoningEffort::Max), - AiReasoningEffort::Ultra => None, - } -} - -pub(super) fn resolve_request_model( - settings: &AiSettings, - request: &AiChatRequest, -) -> AppResult { - tracing::debug!( - requested_model_id = ?request.model_id, - default_model_id = ?settings.default_model_id, - enabled_model_count = settings.models.iter().filter(|model| model.enabled).count(), - "Resolving AI model for request" - ); - - let selected_model = resolve_request_model_config(settings, request)?; - - resolve_model_config(settings, &selected_model) -} - -fn resolve_model_config( - settings: &AiSettings, - selected_model: &AiModelConfigItem, -) -> AppResult { - if selected_model.backend == AiBackendKind::Codex { - return Err(AppError::Config( - "Codex models must be routed through codex app-server".to_string(), - )); - } - - let model_provider_kind = selected_model - .provider_kind - .clone() - .or_else(|| infer_provider_kind_from_model_id(&selected_model.id)); - - let credential = - resolve_model_credential(settings, selected_model, model_provider_kind.as_ref())?; - let provider_kind = credential - .as_ref() - .map(|credential| credential.provider_kind.clone()) - .or(model_provider_kind) - .ok_or_else(|| { - AppError::Config(format!( - "AI model '{}' is missing provider information", - selected_model.name - )) - })?; - validate_model_credential(&provider_kind, credential.as_ref())?; - - tracing::info!( - resolved_model_id = %selected_model.id, - resolved_model_name = %selected_model.name, - provider_kind = ?provider_kind, - credential_id = ?credential.as_ref().map(|item| item.id.as_str()), - "Resolved AI model" - ); - - Ok(ResolvedAiModel { - model_name: selected_model.name.clone(), - provider_kind, - api_format: credential - .as_ref() - .map(|credential| credential.api_format.clone()) - .unwrap_or_default(), - credential, - }) -} - -pub async fn test_model_connection(settings: &AiSettings, model_id: &str) -> AppResult<()> { - // The chat panel's selected effort may belong to a different model. - // Connectivity checks use the target model's own default reasoning behavior. - let mut settings = settings.clone(); - settings.default_reasoning_effort = AiReasoningEffort::Auto; - let selected_model = settings - .models - .iter() - .find(|model| model.id == model_id) - .ok_or_else(|| AppError::Config("AI model is no longer configured".to_string()))?; - let resolved_model = resolve_model_config(&settings, selected_model)?; - let timeout = Duration::from_millis(settings.timeout_ms.clamp(1_000, 60_000)); - - tokio::time::timeout(timeout, async { - if super::responses::uses_responses_api(&resolved_model) { - return super::responses::test_responses_model( - &settings, - &resolved_model, - MODEL_TEST_SYSTEM_PROMPT, - MODEL_TEST_USER_PROMPT, - MODEL_TEST_MAX_OUTPUT_TOKENS, - ) - .await; - } - - let client = build_client(&resolved_model, &settings)?; - let request = ChatRequest::new(vec![ - ChatMessage::system(MODEL_TEST_SYSTEM_PROMPT), - ChatMessage::user(MODEL_TEST_USER_PROMPT), - ]); - let options = build_chat_options(&settings).with_max_tokens(MODEL_TEST_MAX_OUTPUT_TOKENS); - client - .exec_chat(&resolved_model.model_name, request, Some(&options)) - .await - .map_err(|error| AppError::Config(format!("AI model test failed: {error}")))?; - Ok(()) - }) - .await - .map_err(|_| AppError::Config("AI model test timed out".to_string()))? -} - -fn infer_provider_kind_from_model_id(model_id: &str) -> Option { - let (prefix, _) = model_id.split_once(':')?; - match prefix { - "openai" => Some(AiProviderKind::Openai), - "anthropic" => Some(AiProviderKind::Anthropic), - "gemini" => Some(AiProviderKind::Gemini), - "deepseek" => Some(AiProviderKind::Deepseek), - "groq" => Some(AiProviderKind::Groq), - "ollama" => Some(AiProviderKind::Ollama), - "xai" => Some(AiProviderKind::Xai), - "cohere" => Some(AiProviderKind::Cohere), - "mimo" => Some(AiProviderKind::Mimo), - "zai" => Some(AiProviderKind::Zai), - "openai_compatible" => Some(AiProviderKind::OpenaiCompatible), - _ => None, - } -} - -fn resolve_model_credential( - settings: &AiSettings, - model: &AiModelConfigItem, - provider_kind: Option<&AiProviderKind>, -) -> AppResult> { - // Legacy built-in models use the provider ID as their implicit credential ID. - // Never let a newly added account of the same kind capture those models. - let implicit_credential_id = model - .id - .split_once(':') - .map(|(prefix, _)| prefix) - .filter(|prefix| is_builtin_ai_provider_credential_id(prefix)); - if let Some(credential_id) = model.credential_id.as_deref().or(implicit_credential_id) { - let credential = settings - .provider_credentials - .iter() - .find(|item| item.id == credential_id && item.enabled) - .cloned() - .ok_or_else(|| { - AppError::Config(format!( - "No enabled AI credential found for model '{}'", - model.name - )) - })?; - return Ok(Some(credential)); - } - - Ok(provider_kind.and_then(|provider_kind| { - settings - .provider_credentials - .iter() - .find(|item| item.enabled && &item.provider_kind == provider_kind) - .cloned() - })) -} - -fn validate_model_credential( - provider_kind: &AiProviderKind, - credential: Option<&AiProviderCredential>, -) -> AppResult<()> { - match provider_kind { - AiProviderKind::Ollama => Ok(()), - AiProviderKind::OpenaiCompatible => { - if credential.is_none() { - return Err(AppError::Config( - "No enabled OpenAI-compatible AI credential configured".to_string(), - )); - } - Ok(()) - } - _ => { - let credential = credential.ok_or_else(|| { - AppError::Config(format!( - "No enabled AI credential configured for {:?}", - provider_kind - )) - })?; - if credential - .api_key - .as_deref() - .is_none_or(|value| value.trim().is_empty()) - { - return Err(AppError::Config(format!( - "No API key configured for AI credential '{}'", - credential.name - ))); - } - Ok(()) - } - } -} - -pub(super) fn build_client(model: &ResolvedAiModel, settings: &AiSettings) -> AppResult { - tracing::debug!( - model_name = %model.model_name, - provider_kind = ?model.provider_kind, - has_credential = model.credential.is_some(), - has_base_url = model - .credential - .as_ref() - .and_then(|credential| credential.base_url.as_deref()) - .is_some_and(|value| !value.trim().is_empty()), - "Building AI client" - ); - - let adapter_kind = adapter_kind_for_model(model); - let mapped_model = genai_model_name(&model.provider_kind, &model.model_name); - let api_key = model - .credential - .as_ref() - .and_then(|credential| credential.api_key.clone()) - .filter(|value| !value.trim().is_empty()); - let base_url = model - .credential - .as_ref() - .and_then(|credential| credential.base_url.as_deref()) - .map(normalize_api_base_url) - .transpose()? - .filter(|value| !value.trim().is_empty()); - let allows_empty_auth = model.provider_kind == AiProviderKind::OpenaiCompatible - || (model.provider_kind == AiProviderKind::Ollama - && model - .credential - .as_ref() - .and_then(|credential| credential.api_protocol.as_ref()) - == Some(&AiProviderApiProtocol::OpenaiCompatible)); - - let resolver = - ServiceTargetResolver::from_resolver_fn(move |service_target: genai::ServiceTarget| { - Ok(apply_service_target_overrides( - service_target, - api_key.clone(), - base_url.clone(), - allows_empty_auth, - )) - }); - - Ok(Client::builder() - .with_model_mapper_fn(move |_model| Ok(ModelIden::new(adapter_kind, mapped_model.clone()))) - .with_service_target_resolver(resolver) - .with_reqwest(build_http_client(settings)?) - .build()) -} - -fn apply_service_target_overrides( - mut service_target: genai::ServiceTarget, - api_key: Option, - base_url: Option, - allows_empty_auth: bool, -) -> genai::ServiceTarget { - if let Some(api_key) = api_key { - service_target.auth = AuthData::from_single(api_key); - } else if allows_empty_auth { - service_target.auth = AuthData::None; - } - if let Some(base_url) = base_url { - service_target.endpoint = Endpoint::from_owned(base_url); - } - service_target -} - -fn effective_request_user_agent(settings: &AiSettings) -> &str { - let value = settings.request_user_agent.trim(); - if value.is_empty() { - AI_REQUEST_USER_AGENT_DEFAULT - } else { - value - } -} - -pub(super) fn ai_request_headers(settings: &AiSettings) -> AppResult { - let user_agent = effective_request_user_agent(settings); - let user_agent_value = HeaderValue::from_str(user_agent).map_err(|error| { - AppError::Config(format!("Invalid AI User-Agent header value: {error}")) - })?; - let mut headers = HeaderMap::new(); - headers.insert(USER_AGENT, user_agent_value); - Ok(headers) -} - -fn adapter_kind(kind: &AiProviderKind) -> AdapterKind { - match kind { - AiProviderKind::Openai | AiProviderKind::OpenaiCompatible => AdapterKind::OpenAI, - AiProviderKind::Anthropic => AdapterKind::Anthropic, - AiProviderKind::Gemini => AdapterKind::Gemini, - AiProviderKind::Deepseek => AdapterKind::DeepSeek, - AiProviderKind::Groq => AdapterKind::Groq, - AiProviderKind::Ollama => AdapterKind::Ollama, - AiProviderKind::Xai - | AiProviderKind::Cohere - | AiProviderKind::Mimo - | AiProviderKind::Zai => AdapterKind::OpenAI, - } -} - -fn adapter_kind_for_model(model: &ResolvedAiModel) -> AdapterKind { - match model - .credential - .as_ref() - .and_then(|credential| credential.api_protocol.as_ref()) - { - None => adapter_kind(&model.provider_kind), - Some(AiProviderApiProtocol::OpenaiCompatible) => match model.provider_kind { - AiProviderKind::Deepseek => AdapterKind::DeepSeek, - AiProviderKind::Groq => AdapterKind::Groq, - _ => AdapterKind::OpenAI, - }, - Some(AiProviderApiProtocol::Anthropic) => AdapterKind::Anthropic, - Some(AiProviderApiProtocol::Gemini) => AdapterKind::Gemini, - Some(AiProviderApiProtocol::Ollama) => AdapterKind::Ollama, - } -} - -fn genai_model_name(provider_kind: &AiProviderKind, model_name: &str) -> String { - if matches!(provider_kind, AiProviderKind::Deepseek) - && let Some(base_model_name) = model_name.strip_suffix("-none") - { - return base_model_name.to_string(); - } - - model_name.to_string() -} - -pub async fn list_model_names(app: &tauri::AppHandle) -> AppResult> { - let settings = config::load_app_settings(app)?; - list_model_names_for_settings(&settings.ai).await -} - -pub async fn list_model_names_for_settings( - settings: &AiSettings, -) -> AppResult> { - let credentials = model_discovery_credentials(settings); - - let mut models = BTreeMap::new(); - let mut errors = Vec::new(); - - for credential in credentials { - let label = credential.name.as_str(); - tracing::info!( - credential = label, - provider_kind = ?credential.provider_kind, - "Fetching model list from provider" - ); - match fetch_credential_model_names(credential, settings).await { - Ok(names) => { - tracing::info!( - credential = label, - count = names.len(), - "Fetched models from provider" - ); - for name in names { - let trimmed = name.trim(); - if trimmed.is_empty() { - continue; - } - let builtin = is_builtin_ai_provider_credential_id(&credential.id); - let id = if builtin { - ai_model_id_for_provider(&credential.provider_kind, trimmed) - } else { - ai_model_id_for_credential(&credential.id, trimmed) - }; - models.entry(id.clone()).or_insert(AiModelDiscovery { - id, - name: trimmed.to_string(), - backend: AiBackendKind::Genai, - provider_kind: Some(credential.provider_kind.clone()), - credential_id: if builtin { - None - } else { - Some(credential.id.clone()) - }, - source: AiModelSource::RustGenai, - }); - } - } - Err(error) => { - tracing::warn!(credential = label, %error, "Failed to fetch models from provider"); - errors.push(format!("{label}: {error}")); - } - } - } - - if models.is_empty() && !errors.is_empty() { - return Err(AppError::Config(format!( - "Failed to list AI models: {}", - errors.join("; ") - ))); - } - - Ok(models.into_values().collect()) -} - -pub async fn test_provider_connection( - settings: &AiSettings, - credential_id: &str, -) -> AppResult> { - let credential = settings - .provider_credentials - .iter() - .find(|credential| credential.id == credential_id && credential.enabled) - .ok_or_else(|| AppError::Config("No enabled AI provider selected".to_string()))?; - - fetch_credential_model_names(credential, settings).await -} - -async fn fetch_credential_model_names( - credential: &AiProviderCredential, - settings: &AiSettings, -) -> AppResult> { - let base_url = credential.base_url.as_deref().unwrap_or_default().trim(); - if base_url.is_empty() { - return Err(AppError::Config( - "AI provider API base URL is required".to_string(), - )); - } - - let api_key = credential - .api_key - .as_deref() - .filter(|value| !value.trim().is_empty()); - let protocol = - credential - .api_protocol - .clone() - .unwrap_or_else(|| match &credential.provider_kind { - AiProviderKind::Anthropic => AiProviderApiProtocol::Anthropic, - AiProviderKind::Gemini => AiProviderApiProtocol::Gemini, - AiProviderKind::Ollama => AiProviderApiProtocol::Ollama, - _ => AiProviderApiProtocol::OpenaiCompatible, - }); - match protocol { - AiProviderApiProtocol::OpenaiCompatible => { - fetch_openai_compatible_models(base_url, api_key, settings).await - } - AiProviderApiProtocol::Anthropic => { - fetch_provider_model_names( - base_url, - api_key, - settings, - "models", - Some(("limit", "1000")), - "data", - "id", - AiProviderApiProtocol::Anthropic, - ) - .await - } - AiProviderApiProtocol::Gemini => { - fetch_provider_model_names( - base_url, - api_key, - settings, - "models", - Some(("pageSize", "1000")), - "models", - "name", - AiProviderApiProtocol::Gemini, - ) - .await - } - AiProviderApiProtocol::Ollama => { - fetch_provider_model_names( - base_url, - api_key, - settings, - "api/tags", - None, - "models", - "name", - AiProviderApiProtocol::Ollama, - ) - .await - } - } -} - -async fn fetch_provider_model_names( - base_url: &str, - api_key: Option<&str>, - settings: &AiSettings, - path: &str, - query: Option<(&str, &str)>, - response_field: &str, - model_name_field: &str, - protocol: AiProviderApiProtocol, -) -> AppResult> { - let url = join_api_base_url(base_url, path)?; - let mut url = reqwest::Url::parse(&url) - .map_err(|error| AppError::Config(format!("Invalid model list URL: {error}")))?; - if let Some((name, value)) = query { - url.query_pairs_mut().append_pair(name, value); - } - let client = build_http_client(settings)?; - let mut names = Vec::new(); - let mut cursor: Option<(&str, String)> = None; - let mut seen_cursors = std::collections::HashSet::new(); - for _ in 0..100 { - let mut page_url = url.clone(); - if let Some((key, value)) = &cursor { - page_url.query_pairs_mut().append_pair(key, value); - } - let mut request = client - .get(page_url.as_str()) - .timeout(Duration::from_secs(15)); - if let Some(key) = api_key { - request = match protocol { - AiProviderApiProtocol::Anthropic => request - .header("x-api-key", key) - .header("anthropic-version", "2023-06-01"), - AiProviderApiProtocol::Gemini => request.header("x-goog-api-key", key), - AiProviderApiProtocol::Ollama => request.bearer_auth(key), - AiProviderApiProtocol::OpenaiCompatible => request.bearer_auth(key), - }; - } else if protocol == AiProviderApiProtocol::Anthropic { - request = request.header("anthropic-version", "2023-06-01"); - } - let response = request.send().await.map_err(|error| { - AppError::Config(format!("Failed to fetch models from {url}: {error}")) - })?; - if !response.status().is_success() { - let status = response.status(); - let body = response.text().await.unwrap_or_default(); - return Err(AppError::Config(format!( - "Failed to fetch models from {url}: {status} {body}" - ))); - } - let body: Value = response - .json() - .await - .map_err(|error| AppError::Config(format!("Invalid JSON from {url}: {error}")))?; - let entries = body[response_field] - .as_array() - .ok_or_else(|| AppError::Config(format!("Invalid model list from {url}")))?; - for entry in entries { - let name = entry[model_name_field] - .as_str() - .filter(|name| !name.trim().is_empty()) - .ok_or_else(|| AppError::Config("Invalid model list: missing model ID".into()))?; - names.push(if protocol == AiProviderApiProtocol::Gemini { - name.strip_prefix("models/").unwrap_or(name).to_string() - } else { - name.to_string() - }); - } - cursor = next_models_cursor(&body, &protocol)?; - match &cursor { - None => return Ok(names), - Some((_, value)) if !seen_cursors.insert(value.clone()) => { - return Err(AppError::Config( - "Model list pagination repeated a cursor".into(), - )); - } - _ => {} - } - } - Err(AppError::Config( - "Model list pagination exceeded 100 pages".into(), - )) -} - -fn next_models_cursor( - body: &Value, - protocol: &AiProviderApiProtocol, -) -> AppResult> { - let (key, value) = match protocol { - AiProviderApiProtocol::Anthropic if body["has_more"].as_bool() == Some(true) => { - let value = body["last_id"] - .as_str() - .filter(|value| !value.is_empty()) - .ok_or_else(|| { - AppError::Config("Model list has more pages but no last_id".into()) - })?; - ("after_id", value) - } - AiProviderApiProtocol::Gemini => { - let Some(value) = body["nextPageToken"] - .as_str() - .filter(|value| !value.is_empty()) - else { - return Ok(None); - }; - ("pageToken", value) - } - _ => return Ok(None), - }; - Ok(Some((key, value.to_string()))) -} - -fn model_discovery_credentials(settings: &AiSettings) -> Vec<&AiProviderCredential> { - settings - .provider_credentials - .iter() - .filter(|credential| credential.enabled) - .collect() -} - -fn is_builtin_ai_provider_credential_id(id: &str) -> bool { - matches!( - id, - "openai" - | "anthropic" - | "gemini" - | "deepseek" - | "groq" - | "ollama" - | "xai" - | "cohere" - | "mimo" - | "zai" - ) -} - -/// Fetches model names from an OpenAI-compatible `/v1/models` endpoint directly via HTTP, -/// bypassing `genai::Client::all_model_names` which does not apply the `ServiceTargetResolver` -/// (and therefore ignores custom auth/endpoint configuration). -async fn fetch_openai_compatible_models( - base_url: &str, - api_key: Option<&str>, - settings: &AiSettings, -) -> AppResult> { - let url = openai_compatible_models_url(base_url)?; - let client = build_http_client(settings)?; - let mut req = client.get(&url); - if let Some(key) = api_key { - req = req.bearer_auth(key); - } - let resp = req - .timeout(Duration::from_secs(15)) - .send() - .await - .map_err(|e| AppError::Config(format!("Failed to fetch models from {url}: {e}")))?; - if !resp.status().is_success() { - let status = resp.status(); - let body = resp.text().await.unwrap_or_default(); - return Err(AppError::Config(format!( - "Failed to fetch models from {url}: {status} {body}" - ))); - } - let body: Value = resp - .json() - .await - .map_err(|e| AppError::Config(format!("Invalid JSON from {url}: {e}")))?; - parse_openai_model_names(&body) -} - -fn parse_openai_model_names(body: &Value) -> AppResult> { - let models = body["data"].as_array().ok_or_else(|| { - AppError::Config("Invalid models response: expected a data array".to_string()) - })?; - models - .iter() - .map(|item| { - item["id"] - .as_str() - .filter(|name| !name.trim().is_empty()) - .map(String::from) - .ok_or_else(|| { - AppError::Config("Invalid models response: missing model ID".to_string()) - }) - }) - .collect() -} - -fn openai_compatible_models_url(base_url: &str) -> AppResult { - join_api_base_url(base_url, "models") -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::core::ai::types::{AiAction, AiContext, AiRequestOptions}; - use genai::resolver::{AuthData, Endpoint}; - - fn test_service_target(auth: AuthData) -> genai::ServiceTarget { - genai::ServiceTarget { - endpoint: Endpoint::from_static("https://default.example/v1/"), - auth, - model: ModelIden::new(AdapterKind::OpenAI, "test-model"), - } - } - - #[test] - fn builtin_models_keep_their_original_account() { - let mut settings = AiSettings::default(); - settings.provider_credentials = vec![ - test_credential_with_id("credential-new", AiProviderKind::Openai, Some("new-key")), - test_credential_with_id("openai", AiProviderKind::Openai, Some("original-key")), - ]; - let model = settings - .models - .iter() - .find(|model| model.id.starts_with("openai:")) - .unwrap(); - let credential = resolve_model_credential(&settings, model, Some(&AiProviderKind::Openai)) - .unwrap() - .unwrap(); - assert_eq!(credential.id, "openai"); - - settings.provider_credentials[1].enabled = false; - let model = settings - .models - .iter() - .find(|model| model.id.starts_with("openai:")) - .unwrap(); - assert!(resolve_model_credential(&settings, model, Some(&AiProviderKind::Openai)).is_err()); - settings.provider_credentials.pop(); - let model = settings - .models - .iter() - .find(|model| model.id.starts_with("openai:")) - .unwrap(); - assert!(resolve_model_credential(&settings, model, Some(&AiProviderKind::Openai)).is_err()); - } - - #[test] - fn models_response_distinguishes_empty_lists_from_invalid_payloads() { - assert_eq!( - parse_openai_model_names(&serde_json::json!({"data": []})).unwrap(), - Vec::::new() - ); - assert_eq!( - parse_openai_model_names(&serde_json::json!({"data": [{"id": "model-a"}]})).unwrap(), - vec!["model-a"] - ); - assert!(parse_openai_model_names(&serde_json::json!({"error": "unauthorized"})).is_err()); - assert!(parse_openai_model_names(&serde_json::json!({"data": [{}]})).is_err()); - } - - #[test] - fn model_pagination_uses_protocol_specific_cursors() { - assert_eq!( - next_models_cursor( - &serde_json::json!({"has_more": true, "last_id": "claude-a"}), - &AiProviderApiProtocol::Anthropic - ) - .unwrap(), - Some(("after_id", "claude-a".into())) - ); - assert!( - next_models_cursor( - &serde_json::json!({"has_more": true}), - &AiProviderApiProtocol::Anthropic - ) - .is_err() - ); - assert_eq!( - next_models_cursor( - &serde_json::json!({"nextPageToken": "page-2"}), - &AiProviderApiProtocol::Gemini - ) - .unwrap(), - Some(("pageToken", "page-2".into())) - ); - assert_eq!( - next_models_cursor( - &serde_json::json!({"has_more": false}), - &AiProviderApiProtocol::Anthropic - ) - .unwrap(), - None - ); - assert_eq!( - next_models_cursor(&serde_json::json!({}), &AiProviderApiProtocol::Gemini).unwrap(), - None - ); - } - - fn test_credential(kind: AiProviderKind, api_key: Option<&str>) -> AiProviderCredential { - test_credential_with_id("credential-test", kind, api_key) - } - - fn test_credential_with_id( - id: &str, - kind: AiProviderKind, - api_key: Option<&str>, - ) -> AiProviderCredential { - AiProviderCredential { - id: id.to_string(), - name: "Test Provider".to_string(), - provider_kind: kind, - icon_data_url: None, - api_protocol: None, - api_format: AiApiFormat::default(), - base_url: Some("https://api.example.com/v1/".to_string()), - api_key: api_key.map(str::to_string), - enabled: true, - } - } - - fn test_request(model_id: &str) -> AiChatRequest { - AiChatRequest { - stream_id: None, - session_id: None, - connection_id: None, - terminal_session_id: None, - owner_scope: Default::default(), - targets: vec![], - target_contexts: vec![], - mode: crate::config::AiMode::Ask, - agent_kind: crate::config::AiAgentKind::Nyaterm, - permission_mode: crate::config::AiPermissionMode::Confirm, - model_id: Some(model_id.to_string()), - model_name: None, - default_target_session_id: None, - existing_external_session_id: None, - attachments: vec![], - action: AiAction::GenerateCommand, - user_input: "test".to_string(), - context: AiContext::default(), - options: AiRequestOptions::default(), - } - } - - #[test] - fn openai_compatible_models_url_accepts_missing_trailing_slash() { - assert_eq!( - openai_compatible_models_url("https://api.example.com/v1").unwrap(), - "https://api.example.com/v1/models" - ); - } - - #[test] - fn openai_compatible_models_url_accepts_trailing_slash() { - assert_eq!( - openai_compatible_models_url("https://api.example.com/v1/").unwrap(), - "https://api.example.com/v1/models" - ); - } - - #[test] - fn openai_compatible_models_url_preserves_query() { - assert_eq!( - openai_compatible_models_url("https://api.example.com/v1?api-version=1").unwrap(), - "https://api.example.com/v1/models?api-version=1" - ); - } - - #[test] - fn ai_request_headers_use_custom_user_agent() { - let mut settings = AiSettings::default(); - settings.request_user_agent = "nyaterm-test/1.0".to_string(); - - let headers = ai_request_headers(&settings).unwrap(); - - assert_eq!( - headers - .get(USER_AGENT) - .and_then(|value| value.to_str().ok()), - Some("nyaterm-test/1.0") - ); - } - - #[test] - fn ai_request_headers_fall_back_for_blank_user_agent() { - let mut settings = AiSettings::default(); - settings.request_user_agent = " ".to_string(); - - let headers = ai_request_headers(&settings).unwrap(); - - assert_eq!( - headers - .get(USER_AGENT) - .and_then(|value| value.to_str().ok()), - Some(AI_REQUEST_USER_AGENT_DEFAULT) - ); - } - - #[test] - fn ai_request_headers_reject_invalid_user_agent() { - let mut settings = AiSettings::default(); - settings.request_user_agent = "bad\r\nvalue".to_string(); - - let error = ai_request_headers(&settings).unwrap_err(); - - assert!( - error - .to_string() - .contains("Invalid AI User-Agent header value") - ); - } - - #[test] - fn openai_compatible_empty_key_uses_no_auth_override() { - let target = apply_service_target_overrides( - test_service_target(AuthData::from_env("OPENAI_API_KEY")), - None, - Some("https://api.example.com/v1/".to_string()), - true, - ); - - assert!(matches!(target.auth, AuthData::None)); - } - - #[test] - fn openai_compatible_non_empty_key_uses_configured_key() { - let target = apply_service_target_overrides( - test_service_target(AuthData::from_env("OPENAI_API_KEY")), - Some("configured-key".to_string()), - Some("https://api.example.com/v1/".to_string()), - true, - ); - - assert!(matches!(target.auth, AuthData::Key(ref value) if value == "configured-key")); - } - - #[test] - fn custom_base_url_overrides_default_endpoint() { - let target = apply_service_target_overrides( - test_service_target(AuthData::from_env("ANTHROPIC_API_KEY")), - Some("configured-key".to_string()), - Some("https://anthropic-proxy.example.com/".to_string()), - false, - ); - - assert!(format!("{:?}", target.endpoint).contains("https://anthropic-proxy.example.com/")); - } - - #[test] - fn custom_anthropic_and_gemini_use_native_adapters() { - assert_eq!( - adapter_kind(&AiProviderKind::Anthropic), - AdapterKind::Anthropic - ); - assert_eq!(adapter_kind(&AiProviderKind::Gemini), AdapterKind::Gemini); - } - - #[test] - fn ollama_uses_native_adapter_and_preserves_model_tag() { - assert_eq!(adapter_kind(&AiProviderKind::Ollama), AdapterKind::Ollama); - assert_eq!( - genai_model_name(&AiProviderKind::Ollama, "qwen2.5:7b-instruct"), - "qwen2.5:7b-instruct" - ); - } - - #[test] - fn anthropic_and_gemini_empty_keys_fail_validation() { - for kind in [AiProviderKind::Anthropic, AiProviderKind::Gemini] { - let credential = test_credential(kind.clone(), None); - let error = validate_model_credential(&kind, Some(&credential)).unwrap_err(); - - assert!( - error - .to_string() - .contains("No API key configured for AI credential") - ); - } - } - - #[test] - fn openai_compatible_empty_key_still_passes_validation() { - let credential = test_credential(AiProviderKind::OpenaiCompatible, None); - - validate_model_credential(&AiProviderKind::OpenaiCompatible, Some(&credential)).unwrap(); - } - - #[test] - fn openai_empty_key_still_fails_validation() { - let credential = test_credential(AiProviderKind::Openai, None); - let error = - validate_model_credential(&AiProviderKind::Openai, Some(&credential)).unwrap_err(); - - assert!( - error - .to_string() - .contains("No API key configured for AI credential") - ); - } - - #[test] - fn model_discovery_credentials_include_enabled_providers() { - let mut settings = AiSettings::default(); - settings.provider_credentials = vec![ - test_credential_with_id("openai", AiProviderKind::OpenaiCompatible, None), - test_credential_with_id("credential-openai", AiProviderKind::OpenaiCompatible, None), - test_credential_with_id( - "credential-anthropic", - AiProviderKind::Anthropic, - Some("key"), - ), - test_credential_with_id("credential-gemini", AiProviderKind::Gemini, Some("key")), - AiProviderCredential { - enabled: false, - ..test_credential_with_id( - "credential-disabled", - AiProviderKind::OpenaiCompatible, - None, - ) - }, - ]; - - let ids: Vec<_> = model_discovery_credentials(&settings) - .into_iter() - .map(|credential| credential.id.as_str()) - .collect(); - - assert_eq!( - ids, - vec![ - "openai", - "credential-openai", - "credential-anthropic", - "credential-gemini" - ] - ); - } - - #[test] - fn model_resolution_prefers_explicit_credential_id_for_same_protocol_credentials() { - let mut settings = AiSettings::default(); - settings.provider_credentials = vec![ - test_credential_with_id("credential-a", AiProviderKind::Anthropic, Some("key-a")), - test_credential_with_id("credential-b", AiProviderKind::Anthropic, Some("key-b")), - ]; - settings.models = vec![AiModelConfigItem { - id: "credential-b:claude-test".to_string(), - name: "claude-test".to_string(), - backend: AiBackendKind::Genai, - provider_kind: Some(AiProviderKind::Anthropic), - credential_id: Some("credential-b".to_string()), - enabled: true, - source: AiModelSource::Manual, - last_seen_at: None, - supported_reasoning_efforts: None, - }]; - settings.default_model_id = Some("credential-b:claude-test".to_string()); - - let resolved = - resolve_request_model(&settings, &test_request("credential-b:claude-test")).unwrap(); - - let credential = resolved.credential.expect("credential should resolve"); - assert_eq!(credential.id, "credential-b"); - assert_eq!(credential.api_key.as_deref(), Some("key-b")); - assert_eq!(resolved.provider_kind, AiProviderKind::Anthropic); - } - - #[test] - fn default_reasoning_effort_auto_is_not_sent_to_genai() { - let settings = AiSettings::default(); - let options = build_chat_options(&settings); - - assert!(options.reasoning_effort.is_none()); - } - - #[test] - fn explicit_reasoning_effort_maps_to_genai_options() { - let cases = [ - (AiReasoningEffort::None, "none"), - (AiReasoningEffort::Low, "low"), - (AiReasoningEffort::Medium, "medium"), - (AiReasoningEffort::High, "high"), - (AiReasoningEffort::XHigh, "xhigh"), - ]; - - for (effort, expected) in cases { - let mut settings = AiSettings::default(); - settings.default_reasoning_effort = effort; - - let options = build_chat_options(&settings); - - assert_eq!( - options - .reasoning_effort - .as_ref() - .map(ReasoningEffort::variant_name), - Some(expected) - ); - } - } - - #[test] - fn deepseek_none_reasoning_suffix_is_not_passed_to_genai() { - assert_eq!( - genai_model_name(&AiProviderKind::Deepseek, "deepseek-v4-flash-none"), - "deepseek-v4-flash" - ); - assert_eq!( - genai_model_name(&AiProviderKind::Deepseek, "deepseek-v4-pro-none"), - "deepseek-v4-pro" - ); - } - - #[test] - fn deepseek_supported_reasoning_suffix_stays_available_for_genai() { - assert_eq!( - genai_model_name(&AiProviderKind::Deepseek, "deepseek-v4-flash-max"), - "deepseek-v4-flash-max" - ); - } - - #[test] - fn non_deepseek_none_suffix_is_preserved() { - assert_eq!( - genai_model_name(&AiProviderKind::Openai, "gpt-test-none"), - "gpt-test-none" - ); - } -} +pub use nyaterm_core::core::ai::model::*; diff --git a/src-tauri/src/core/ai/parser.rs b/src-tauri/src/core/ai/parser.rs index 0ec648e1..92978574 100644 --- a/src-tauri/src/core/ai/parser.rs +++ b/src-tauri/src/core/ai/parser.rs @@ -1,363 +1 @@ -use regex::Regex; -use serde::{Deserialize, Serialize}; -use std::sync::OnceLock; - -use super::types::{AiChatRequest, AiCommandCard, AiTerminalTarget}; - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub(super) struct AiModelOutput { - #[serde(default)] - pub text: String, - #[serde(default)] - pub reasoning: Option, - #[serde(default)] - pub command_cards: Vec, -} - -pub(super) fn parse_model_output( - raw_text: &str, - stream_reasoning: Option, -) -> (String, Option, Vec) { - let candidate = extract_json_object(raw_text).unwrap_or_else(|| raw_text.trim().to_string()); - match serde_json::from_str::(&candidate) { - Ok(output) => { - let text = if output.text.trim().is_empty() { - raw_text.trim().to_string() - } else { - output.text - }; - let reasoning_content = trim_optional_to_option(output.reasoning) - .or_else(|| trim_optional_to_option(stream_reasoning)); - let (text, extracted_reasoning) = extract_think_block(&text); - let result = ( - text, - extracted_reasoning.or(reasoning_content), - output.command_cards, - ); - if !result.0.is_empty() { - return result; - } - promote_reasoning_to_text(result) - } - Err(_) => { - let normalized_reasoning = trim_optional_to_option(stream_reasoning); - let (text, extracted_reasoning) = extract_think_block(raw_text); - let result = (text, extracted_reasoning.or(normalized_reasoning), vec![]); - if !result.0.is_empty() { - return result; - } - promote_reasoning_to_text(result) - } - } -} - -pub(super) fn bind_command_card_targets(cards: &mut [AiCommandCard], request: &AiChatRequest) { - match request.targets.as_slice() { - [] => {} - [target] => { - for card in cards { - card.target = Some(target.clone()); - card.target_terminal_session_id = Some(target.terminal_session_id.clone()); - } - } - targets => { - for card in cards { - card.target = resolve_card_target(card, targets); - } - } - } -} - -fn resolve_card_target( - card: &AiCommandCard, - targets: &[AiTerminalTarget], -) -> Option { - if let Some(target_id) = card - .target_terminal_session_id - .as_deref() - .filter(|value| !value.trim().is_empty()) - { - return targets - .iter() - .find(|target| target.terminal_session_id == target_id) - .cloned(); - } - - if let Some(model_target) = card.target.as_ref() { - return targets - .iter() - .find(|target| target.terminal_session_id == model_target.terminal_session_id) - .cloned(); - } - - None -} - -/// When the primary text is empty but reasoning content exists, try to -/// extract a usable answer from the reasoning. Thinking models (e.g. Qwen3) -/// sometimes put the entire response in the reasoning channel. -fn promote_reasoning_to_text( - (text, reasoning, cards): (String, Option, Vec), -) -> (String, Option, Vec) { - if !text.is_empty() { - return (text, reasoning, cards); - } - let reasoning_str = match reasoning.as_deref() { - Some(r) if !r.trim().is_empty() => r, - _ => return (text, reasoning, cards), - }; - - tracing::info!( - reasoning_preview = %truncate_preview(reasoning_str, 300), - "Text content empty; attempting to extract answer from reasoning" - ); - - if let Some(json_str) = extract_json_object(reasoning_str) { - if let Ok(output) = serde_json::from_str::(&json_str) { - let promoted_text = if output.text.trim().is_empty() { - json_str.clone() - } else { - output.text - }; - let inner_reasoning = trim_optional_to_option(output.reasoning); - return (promoted_text, inner_reasoning, output.command_cards); - } - } - - let (visible, inner_reasoning) = extract_think_block(reasoning_str); - if !visible.is_empty() { - return (visible, inner_reasoning, cards); - } - - (reasoning.unwrap_or_default(), None, cards) -} - -pub(super) fn extract_json_object(raw_text: &str) -> Option { - let trimmed = raw_text - .trim() - .trim_start_matches("```json") - .trim_start_matches("```") - .trim_end_matches("```") - .trim(); - if trimmed.starts_with('{') && trimmed.ends_with('}') { - return Some(trimmed.to_string()); - } - let start = trimmed.find('{')?; - let end = trimmed.rfind('}')?; - if start >= end { - return None; - } - Some(trimmed[start..=end].to_string()) -} - -pub(super) fn extract_text_from_assistant(content: &str) -> String { - let trimmed = content.trim(); - if let Some(json_str) = extract_json_object(trimmed) { - if let Ok(output) = serde_json::from_str::(&json_str) { - if !output.text.trim().is_empty() { - return output.text; - } - } - } - trimmed.to_string() -} - -fn extract_think_block(raw_text: &str) -> (String, Option) { - static THINK_REGEX: OnceLock = OnceLock::new(); - let regex = THINK_REGEX.get_or_init(|| Regex::new(r"(?is)(.*?)").unwrap()); - - let mut reasoning_parts = Vec::new(); - for captures in regex.captures_iter(raw_text) { - if let Some(value) = captures.get(1) { - let reasoning = value.as_str().trim(); - if !reasoning.is_empty() { - reasoning_parts.push(reasoning.to_string()); - } - } - } - - let visible_text = regex.replace_all(raw_text, "").to_string(); - ( - visible_text.trim().to_string(), - trim_string_to_option(reasoning_parts.join("\n\n")), - ) -} - -pub(super) fn truncate_preview(s: &str, max_len: usize) -> String { - let trimmed = s.trim(); - if trimmed.len() <= max_len { - trimmed.to_string() - } else { - let boundary = trimmed - .char_indices() - .map(|(i, _)| i) - .take_while(|&i| i <= max_len) - .last() - .unwrap_or(0); - format!("{}…", &trimmed[..boundary]) - } -} - -pub(super) fn trim_string_to_option(value: String) -> Option { - let trimmed = value.trim(); - if trimmed.is_empty() { - None - } else { - Some(trimmed.to_string()) - } -} - -pub(super) fn trim_optional_to_option(value: Option) -> Option { - value.and_then(trim_string_to_option) -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::core::ai::types::AiAction; - - #[test] - fn parses_json_command_cards() { - let raw = r#"{"text":"ok","commandCards":[{"id":"1","title":"CPU","command":"ps aux","explanation":"x","riskLevel":"low","riskReason":"read only","expectedEffect":"list","rollback":"none"}]}"#; - let (text, reasoning, cards) = parse_model_output(raw, None); - assert_eq!(text, "ok"); - assert_eq!(reasoning, None); - assert_eq!(cards.len(), 1); - } - - #[test] - fn single_target_request_binds_all_command_cards() { - let mut cards = vec![AiCommandCard { - id: "1".to_string(), - title: "CPU".to_string(), - command: "ps aux".to_string(), - explanation: "x".to_string(), - risk_level: None, - risk_reason: None, - expected_effect: "list".to_string(), - rollback: None, - category: None, - references: vec![], - target_terminal_session_id: None, - target: None, - }]; - let mut request = test_request(); - request.targets.push(AiTerminalTarget { - terminal_session_id: "term-a".to_string(), - connection_id: Some("conn-a".to_string()), - label: "Prod".to_string(), - host: Some("10.0.0.1".to_string()), - username: Some("root".to_string()), - session_type: "SSH".to_string(), - }); - - bind_command_card_targets(&mut cards, &request); - - assert_eq!( - cards[0] - .target - .as_ref() - .map(|target| target.terminal_session_id.as_str()), - Some("term-a") - ); - } - - #[test] - fn multi_target_request_rejects_unknown_command_target() { - let mut cards = vec![AiCommandCard { - id: "1".to_string(), - title: "CPU".to_string(), - command: "ps aux".to_string(), - explanation: "x".to_string(), - risk_level: None, - risk_reason: None, - expected_effect: "list".to_string(), - rollback: None, - category: None, - references: vec![], - target_terminal_session_id: Some("term-x".to_string()), - target: None, - }]; - let mut request = test_request(); - request.targets.push(AiTerminalTarget { - terminal_session_id: "term-a".to_string(), - connection_id: None, - label: "Prod".to_string(), - host: None, - username: None, - session_type: "SSH".to_string(), - }); - request.targets.push(AiTerminalTarget { - terminal_session_id: "term-b".to_string(), - connection_id: None, - label: "Test".to_string(), - host: None, - username: None, - session_type: "SSH".to_string(), - }); - - bind_command_card_targets(&mut cards, &request); - - assert!(cards[0].target.is_none()); - } - - fn test_request() -> AiChatRequest { - AiChatRequest { - stream_id: None, - session_id: None, - connection_id: None, - terminal_session_id: None, - owner_scope: Default::default(), - targets: vec![], - target_contexts: vec![], - mode: crate::config::AiMode::Ask, - agent_kind: crate::config::AiAgentKind::Nyaterm, - permission_mode: crate::config::AiPermissionMode::Confirm, - model_id: None, - model_name: None, - default_target_session_id: None, - existing_external_session_id: None, - attachments: vec![], - action: AiAction::GenerateCommand, - user_input: "test".to_string(), - context: Default::default(), - options: Default::default(), - } - } - - #[test] - fn parse_failure_returns_text_without_cards() { - let (text, reasoning, cards) = parse_model_output("plain text", None); - assert_eq!(text, "plain text"); - assert_eq!(reasoning, None); - assert!(cards.is_empty()); - } - - #[test] - fn extracts_think_block_into_reasoning() { - let (text, reasoning, cards) = - parse_model_output("step 1\nstep 2final answer", None); - assert_eq!(text, "final answer"); - assert_eq!(reasoning.as_deref(), Some("step 1\nstep 2")); - assert!(cards.is_empty()); - } - - #[test] - fn keeps_markdown_text_when_json_parse_fails() { - let markdown = "## Summary\n\n- item 1\n- item 2"; - let (text, reasoning, cards) = parse_model_output(markdown, None); - assert_eq!(text, markdown); - assert_eq!(reasoning, None); - assert!(cards.is_empty()); - } - - #[test] - fn prefers_json_reasoning_when_present() { - let raw = r#"{"text":"answer","reasoning":"first\nsecond","commandCards":[]}"#; - let (text, reasoning, cards) = parse_model_output(raw, None); - assert_eq!(text, "answer"); - assert_eq!(reasoning.as_deref(), Some("first\nsecond")); - assert!(cards.is_empty()); - } -} +pub use nyaterm_core::core::ai::parser::*; diff --git a/src-tauri/src/core/ai/prompt.rs b/src-tauri/src/core/ai/prompt.rs index 872422aa..a9fc2f35 100644 --- a/src-tauri/src/core/ai/prompt.rs +++ b/src-tauri/src/core/ai/prompt.rs @@ -1,1000 +1 @@ -use crate::config::AiSettings; - -use super::types::{AiAction, AiChatRequest, CommandObservation}; - -const SYSTEM_PROMPT_ZH: &str = r#"你是一个专业、谨慎、安全优先的 Linux / DevOps / 云原生终端助手。 -你的任务是帮助用户解释终端输出、生成 Shell 命令、分析错误、提供排查步骤。 - -必须遵守: -1. 不要建议不可逆高危操作,除非明确说明风险和安全替代方案。 -2. 默认生成只读诊断命令。 -3. 对任何删除、格式化、重启、停服务、改权限、批量变更命令标记风险。 -4. 命令必须适配用户当前系统、架构、shell 和权限上下文。 -5. 输出必须结构化,包含命令、说明、风险等级、影响范围和回滚建议。 -6. 不要编造当前系统不存在的信息;不确定时给出验证命令。 -7. 不要要求用户粘贴密码、私钥、token。 - -只返回一个 JSON 对象,不要使用 Markdown 代码块。格式: -{ - "text": "给用户看的说明", - "commandCards": [ - { - "id": "cmd-uuid", - "title": "标题", - "command": "shell command", - "explanation": "命令说明", - "riskLevel": "low|medium|high|critical", - "riskReason": "风险原因", - "expectedEffect": "预计影响", - "rollback": "回滚方式或无需回滚", - "category": "Linux 性能" - } - ] -}"#; - -const SYSTEM_PROMPT_ZH_HANT: &str = r#"你是一個專業、謹慎、安全優先的 Linux / DevOps / 雲端原生終端助手。 -你的任務是協助使用者解釋終端輸出、產生 Shell 命令、分析錯誤、提供排查步驟。 - -必須遵守: -1. 不要建議不可逆的高風險操作,除非明確說明風險並提供較安全的替代方案。 -2. 預設產生唯讀診斷命令。 -3. 對任何刪除、格式化、重新啟動、停止服務、修改權限、批次變更命令標記風險。 -4. 命令必須符合使用者目前的系統、架構、Shell 和權限情境。 -5. 輸出必須結構化,包含命令、說明、風險等級、影響範圍和復原建議。 -6. 不要編造目前系統不存在的資訊;不確定時提供驗證命令。 -7. 不要要求使用者貼上密碼、私鑰、token。 - -只回傳一個 JSON 物件,不要使用 Markdown 程式碼區塊。格式: -{ - "text": "給使用者看的說明", - "commandCards": [ - { - "id": "cmd-uuid", - "title": "標題", - "command": "shell command", - "explanation": "命令說明", - "riskLevel": "low|medium|high|critical", - "riskReason": "風險原因", - "expectedEffect": "預期影響", - "rollback": "復原方式或無需復原", - "category": "Linux 效能" - } - ] -}"#; - -const SYSTEM_PROMPT_EN: &str = r#"You are a professional, careful, safety-first Linux / DevOps / cloud-native terminal assistant. -Your job is to explain terminal output, generate Shell commands, analyze errors, and suggest next troubleshooting steps. - -You must follow these rules: -1. Do not suggest irreversible high-risk actions unless you clearly explain the risk and provide safer alternatives. -2. Prefer read-only diagnostic commands by default. -3. Mark any delete, format, restart, stop-service, permission-change, or bulk-change command with the appropriate risk. -4. Commands must fit the user's current system, architecture, shell, and privilege context. -5. Output must be structured and include commands, explanations, risk level, expected effect, and rollback guidance. -6. Do not invent facts about the current system. If uncertain, provide verification commands. -7. Do not ask the user to paste passwords, private keys, or tokens. - -Return exactly one JSON object and do not use Markdown code fences. Format: -{ - "text": "user-facing explanation", - "commandCards": [ - { - "id": "cmd-uuid", - "title": "title", - "command": "shell command", - "explanation": "command explanation", - "riskLevel": "low|medium|high|critical", - "riskReason": "why this risk applies", - "expectedEffect": "expected effect", - "rollback": "rollback steps or state that rollback is unnecessary", - "category": "Linux performance" - } - ] -}"#; - -const SYSTEM_PROMPT_KO: &str = r#"당신은 전문적이고 신중하며 안전을 최우선으로 하는 Linux / DevOps / 클라우드 네이티브 터미널 어시스턴트입니다. -사용자의 터미널 출력 설명, Shell 명령 생성, 오류 분석, 다음 문제 해결 단계 제안을 돕는 것이 임무입니다. - -반드시 다음 규칙을 따르세요: -1. 되돌릴 수 없는 고위험 작업은 위험을 명확히 설명하고 더 안전한 대안을 제공하지 않는 한 제안하지 마세요. -2. 기본적으로 읽기 전용 진단 명령을 우선하세요. -3. 삭제, 포맷, 재시작, 서비스 중지, 권한 변경, 대량 변경 명령에는 적절한 위험 등급을 표시하세요. -4. 명령은 사용자의 현재 시스템, 아키텍처, shell, 권한 컨텍스트에 맞아야 합니다. -5. 출력은 구조화되어야 하며 명령, 설명, 위험 등급, 예상 효과, 롤백 안내를 포함해야 합니다. -6. 현재 시스템에 대해 존재하지 않는 정보를 지어내지 마세요. 확실하지 않으면 확인 명령을 제공하세요. -7. 사용자에게 비밀번호, 개인 키, token을 붙여 넣으라고 요청하지 마세요. - -Markdown 코드 블록을 사용하지 말고 정확히 하나의 JSON 객체만 반환하세요. 형식: -{ - "text": "사용자에게 보여줄 설명", - "commandCards": [ - { - "id": "cmd-uuid", - "title": "제목", - "command": "shell command", - "explanation": "명령 설명", - "riskLevel": "low|medium|high|critical", - "riskReason": "위험 이유", - "expectedEffect": "예상 효과", - "rollback": "롤백 단계 또는 롤백이 필요 없다는 설명", - "category": "Linux 성능" - } - ] -}"#; - -const AGENT_SYSTEM_PROMPT_ZH: &str = r#"你是一个终端自动化 Agent,通过"思考—执行—观察"循环完成用户的任务。 - -每一轮你只能做一件事:调用 execute_command 工具执行一条命令,或调用 final_answer 工具给出最终回答。 - -规则: -1. 每轮必须且只能调用一个工具,不要在普通正文里输出 JSON。 -2. 如果需要执行命令,调用 execute_command。 -3. 任务完成或无需执行命令时,调用 final_answer。 -4. thought 和 answer 尽量使用用户请求指定的目标语言。 -5. 优先使用只读命令收集信息,再做修改操作。 -6. 不要执行不可逆高危命令(如 rm -rf /、mkfs、停止 SSH 等),改为在 thought 中说明风险并调用 final_answer。 -7. 不要编造信息;不确定时先用验证命令确认。 -8. 不要要求用户提供密码、私钥、token。 -9. 命令必须适配用户当前的系统和 shell 环境。 -10. riskLevel 规则:只读命令 → low,普通写操作 → medium,删除/重启/权限修改 → high,不可逆破坏 → critical。 -11. 调用 execute_command 时必须同时提供 riskLevel 和 riskReason;riskReason 要简短说明为什么这样分级。"#; - -const AGENT_SYSTEM_PROMPT_ZH_HANT: &str = r#"你是一個終端自動化 Agent,透過「思考—執行—觀察」迴圈完成使用者的任務。 - -每一輪你只能做一件事:呼叫 execute_command 工具執行一個命令,或呼叫 final_answer 工具提供最終回答。 - -規則: -1. 每輪必須且只能呼叫一個工具,不要在一般正文裡輸出 JSON。 -2. 如果需要執行命令,呼叫 execute_command。 -3. 任務完成或無需執行命令時,呼叫 final_answer。 -4. thought 和 answer 盡量使用使用者請求指定的目標語言。 -5. 優先使用唯讀命令收集資訊,再做修改操作。 -6. 不要執行不可逆的高風險命令(如 rm -rf /、mkfs、停止 SSH 等),改為在 thought 中說明風險並呼叫 final_answer。 -7. 不要編造資訊;不確定時先用驗證命令確認。 -8. 不要要求使用者提供密碼、私鑰、token。 -9. 命令必須符合使用者目前的系統和 Shell 環境。 -10. riskLevel 規則:唯讀命令 → low,一般寫入操作 → medium,刪除/重新啟動/權限修改 → high,不可逆破壞 → critical。 -11. 呼叫 execute_command 時必須同時提供 riskLevel 和 riskReason;riskReason 要簡短說明為什麼這樣分級。"#; - -const AGENT_SYSTEM_PROMPT_EN: &str = r#"You are a terminal automation agent that completes tasks using a think-execute-observe loop. - -In each turn, do exactly one thing: call the execute_command tool to execute one command, or call the final_answer tool to finish. - -Rules: -1. You must call exactly one tool per turn. Do not put protocol JSON in normal assistant text. -2. If a command must be executed, call execute_command. -3. If the task is complete or no command is needed, call final_answer. -4. Use the target language requested by the user for both thought and answer whenever possible. -5. Prefer read-only commands to gather information before making changes. -6. Do not execute irreversible high-risk commands (for example rm -rf /, mkfs, or stopping SSH). Explain the risk in thought and call final_answer instead. -7. Do not invent facts. If uncertain, verify first. -8. Do not ask the user for passwords, private keys, or tokens. -9. Commands must fit the user's current system and shell environment. -10. riskLevel guidance: read-only commands -> low, normal write actions -> medium, delete/restart/permission changes -> high, irreversible destructive actions -> critical. -11. execute_command calls must include both riskLevel and riskReason. Keep riskReason brief and explain why the risk applies."#; - -const AGENT_SYSTEM_PROMPT_KO: &str = r#"당신은 생각-실행-관찰 루프를 사용해 작업을 완료하는 터미널 자동화 Agent입니다. - -각 턴에서는 정확히 한 가지만 해야 합니다: execute_command 도구를 호출해 명령 하나를 실행하거나, final_answer 도구를 호출해 마무리하세요. - -규칙: -1. 각 턴에서 반드시 정확히 하나의 도구만 호출하세요. 일반 assistant 본문에 프로토콜 JSON을 넣지 마세요. -2. 명령을 실행해야 한다면 execute_command를 호출하세요. -3. 작업이 완료되었거나 명령이 필요 없다면 final_answer를 호출하세요. -4. 가능하면 thought와 answer 모두 사용자 요청의 대상 언어를 사용하세요. -5. 변경 작업 전에 읽기 전용 명령으로 정보를 수집하는 것을 우선하세요. -6. 되돌릴 수 없는 고위험 명령(예: rm -rf /, mkfs, SSH 중지)은 실행하지 마세요. thought에서 위험을 설명하고 대신 final_answer를 호출하세요. -7. 정보를 지어내지 마세요. 확실하지 않으면 먼저 확인하세요. -8. 사용자에게 비밀번호, 개인 키, token을 요청하지 마세요. -9. 명령은 사용자의 현재 시스템과 shell 환경에 맞아야 합니다. -10. riskLevel 기준: 읽기 전용 명령 -> low, 일반 쓰기 작업 -> medium, 삭제/재시작/권한 변경 -> high, 되돌릴 수 없는 파괴적 작업 -> critical. -11. execute_command 호출에는 반드시 riskLevel과 riskReason을 모두 포함하세요. riskReason은 짧게 작성하고 해당 위험 등급의 이유를 설명하세요."#; - -#[derive(Debug, Clone, Copy, Eq, PartialEq)] -enum PromptLanguage { - ZhHans, - ZhHant, - En, - Ko, -} - -fn resolve_prompt_language(language: &str) -> PromptLanguage { - let normalized = language.trim().replace('_', "-").to_ascii_lowercase(); - match normalized.as_str() { - "zh-tw" | "zh-hant" | "zh-hk" | "zh-mo" => PromptLanguage::ZhHant, - "zh" | "zh-cn" | "zh-sg" | "zh-hans" => PromptLanguage::ZhHans, - "en" | "en-us" | "en-gb" => PromptLanguage::En, - "ko" | "ko-kr" => PromptLanguage::Ko, - code if code.starts_with("zh-hant-") => PromptLanguage::ZhHant, - code if code.starts_with("zh-hans-") => PromptLanguage::ZhHans, - _ => PromptLanguage::En, - } -} - -fn user_input_with_target_contexts(request: &AiChatRequest) -> String { - if request.targets.is_empty() && request.target_contexts.is_empty() { - return request.user_input.clone(); - } - - let mut result = request.user_input.clone(); - result.push_str("\n\nAvailable terminal targets:\n"); - for target in &request.targets { - result.push_str(&format!( - "- {}: {}{}{}\n", - target.terminal_session_id, - target.label, - target - .host - .as_deref() - .map(|host| format!(" host={host}")) - .unwrap_or_default(), - target - .username - .as_deref() - .map(|username| format!(" user={username}")) - .unwrap_or_default() - )); - } - - if request.targets.len() > 1 { - result.push_str( - "\nEvery executable command card or agent execute_command call must include a valid targetTerminalSessionId from the list above. If no target is clear, do not execute.\n", - ); - } - - if !request.target_contexts.is_empty() { - result.push_str("\nTerminal target context snapshots:\n"); - for item in &request.target_contexts { - let target_label = item - .target - .as_ref() - .map(|target| format!("{} ({})", target.label, target.terminal_session_id)) - .unwrap_or_else(|| "unknown".to_string()); - let ctx = &item.context; - result.push_str(&format!( - "\n[{}]\n- cwd: {}\n- input: {}\n- selected text:\n{}\n- recent output:\n{}\n", - target_label, - ctx.cwd.as_deref().unwrap_or("-"), - ctx.input_buffer, - ctx.selected_text, - ctx.recent_output - )); - } - } - - result -} - -pub(super) fn system_prompt(language: &str) -> &'static str { - match resolve_prompt_language(language) { - PromptLanguage::ZhHans => SYSTEM_PROMPT_ZH, - PromptLanguage::ZhHant => SYSTEM_PROMPT_ZH_HANT, - PromptLanguage::En => SYSTEM_PROMPT_EN, - PromptLanguage::Ko => SYSTEM_PROMPT_KO, - } -} - -pub(super) fn agent_system_prompt(language: &str) -> &'static str { - match resolve_prompt_language(language) { - PromptLanguage::ZhHans => AGENT_SYSTEM_PROMPT_ZH, - PromptLanguage::ZhHant => AGENT_SYSTEM_PROMPT_ZH_HANT, - PromptLanguage::En => AGENT_SYSTEM_PROMPT_EN, - PromptLanguage::Ko => AGENT_SYSTEM_PROMPT_KO, - } -} - -pub(super) fn build_agent_prompt(request: &AiChatRequest, settings: &AiSettings) -> String { - let ctx = &request.context; - let user_input = user_input_with_target_contexts(request); - match resolve_prompt_language(&request.options.language) { - PromptLanguage::ZhHans => format!( - r#"用户任务: -{user_input} - -当前连接上下文: -- 连接名:{connection_name} -- 主机:{host} -- 用户:{username} -- 当前目录:{cwd} -- 操作系统:{os} -- 架构:{arch} - -最近终端输出(最多 {line_limit} 行): -{recent_output} - -要求: -- 面向用户的说明、总结以及推理过程使用:{language} -- 命令、路径、文件名、配置键名保持原样,不要翻译 -- 通过 execute_command 执行的命令必须是非交互式的,不能等待用户输入、确认或翻页 -- 对可能启动分页器的工具使用其非交互或禁用分页选项,例如 git --no-pager ...、journalctl --no-pager;不要依赖用户按键继续 -- 如果命令必须进入交互式 UI、确认提示或等待输入且没有非交互替代方案,不要自动执行;改用非交互替代方案,或调用 final_answer 向用户说明 - -请开始执行任务。每轮调用且只调用一个工具。"#, - user_input = user_input, - connection_name = ctx.connection_name.as_deref().unwrap_or("-"), - host = ctx.host.as_deref().unwrap_or("-"), - username = ctx.username.as_deref().unwrap_or("-"), - cwd = ctx.cwd.as_deref().unwrap_or("-"), - os = ctx.os.as_deref().unwrap_or("-"), - arch = ctx.arch.as_deref().unwrap_or(std::env::consts::ARCH), - line_limit = settings.context_line_limit, - recent_output = ctx.recent_output, - language = request.options.language, - ), - PromptLanguage::ZhHant => format!( - r#"使用者任務: -{user_input} - -目前連線情境: -- 連線名稱:{connection_name} -- 主機:{host} -- 使用者:{username} -- 目前目錄:{cwd} -- 作業系統:{os} -- 架構:{arch} - -最近終端輸出(最多 {line_limit} 行): -{recent_output} - -要求: -- 面向使用者的說明、摘要以及推理過程使用:{language} -- 命令、路徑、檔名、設定鍵名保持原樣,不要翻譯 -- 透過 execute_command 執行的命令必須是非互動式的,不能等待使用者輸入、確認或翻頁 -- 對可能啟動分頁器的工具使用其非互動或停用分頁選項,例如 git --no-pager ...、journalctl --no-pager;不要依賴使用者按鍵繼續 -- 如果命令必須進入互動式 UI、確認提示或等待輸入且沒有非互動替代方案,不要自動執行;改用非互動替代方案,或呼叫 final_answer 向使用者說明 - -請開始執行任務。每輪呼叫且只呼叫一個工具。"#, - user_input = user_input, - connection_name = ctx.connection_name.as_deref().unwrap_or("-"), - host = ctx.host.as_deref().unwrap_or("-"), - username = ctx.username.as_deref().unwrap_or("-"), - cwd = ctx.cwd.as_deref().unwrap_or("-"), - os = ctx.os.as_deref().unwrap_or("-"), - arch = ctx.arch.as_deref().unwrap_or(std::env::consts::ARCH), - line_limit = settings.context_line_limit, - recent_output = ctx.recent_output, - language = request.options.language, - ), - PromptLanguage::Ko => format!( - r#"사용자 작업: -{user_input} - -현재 연결 컨텍스트: -- 연결 이름: {connection_name} -- 호스트: {host} -- 사용자: {username} -- 현재 디렉터리: {cwd} -- 운영 체제: {os} -- 아키텍처: {arch} - -최근 터미널 출력(최대 {line_limit}줄): -{recent_output} - -요구 사항: -- 사용자에게 보이는 설명, 요약, 추론에는 {language}을(를) 사용하세요. -- 명령, 경로, 파일 이름, 구성 키는 변경하지 말고 번역하지 마세요. -- execute_command로 실행하는 명령은 비대화형이어야 하며 사용자 입력, 확인 또는 페이지 넘김을 기다리면 안 됩니다. -- pager를 실행할 수 있는 도구는 git --no-pager ..., journalctl --no-pager 같은 도구별 비대화형 또는 pager 비활성화 옵션을 사용하고 사용자 키 입력에 의존하지 마세요. -- 명령이 대화형 UI, 확인 프롬프트 또는 입력 대기를 반드시 필요로 하고 비대화형 대안이 없다면 자동 실행하지 말고, 비대화형 대안을 사용하거나 final_answer로 사용자에게 설명하세요. - -지금 작업을 시작하세요. 각 턴에서 정확히 하나의 도구만 호출하세요."#, - user_input = user_input, - connection_name = ctx.connection_name.as_deref().unwrap_or("-"), - host = ctx.host.as_deref().unwrap_or("-"), - username = ctx.username.as_deref().unwrap_or("-"), - cwd = ctx.cwd.as_deref().unwrap_or("-"), - os = ctx.os.as_deref().unwrap_or("-"), - arch = ctx.arch.as_deref().unwrap_or(std::env::consts::ARCH), - line_limit = settings.context_line_limit, - recent_output = ctx.recent_output, - language = request.options.language, - ), - PromptLanguage::En => format!( - r#"User task: -{user_input} - -Current connection context: -- Connection name: {connection_name} -- Host: {host} -- User: {username} -- Current directory: {cwd} -- Operating system: {os} -- Architecture: {arch} - -Recent terminal output (up to {line_limit} lines): -{recent_output} - -Requirements: -- Use {language} for user-facing explanations and summaries. -- Prefer {language} for reasoning when possible. -- Keep commands, paths, file names, and configuration keys unchanged. -- Commands sent through execute_command must be non-interactive and must not wait for user input, confirmation, or pager navigation. -- For tools that may start a pager, use tool-specific non-interactive or no-pager options such as git --no-pager ... or journalctl --no-pager; never rely on user key presses to continue. -- If a command requires an interactive UI, confirmation prompt, or input and has no non-interactive alternative, do not execute it automatically; choose a non-interactive alternative or call final_answer to explain it to the user. - -Start the task now. Call exactly one tool per turn."#, - user_input = user_input, - connection_name = ctx.connection_name.as_deref().unwrap_or("-"), - host = ctx.host.as_deref().unwrap_or("-"), - username = ctx.username.as_deref().unwrap_or("-"), - cwd = ctx.cwd.as_deref().unwrap_or("-"), - os = ctx.os.as_deref().unwrap_or("-"), - arch = ctx.arch.as_deref().unwrap_or(std::env::consts::ARCH), - line_limit = settings.context_line_limit, - recent_output = ctx.recent_output, - language = request.options.language, - ), - } -} - -pub(super) fn build_observation_message( - obs: &CommandObservation, - command: &str, - language: &str, -) -> String { - let status = obs - .exit_code - .map(|c| format!("exit code {c}")) - .unwrap_or_else(|| "unknown exit code".to_string()); - let output = if obs.output.len() > 8000 { - let mut start = obs.output.len() - 8000; - while !obs.output.is_char_boundary(start) { - start += 1; - } - let truncated = &obs.output[start..]; - format!("...(truncated)\n{truncated}") - } else { - obs.output.clone() - }; - match resolve_prompt_language(language) { - PromptLanguage::ZhHans => format!( - "命令 `{command}` 执行完成({status},耗时 {duration}ms)。\n\n输出:\n{output}\n\n请根据观察结果决定下一步。每轮必须且只能调用一个工具:execute_command 或 final_answer。不要在普通正文里输出 JSON。", - duration = obs.duration_ms, - ), - PromptLanguage::ZhHant => format!( - "命令 `{command}` 執行完成({status},耗時 {duration}ms)。\n\n輸出:\n{output}\n\n請根據觀察結果決定下一步。每輪必須且只能呼叫一個工具:execute_command 或 final_answer。不要在一般正文裡輸出 JSON。", - duration = obs.duration_ms, - ), - PromptLanguage::Ko => format!( - "명령 `{command}` 실행이 완료되었습니다({status}, {duration}ms).\n\n출력:\n{output}\n\n이 관찰 결과를 바탕으로 다음 단계를 결정하세요. execute_command 또는 final_answer 중 정확히 하나의 도구만 호출하세요. 일반 assistant 본문에 프로토콜 JSON을 넣지 마세요.", - duration = obs.duration_ms, - ), - PromptLanguage::En => format!( - "Command `{command}` finished ({status}, {duration}ms).\n\nOutput:\n{output}\n\nDecide the next step based on this observation. Call exactly one tool: execute_command or final_answer. Do not put protocol JSON in normal assistant text.", - duration = obs.duration_ms, - ), - } -} - -pub(super) fn agent_send_only_observation(language: &str) -> &'static str { - match resolve_prompt_language(language) { - PromptLanguage::ZhHans => "命令已发送到终端,但当前会话使用仅发送模式,未捕获输出。", - PromptLanguage::ZhHant => "命令已傳送到終端,但目前工作階段使用僅傳送模式,未擷取輸出。", - PromptLanguage::Ko => { - "명령을 터미널로 보냈지만 현재 세션은 전송 전용 모드이므로 출력을 캡처하지 않았습니다." - } - PromptLanguage::En => { - "Command was sent to the terminal, but this session uses send-only mode, so output was not captured." - } - } -} - -pub(super) fn agent_execution_disabled_message(language: &str) -> &'static str { - match resolve_prompt_language(language) { - PromptLanguage::ZhHans => "当前会话已禁用 AI Agent 命令执行。", - PromptLanguage::ZhHant => "目前工作階段已停用 AI Agent 命令執行。", - PromptLanguage::Ko => "현재 세션에서 AI Agent 명령 실행이 비활성화되어 있습니다.", - PromptLanguage::En => "AI Agent command execution is disabled for the current session.", - } -} - -pub(super) fn build_agent_rejected_message(command: &str, language: &str) -> String { - match resolve_prompt_language(language) { - PromptLanguage::ZhHans => { - format!("用户拒绝执行命令 `{command}`。请换用其他方案或给出 final_answer。") - } - PromptLanguage::ZhHant => { - format!("使用者拒絕執行命令 `{command}`。請改用其他方案或呼叫 final_answer。") - } - PromptLanguage::Ko => { - format!( - "사용자가 명령 `{command}` 실행을 거부했습니다. 다른 방법을 사용하거나 final_answer를 제공하세요." - ) - } - PromptLanguage::En => { - format!( - "The user rejected command `{command}`. Use another approach or provide final_answer." - ) - } - } -} - -pub(super) fn build_agent_failed_message(error: &str, language: &str) -> String { - match resolve_prompt_language(language) { - PromptLanguage::ZhHans => format!("命令执行失败:{error}。请分析原因并给出下一步。"), - PromptLanguage::ZhHant => format!("命令執行失敗:{error}。請分析原因並提供下一步。"), - PromptLanguage::Ko => { - format!("명령 실행이 실패했습니다: {error}. 원인을 분석하고 다음 단계를 제시하세요.") - } - PromptLanguage::En => { - format!( - "Command execution failed: {error}. Analyze the cause and provide the next step." - ) - } - } -} - -pub(super) fn build_agent_unknown_action_message( - action: &str, - fallback: &str, - language: &str, -) -> String { - match resolve_prompt_language(language) { - PromptLanguage::ZhHans => { - format!("未知动作 `{action}`。将其作为最终回答处理。{fallback}") - } - PromptLanguage::ZhHant => { - format!("未知動作 `{action}`。將其作為最終回答處理。{fallback}") - } - PromptLanguage::Ko => { - format!("알 수 없는 작업 `{action}`입니다. 최종 답변으로 처리합니다. {fallback}") - } - PromptLanguage::En => { - format!("Unknown action `{action}`. Treating as final answer. {fallback}") - } - } -} - -pub(super) fn agent_max_steps_message(language: &str) -> &'static str { - match resolve_prompt_language(language) { - PromptLanguage::ZhHans => "Agent 已达到最大步数限制,任务可能未完成。", - PromptLanguage::ZhHant => "Agent 已達到最大步數限制,任務可能尚未完成。", - PromptLanguage::Ko => { - "Agent가 최대 단계 수 제한에 도달했으며 작업이 완료되지 않았을 수 있습니다." - } - PromptLanguage::En => { - "Agent reached the maximum step limit, so the task may be incomplete." - } - } -} - -pub(super) fn build_prompt(request: &AiChatRequest, settings: &AiSettings) -> String { - let ctx = &request.context; - let user_input = user_input_with_target_contexts(request); - match resolve_prompt_language(&request.options.language) { - PromptLanguage::ZhHans => { - let action = match request.action { - AiAction::GenerateCommand => "根据自然语言需求生成 1 到 2 条 Shell 命令", - AiAction::ExplainOutput => "解释最近终端输出并给出下一步建议", - AiAction::ExplainSelected => "解释用户选中的终端文本并给出下一步建议", - AiAction::AnalyzeError => "分析终端错误输出并给出排查步骤", - AiAction::RepairFromSelection => "根据选中内容生成修复或排查命令", - AiAction::CustomTerminalAction => "根据用户配置的终端 AI 功能处理选中内容", - AiAction::CustomFileAction => "根据用户配置的文件 AI 功能处理文件内容", - }; - format!( - r#"任务:{action} -用户需求: -{user_input} - -当前连接上下文: -- 连接名:{connection_name} -- 主机:{host} -- 端口:{port} -- 用户:{username} -- 当前目录:{cwd} -- 操作系统:{os} -- 架构:{arch} -- 当前输入:{input_buffer} - -选中文本: -{selected_text} - -最近终端输出(最多 {line_limit} 行): -{recent_output} - -要求: -- 语言:{language} -- 面向用户的说明和推理过程使用该语言;命令、路径、文件名、配置键名保持原样 -- 安全模式:{safety_mode} -- 最多生成 {max_commands} 条命令 -- 优先生成只读诊断命令 -- 如果信息不足,请给出验证命令 -- 必须返回 JSON 对象,不要返回 Markdown"#, - user_input = user_input, - connection_name = ctx.connection_name.as_deref().unwrap_or("-"), - host = ctx.host.as_deref().unwrap_or("-"), - port = ctx - .port - .map(|value| value.to_string()) - .unwrap_or_else(|| "-".to_string()), - username = ctx.username.as_deref().unwrap_or("-"), - cwd = ctx.cwd.as_deref().unwrap_or("-"), - os = ctx.os.as_deref().unwrap_or("-"), - arch = ctx.arch.as_deref().unwrap_or(std::env::consts::ARCH), - input_buffer = ctx.input_buffer, - selected_text = ctx.selected_text, - line_limit = settings.context_line_limit, - recent_output = ctx.recent_output, - language = request.options.language, - safety_mode = request.options.safety_mode, - max_commands = request.options.max_output_commands, - ) - } - PromptLanguage::ZhHant => { - let action = match request.action { - AiAction::GenerateCommand => "根據自然語言需求產生 1 到 2 個 Shell 命令", - AiAction::ExplainOutput => "解釋最近終端輸出並提供下一步建議", - AiAction::ExplainSelected => "解釋使用者選取的終端文字並提供下一步建議", - AiAction::AnalyzeError => "分析終端錯誤輸出並提供排查步驟", - AiAction::RepairFromSelection => "根據選取內容產生修復或排查命令", - AiAction::CustomTerminalAction => "根據使用者設定的終端 AI 功能處理選取內容", - AiAction::CustomFileAction => "根據使用者設定的檔案 AI 功能處理檔案內容", - }; - format!( - r#"任務:{action} -使用者需求: -{user_input} - -目前連線情境: -- 連線名稱:{connection_name} -- 主機:{host} -- 連接埠:{port} -- 使用者:{username} -- 目前目錄:{cwd} -- 作業系統:{os} -- 架構:{arch} -- 目前輸入:{input_buffer} - -選取文字: -{selected_text} - -最近終端輸出(最多 {line_limit} 行): -{recent_output} - -要求: -- 語言:{language} -- 面向使用者的說明和推理過程使用該語言;命令、路徑、檔名、設定鍵名保持原樣 -- 安全模式:{safety_mode} -- 最多產生 {max_commands} 條命令 -- 優先產生唯讀診斷命令 -- 如果資訊不足,請提供驗證命令 -- 必須回傳 JSON 物件,不要回傳 Markdown"#, - user_input = user_input, - connection_name = ctx.connection_name.as_deref().unwrap_or("-"), - host = ctx.host.as_deref().unwrap_or("-"), - port = ctx - .port - .map(|value| value.to_string()) - .unwrap_or_else(|| "-".to_string()), - username = ctx.username.as_deref().unwrap_or("-"), - cwd = ctx.cwd.as_deref().unwrap_or("-"), - os = ctx.os.as_deref().unwrap_or("-"), - arch = ctx.arch.as_deref().unwrap_or(std::env::consts::ARCH), - input_buffer = ctx.input_buffer, - selected_text = ctx.selected_text, - line_limit = settings.context_line_limit, - recent_output = ctx.recent_output, - language = request.options.language, - safety_mode = request.options.safety_mode, - max_commands = request.options.max_output_commands, - ) - } - PromptLanguage::Ko => { - let action = match request.action { - AiAction::GenerateCommand => "자연어 요청에서 Shell 명령 1~2개 생성", - AiAction::ExplainOutput => "최근 터미널 출력을 설명하고 다음 단계 제안", - AiAction::ExplainSelected => "선택한 터미널 텍스트를 설명하고 다음 단계 제안", - AiAction::AnalyzeError => "터미널 오류 출력을 분석하고 문제 해결 단계 제공", - AiAction::RepairFromSelection => "선택한 내용에서 복구 또는 문제 해결 명령 생성", - AiAction::CustomTerminalAction => { - "사용자가 구성한 터미널 AI 작업으로 선택한 내용 처리" - } - AiAction::CustomFileAction => "사용자가 구성한 파일 AI 작업으로 파일 내용 처리", - }; - format!( - r#"작업: {action} -사용자 요청: -{user_input} - -현재 연결 컨텍스트: -- 연결 이름: {connection_name} -- 호스트: {host} -- 포트: {port} -- 사용자: {username} -- 현재 디렉터리: {cwd} -- 운영 체제: {os} -- 아키텍처: {arch} -- 현재 입력: {input_buffer} - -선택한 텍스트: -{selected_text} - -최근 터미널 출력(최대 {line_limit}줄): -{recent_output} - -요구 사항: -- 대상 언어: {language} -- 사용자에게 보이는 설명과 추론에는 가능하면 해당 언어를 사용하세요. -- 명령, 경로, 파일 이름, 구성 키는 변경하지 말고 번역하지 마세요. -- 안전 모드: {safety_mode} -- 최대 {max_commands}개의 명령을 생성하세요. -- 읽기 전용 진단 명령을 먼저 우선하세요. -- 정보가 부족하면 확인 명령을 제공하세요. -- JSON 객체만 반환하세요. Markdown을 반환하지 마세요."#, - user_input = user_input, - connection_name = ctx.connection_name.as_deref().unwrap_or("-"), - host = ctx.host.as_deref().unwrap_or("-"), - port = ctx - .port - .map(|value| value.to_string()) - .unwrap_or_else(|| "-".to_string()), - username = ctx.username.as_deref().unwrap_or("-"), - cwd = ctx.cwd.as_deref().unwrap_or("-"), - os = ctx.os.as_deref().unwrap_or("-"), - arch = ctx.arch.as_deref().unwrap_or(std::env::consts::ARCH), - input_buffer = ctx.input_buffer, - selected_text = ctx.selected_text, - line_limit = settings.context_line_limit, - recent_output = ctx.recent_output, - language = request.options.language, - safety_mode = request.options.safety_mode, - max_commands = request.options.max_output_commands, - ) - } - PromptLanguage::En => { - let action = match request.action { - AiAction::GenerateCommand => { - "Generate 1 to 2 Shell commands from the natural language request" - } - AiAction::ExplainOutput => { - "Explain the recent terminal output and suggest the next step" - } - AiAction::ExplainSelected => { - "Explain the selected terminal text and suggest the next step" - } - AiAction::AnalyzeError => { - "Analyze the terminal error output and provide troubleshooting steps" - } - AiAction::RepairFromSelection => { - "Generate repair or troubleshooting commands from the selected content" - } - AiAction::CustomTerminalAction => { - "Handle the selected content using the configured terminal AI action" - } - AiAction::CustomFileAction => { - "Handle the file content using the configured file AI action" - } - }; - format!( - r#"Task: {action} -User request: -{user_input} - -Current connection context: -- Connection name: {connection_name} -- Host: {host} -- Port: {port} -- User: {username} -- Current directory: {cwd} -- Operating system: {os} -- Architecture: {arch} -- Current input: {input_buffer} - -Selected text: -{selected_text} - -Recent terminal output (up to {line_limit} lines): -{recent_output} - -Requirements: -- Target language: {language} -- Use that language for user-facing explanation and reasoning when possible. -- Keep commands, paths, file names, and configuration keys unchanged. -- Safety mode: {safety_mode} -- Generate at most {max_commands} commands. -- Prefer read-only diagnostic commands first. -- If information is insufficient, provide verification commands. -- Return a JSON object only. Do not return Markdown."#, - user_input = user_input, - connection_name = ctx.connection_name.as_deref().unwrap_or("-"), - host = ctx.host.as_deref().unwrap_or("-"), - port = ctx - .port - .map(|value| value.to_string()) - .unwrap_or_else(|| "-".to_string()), - username = ctx.username.as_deref().unwrap_or("-"), - cwd = ctx.cwd.as_deref().unwrap_or("-"), - os = ctx.os.as_deref().unwrap_or("-"), - arch = ctx.arch.as_deref().unwrap_or(std::env::consts::ARCH), - input_buffer = ctx.input_buffer, - selected_text = ctx.selected_text, - line_limit = settings.context_line_limit, - recent_output = ctx.recent_output, - language = request.options.language, - safety_mode = request.options.safety_mode, - max_commands = request.options.max_output_commands, - ) - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::config::AiMode; - - use super::super::types::{AiContext, AiRequestOptions}; - - fn test_request(language: &str) -> AiChatRequest { - AiChatRequest { - stream_id: None, - session_id: None, - connection_id: None, - terminal_session_id: None, - owner_scope: Default::default(), - targets: vec![], - target_contexts: vec![], - mode: AiMode::Ask, - agent_kind: crate::config::AiAgentKind::Nyaterm, - permission_mode: crate::config::AiPermissionMode::Confirm, - model_id: None, - model_name: None, - default_target_session_id: None, - existing_external_session_id: None, - attachments: vec![], - action: AiAction::GenerateCommand, - user_input: "check disk usage".to_string(), - context: AiContext { - connection_name: Some("prod".to_string()), - host: Some("example.com".to_string()), - username: Some("ops".to_string()), - cwd: Some("/srv/app".to_string()), - os: Some("Linux".to_string()), - recent_output: "Filesystem Size Used Avail Use% Mounted on".to_string(), - ..AiContext::default() - }, - options: AiRequestOptions { - language: language.to_string(), - ..AiRequestOptions::default() - }, - } - } - - #[test] - fn resolves_chinese_locale_variants_to_writing_systems() { - let simplified_cases = ["zh", "zh-CN", "zh_CN", "zh-SG", "zh-Hans", "zh-Hans-CN"]; - for language in simplified_cases { - assert_eq!(resolve_prompt_language(language), PromptLanguage::ZhHans); - } - - let traditional_cases = ["zh-TW", "zh_TW", "zh-Hant", "zh-Hant-TW", "zh-HK", "zh-MO"]; - for language in traditional_cases { - assert_eq!(resolve_prompt_language(language), PromptLanguage::ZhHant); - } - - assert_eq!(resolve_prompt_language("zh-unknown"), PromptLanguage::En); - } - - #[test] - fn returns_traditional_chinese_system_prompts() { - assert!(system_prompt("zh-TW").contains("雲端原生終端助手")); - assert!(agent_system_prompt("zh-Hant").contains("終端自動化 Agent")); - } - - #[test] - fn builds_traditional_chinese_chat_and_agent_prompts() { - let settings = AiSettings::default(); - let request = test_request("zh-TW"); - - let prompt = build_prompt(&request, &settings); - assert!(prompt.contains("任務:根據自然語言需求產生")); - assert!(prompt.contains("目前連線情境:")); - assert!(prompt.contains("語言:zh-TW")); - - let agent_prompt = build_agent_prompt(&request, &settings); - assert!(agent_prompt.contains("使用者任務:")); - assert!(agent_prompt.contains("最近終端輸出")); - assert!(agent_prompt.contains("zh-TW")); - } - - #[test] - fn agent_prompts_require_non_interactive_commands() { - let settings = AiSettings::default(); - for (language, localized_rule) in [ - ("zh-CN", "不能等待用户输入"), - ("zh-TW", "不能等待使用者輸入"), - ("en", "must be non-interactive"), - ("ko", "비대화형이어야"), - ] { - let request = test_request(language); - let prompt = build_agent_prompt(&request, &settings); - - assert!(prompt.contains(localized_rule), "language={language}"); - assert!(prompt.contains("git --no-pager"), "language={language}"); - assert!( - prompt.contains("journalctl --no-pager"), - "language={language}" - ); - assert!(prompt.contains("final_answer"), "language={language}"); - } - } - - #[test] - fn builds_traditional_chinese_agent_runtime_messages() { - assert!(agent_send_only_observation("zh-TW").contains("僅傳送模式")); - assert!(agent_execution_disabled_message("zh-Hant").contains("已停用")); - assert!(build_agent_rejected_message("rm -rf tmp", "zh-TW").contains("拒絕執行")); - assert!(build_agent_failed_message("boom", "zh-TW").contains("下一步")); - assert!( - build_agent_unknown_action_message("noop", "fallback", "zh-TW").contains("fallback") - ); - assert!(agent_max_steps_message("zh-TW").contains("最大步數限制")); - } - - #[test] - fn resolves_korean_locale_variants() { - assert!(resolve_prompt_language("ko") == PromptLanguage::Ko); - assert!(resolve_prompt_language("ko-KR") == PromptLanguage::Ko); - assert!(resolve_prompt_language("ko_KR") == PromptLanguage::Ko); - } - - #[test] - fn returns_korean_system_prompts() { - assert!(system_prompt("ko").contains("터미널 어시스턴트")); - assert!(agent_system_prompt("ko").contains("터미널 자동화 Agent")); - } - - #[test] - fn builds_korean_chat_and_agent_prompts() { - let settings = AiSettings::default(); - let request = test_request("ko"); - - let prompt = build_prompt(&request, &settings); - assert!(prompt.contains("작업: 자연어 요청에서 Shell 명령")); - assert!(prompt.contains("현재 연결 컨텍스트:")); - assert!(prompt.contains("대상 언어: ko")); - - let agent_prompt = build_agent_prompt(&request, &settings); - assert!(agent_prompt.contains("사용자 작업:")); - assert!(agent_prompt.contains("최근 터미널 출력")); - assert!(agent_prompt.contains("ko")); - } - - #[test] - fn builds_korean_observation_message() { - let obs = CommandObservation { - output: "ok".to_string(), - exit_code: Some(0), - duration_ms: 42, - }; - - let message = build_observation_message(&obs, "ls", "ko-KR"); - assert!(message.contains("명령 `ls` 실행이 완료되었습니다")); - assert!(message.contains("출력:")); - assert!(message.contains("execute_command 또는 final_answer")); - } - - #[test] - fn safely_truncates_multibyte_observation_output() { - let obs = CommandObservation { - output: "你".repeat(3000), - exit_code: Some(0), - duration_ms: 42, - }; - - let message = build_observation_message(&obs, "cat file", "zh-CN"); - assert!(message.contains("...(truncated)\n你")); - assert!(message.contains("命令 `cat file` 执行完成")); - } - - #[test] - fn builds_korean_agent_runtime_messages() { - assert!(agent_send_only_observation("ko").contains("전송 전용 모드")); - assert!(agent_execution_disabled_message("ko-KR").contains("비활성화")); - assert!(build_agent_rejected_message("rm -rf tmp", "ko").contains("거부했습니다")); - assert!(build_agent_failed_message("boom", "ko").contains("다음 단계")); - assert!(build_agent_unknown_action_message("noop", "fallback", "ko").contains("fallback")); - assert!(agent_max_steps_message("ko").contains("최대 단계 수")); - } -} +pub use nyaterm_core::core::ai::prompt::*; diff --git a/src-tauri/src/core/ai/redaction.rs b/src-tauri/src/core/ai/redaction.rs index 05106668..e9fc1526 100644 --- a/src-tauri/src/core/ai/redaction.rs +++ b/src-tauri/src/core/ai/redaction.rs @@ -1,113 +1 @@ -use regex::Regex; -use std::sync::OnceLock; - -use super::types::AiContext; - -pub(super) fn redact_context(context: &mut AiContext) { - context.recent_output = redact_sensitive_text(&context.recent_output); - context.selected_text = redact_sensitive_text(&context.selected_text); - context.input_buffer = redact_sensitive_text(&context.input_buffer); -} - -pub fn redact_sensitive_text(input: &str) -> String { - let mut output = input.to_string(); - for (pattern, replacement) in redaction_patterns() { - output = pattern.replace_all(&output, *replacement).to_string(); - } - output -} - -pub(super) fn redact_marker_values(input: &str, markers: &[&str]) -> String { - let mut output = input.to_string(); - for marker in markers.iter().copied().filter(|marker| !marker.is_empty()) { - let mut search_start = 0; - while let Some(relative_index) = output[search_start..].find(marker) { - let marker_index = search_start + relative_index; - let value_start = marker_index + marker.len(); - let value_end = output[value_start..] - .find(['&', ' ', '"']) - .map(|offset| value_start + offset) - .unwrap_or(output.len()); - output.replace_range(value_start..value_end, "[redacted]"); - search_start = value_start + "[redacted]".len(); - } - } - output -} - -fn redaction_patterns() -> &'static [(Regex, &'static str)] { - static PATTERNS: OnceLock> = OnceLock::new(); - PATTERNS.get_or_init(|| { - vec![ - ( - Regex::new( - r"-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----", - ) - .unwrap(), - "[REDACTED_PRIVATE_KEY]", - ), - ( - Regex::new(r"(?i)Authorization:\s*Bearer\s+[A-Za-z0-9._\-]+").unwrap(), - "Authorization: Bearer [REDACTED]", - ), - ( - Regex::new(r"(?i)(password|passwd|pwd)\s*[:=]\s*[^\s;&|]+").unwrap(), - "$1=[REDACTED]", - ), - ( - Regex::new( - r"(?i)(token|api[_-]?key|secret[_-]?key|access[_-]?key)\s*[:=]\s*[^\s;&|]+", - ) - .unwrap(), - "$1=[REDACTED]", - ), - ( - Regex::new(r"AKIA[0-9A-Z]{16}").unwrap(), - "[REDACTED_AWS_ACCESS_KEY]", - ), - ( - Regex::new(r"(?i)(postgres|mysql|mongodb)://[^@\s]+@").unwrap(), - "$1://[REDACTED]@", - ), - ] - }) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn redacts_sensitive_values() { - let raw = "password=secret token:abc Authorization: Bearer abc.def AKIA1234567890ABCDEF"; - let redacted = redact_sensitive_text(raw); - assert!(!redacted.contains("secret")); - assert!(!redacted.contains("abc.def")); - assert!(!redacted.contains("AKIA1234567890ABCDEF")); - } - - #[test] - fn redacts_marker_values_without_revisiting_redacted_markers() { - let raw = "access_token=abc access_token=[redacted] code=xyz&state=ok refresh_token=end"; - let redacted = redact_marker_values(raw, &["access_token=", "code=", "refresh_token="]); - - assert_eq!( - redacted, - "access_token=[redacted] access_token=[redacted] code=[redacted]&state=ok refresh_token=[redacted]" - ); - assert!(!redacted.contains("abc")); - assert!(!redacted.contains("code=xyz")); - assert!(!redacted.contains("refresh_token=end")); - } - - #[test] - fn redacts_marker_values_until_space_quote_ampersand_or_line_end() { - let raw = r#"api_key=one next="api_key=two" code=three&state=ok id_token=four"#; - let redacted = redact_marker_values(raw, &["api_key=", "code=", "id_token="]); - - assert_eq!( - redacted, - r#"api_key=[redacted] next="api_key=[redacted]" code=[redacted]&state=ok id_token=[redacted]"# - ); - } -} +pub use nyaterm_core::core::ai::redaction::*; diff --git a/src-tauri/src/core/ai/responses.rs b/src-tauri/src/core/ai/responses.rs index 1a06ca7b..eb07494a 100644 --- a/src-tauri/src/core/ai/responses.rs +++ b/src-tauri/src/core/ai/responses.rs @@ -1,721 +1 @@ -use std::time::Duration; - -use genai::chat::{ChatMessage, ChatRole}; -use serde::Serialize; -use serde_json::{Value, json}; -use tauri::AppHandle; -use tokio::sync::oneshot; - -use crate::config::{ - AiApiFormat, AiProviderApiProtocol, AiProviderKind, AiReasoningEffort, AiSettings, -}; -use crate::error::{AppError, AppResult}; -use crate::utils::url::{join_api_base_url, normalize_api_base_url}; - -use super::http::build_http_client; -use super::model::ResolvedAiModel; -use super::parser::{trim_string_to_option, truncate_preview}; -use super::stream::{AiStreamResult, emit_stream_event}; -use super::types::{AiChatRequest, AiStreamEventPayload}; - -const OPENAI_DEFAULT_BASE_URL: &str = "https://api.openai.com/v1/"; - -#[derive(Debug, Clone, Serialize)] -struct ResponsesInputMessage { - role: &'static str, - content: String, -} - -#[derive(Debug)] -enum ResponsesSseEvent { - TextDelta(String), - ReasoningDelta(String), - Completed { - text: Option, - reasoning: Option, - }, - Error(String), - Ignored, -} - -pub(super) fn uses_responses_api(model: &ResolvedAiModel) -> bool { - if model.api_format != AiApiFormat::Responses { - return false; - } - - match model - .credential - .as_ref() - .and_then(|credential| credential.api_protocol.as_ref()) - { - Some(AiProviderApiProtocol::OpenaiCompatible) => true, - Some(_) => false, - None => matches!( - model.provider_kind, - AiProviderKind::Openai | AiProviderKind::OpenaiCompatible - ), - } -} - -pub(super) async fn test_responses_model( - settings: &AiSettings, - model: &ResolvedAiModel, - system_prompt: &str, - user_prompt: &str, - max_output_tokens: u32, -) -> AppResult<()> { - let mut body = json!({ - "model": model.model_name, - "input": [ - { "role": "system", "content": system_prompt }, - { "role": "user", "content": user_prompt }, - ], - "max_output_tokens": max_output_tokens, - "stream": false, - "store": false, - }); - if let Some(effort) = responses_reasoning_effort(&settings.default_reasoning_effort) { - body["reasoning"] = json!({ "effort": effort }); - } - - let response = responses_request(settings, model, &body)? - .send() - .await - .map_err(|error| AppError::Config(format!("AI model test failed: {error}")))?; - if !response.status().is_success() { - let status = response.status(); - let body = response.text().await.unwrap_or_default(); - return Err(AppError::Config(format!( - "AI model test failed: {status} {}", - truncate_preview(&body, 500) - ))); - } - let body: Value = response - .json() - .await - .map_err(|error| AppError::Config(format!("Invalid AI model test response: {error}")))?; - if let Some(error) = body.get("error").filter(|error| !error.is_null()) { - let message = error - .get("message") - .and_then(Value::as_str) - .map(str::to_string) - .unwrap_or_else(|| error.to_string()); - return Err(AppError::Config(format!("AI model test failed: {message}"))); - } - if matches!(body["status"].as_str(), Some("failed" | "cancelled")) { - return Err(AppError::Config("AI model test failed".to_string())); - } - if body["id"].as_str().is_none() && body["output"].as_array().is_none() { - return Err(AppError::Config( - "AI model test returned an invalid response".to_string(), - )); - } - Ok(()) -} - -pub(super) async fn run_responses_chat_messages_stream( - app: &AppHandle, - stream_id: &str, - request: &AiChatRequest, - settings: &AiSettings, - resolved_model: &ResolvedAiModel, - messages: &[ChatMessage], - cancel_rx: &mut oneshot::Receiver<()>, -) -> AppResult { - let input = responses_input_messages(messages)?; - run_responses_input_stream( - app, - stream_id, - request, - settings, - resolved_model, - input, - true, - cancel_rx, - ) - .await -} - -pub(super) async fn run_responses_chat_messages_stream_without_text_deltas( - app: &AppHandle, - stream_id: &str, - request: &AiChatRequest, - settings: &AiSettings, - resolved_model: &ResolvedAiModel, - messages: &[ChatMessage], - cancel_rx: &mut oneshot::Receiver<()>, -) -> AppResult { - let input = responses_input_messages(messages)?; - run_responses_input_stream( - app, - stream_id, - request, - settings, - resolved_model, - input, - false, - cancel_rx, - ) - .await -} - -fn responses_input_messages(messages: &[ChatMessage]) -> AppResult> { - let mut input = Vec::new(); - - for message in messages { - let role = match message.role { - ChatRole::System => "system", - ChatRole::User => "user", - ChatRole::Assistant => "assistant", - ChatRole::Tool => continue, - }; - let content = message.content.joined_texts().unwrap_or_default(); - if content.trim().is_empty() { - continue; - } - input.push(ResponsesInputMessage { role, content }); - } - - if input.is_empty() { - return Err(AppError::Config( - "Responses API request has no text input".to_string(), - )); - } - - Ok(input) -} - -async fn run_responses_input_stream( - app: &AppHandle, - stream_id: &str, - request: &AiChatRequest, - settings: &AiSettings, - resolved_model: &ResolvedAiModel, - input: Vec, - emit_text_deltas: bool, - cancel_rx: &mut oneshot::Receiver<()>, -) -> AppResult { - let url = responses_url(resolved_model)?; - - let mut body = json!({ - "model": resolved_model.model_name, - "input": input, - "stream": true, - "store": false, - }); - if let Some(effort) = responses_reasoning_effort(&settings.default_reasoning_effort) { - body["reasoning"] = json!({ "effort": effort }); - } - - let req = responses_request(settings, resolved_model, &body)?; - - tracing::debug!( - stream_id = %stream_id, - model_name = %resolved_model.model_name, - provider_kind = ?resolved_model.provider_kind, - url = %url, - "Dispatching Responses API stream request" - ); - - let send_future = req - .timeout(Duration::from_millis(settings.timeout_ms)) - .send(); - let mut resp = tokio::select! { - _ = &mut *cancel_rx => { - return Err(AppError::Cancelled("AI stream cancelled".to_string())); - } - result = send_future => { - result.map_err(|error| AppError::Config(format!("AI request failed: {error}")))? - } - }; - - if !resp.status().is_success() { - let status = resp.status(); - let body = resp.text().await.unwrap_or_default(); - return Err(AppError::Config(format!( - "Responses API request failed: {status} {body}" - ))); - } - - let mut output = String::new(); - let mut reasoning_output = String::new(); - let mut sse_buffer = String::new(); - let idle_duration = Duration::from_millis(settings.timeout_ms); - let idle_deadline = tokio::time::sleep(idle_duration); - tokio::pin!(idle_deadline); - - loop { - tokio::select! { - _ = &mut idle_deadline => { - return Err(AppError::Config("AI stream timed out (no data received)".to_string())); - } - _ = &mut *cancel_rx => { - return Err(AppError::Cancelled("AI stream cancelled".to_string())); - } - chunk = resp.chunk() => { - idle_deadline.as_mut().reset(tokio::time::Instant::now() + idle_duration); - let Some(chunk) = chunk - .map_err(|error| AppError::Config(format!("AI stream failed: {error}")))? - else { - break; - }; - sse_buffer.push_str(&String::from_utf8_lossy(&chunk)); - process_sse_buffer( - app, - stream_id, - request, - &mut sse_buffer, - &mut output, - &mut reasoning_output, - emit_text_deltas, - )?; - } - } - } - - if !sse_buffer.trim().is_empty() { - process_sse_block_into_output( - app, - stream_id, - request, - sse_buffer.trim(), - &mut output, - &mut reasoning_output, - emit_text_deltas, - )?; - } - - tracing::info!( - stream_id = %stream_id, - text_len = output.len(), - reasoning_len = reasoning_output.len(), - text_preview = %truncate_preview(&output, 200), - reasoning_preview = %truncate_preview(&reasoning_output, 200), - "Responses API stream completed" - ); - - Ok(AiStreamResult { - text: output, - reasoning_content: trim_string_to_option(reasoning_output), - }) -} - -fn process_sse_buffer( - app: &AppHandle, - stream_id: &str, - request: &AiChatRequest, - buffer: &mut String, - output: &mut String, - reasoning_output: &mut String, - emit_text_deltas: bool, -) -> AppResult<()> { - while let Some((index, separator_len)) = find_sse_separator(buffer) { - let block = buffer[..index].trim().to_string(); - buffer.drain(..index + separator_len); - if !block.is_empty() { - process_sse_block_into_output( - app, - stream_id, - request, - &block, - output, - reasoning_output, - emit_text_deltas, - )?; - } - } - Ok(()) -} - -fn find_sse_separator(buffer: &str) -> Option<(usize, usize)> { - let lf = buffer.find("\n\n").map(|index| (index, 2)); - let crlf = buffer.find("\r\n\r\n").map(|index| (index, 4)); - - match (lf, crlf) { - (Some(left), Some(right)) => Some(if left.0 <= right.0 { left } else { right }), - (Some(found), None) | (None, Some(found)) => Some(found), - (None, None) => None, - } -} - -fn process_sse_block_into_output( - app: &AppHandle, - stream_id: &str, - request: &AiChatRequest, - block: &str, - output: &mut String, - reasoning_output: &mut String, - emit_text_deltas: bool, -) -> AppResult<()> { - match parse_responses_sse_block(block)? { - ResponsesSseEvent::TextDelta(text_delta) => { - if !text_delta.is_empty() { - output.push_str(&text_delta); - if emit_text_deltas { - emit_stream_event( - app, - stream_id, - AiStreamEventPayload { - event_type: "delta".to_string(), - stream_id: stream_id.to_string(), - session_id: request.session_id.clone(), - text_delta: Some(text_delta), - reasoning_delta: None, - message: None, - command_cards: vec![], - usage: None, - error: None, - }, - ); - } - } - } - ResponsesSseEvent::ReasoningDelta(reasoning_delta) => { - if !reasoning_delta.is_empty() { - reasoning_output.push_str(&reasoning_delta); - emit_stream_event( - app, - stream_id, - AiStreamEventPayload { - event_type: "reasoning_delta".to_string(), - stream_id: stream_id.to_string(), - session_id: request.session_id.clone(), - text_delta: None, - reasoning_delta: Some(reasoning_delta), - message: None, - command_cards: vec![], - usage: None, - error: None, - }, - ); - } - } - ResponsesSseEvent::Completed { text, reasoning } => { - if output.is_empty() - && let Some(text) = text - { - output.push_str(&text); - } - if reasoning_output.is_empty() - && let Some(reasoning) = reasoning - { - reasoning_output.push_str(&reasoning); - } - } - ResponsesSseEvent::Error(message) => { - return Err(AppError::Config(format!( - "Responses API stream failed: {message}" - ))); - } - ResponsesSseEvent::Ignored => {} - } - - Ok(()) -} - -fn parse_responses_sse_block(block: &str) -> AppResult { - let mut event_type: Option<&str> = None; - let mut data_lines = Vec::new(); - - for line in block.lines() { - let line = line.trim_end_matches('\r'); - if let Some(value) = line.strip_prefix("event:") { - event_type = Some(value.trim()); - } else if let Some(value) = line.strip_prefix("data:") { - data_lines.push(value.trim_start()); - } - } - - let data = data_lines.join("\n"); - if data.is_empty() || data == "[DONE]" { - return Ok(ResponsesSseEvent::Ignored); - } - - let value: Value = serde_json::from_str(&data) - .map_err(|error| AppError::Config(format!("Invalid Responses API stream JSON: {error}")))?; - let kind = value - .get("type") - .and_then(Value::as_str) - .or(event_type) - .unwrap_or_default(); - - if kind == "error" { - return Ok(ResponsesSseEvent::Error(error_message(&value))); - } - - if kind == "response.output_text.delta" { - return Ok(ResponsesSseEvent::TextDelta( - value - .get("delta") - .and_then(Value::as_str) - .unwrap_or_default() - .to_string(), - )); - } - - if kind.ends_with(".delta") && kind.contains("reasoning") { - return Ok(ResponsesSseEvent::ReasoningDelta( - value - .get("delta") - .or_else(|| value.get("text")) - .and_then(Value::as_str) - .unwrap_or_default() - .to_string(), - )); - } - - if kind == "response.completed" { - let response = value.get("response").unwrap_or(&value); - return Ok(ResponsesSseEvent::Completed { - text: completed_output_text(response), - reasoning: completed_reasoning_text(response), - }); - } - - Ok(ResponsesSseEvent::Ignored) -} - -fn error_message(value: &Value) -> String { - value - .get("error") - .and_then(|error| { - error - .get("message") - .or_else(|| error.get("type")) - .and_then(Value::as_str) - }) - .or_else(|| value.get("message").and_then(Value::as_str)) - .unwrap_or("Unknown Responses API error") - .to_string() -} - -fn completed_output_text(response: &Value) -> Option { - let mut text = String::new(); - for item in response.get("output")?.as_array()? { - if item.get("type").and_then(Value::as_str) != Some("message") { - continue; - } - let Some(content) = item.get("content").and_then(Value::as_array) else { - continue; - }; - for part in content { - if part.get("type").and_then(Value::as_str) == Some("output_text") - && let Some(part_text) = part.get("text").and_then(Value::as_str) - { - text.push_str(part_text); - } - } - } - trim_string_to_option(text) -} - -fn completed_reasoning_text(response: &Value) -> Option { - let mut text = String::new(); - for item in response.get("output")?.as_array()? { - if item.get("type").and_then(Value::as_str) != Some("reasoning") { - continue; - } - for key in ["summary", "content"] { - if let Some(parts) = item.get(key).and_then(Value::as_array) { - for part in parts { - if let Some(part_text) = part - .get("text") - .or_else(|| part.get("summary")) - .and_then(Value::as_str) - { - if !text.is_empty() { - text.push('\n'); - } - text.push_str(part_text); - } - } - } - } - } - trim_string_to_option(text) -} - -fn responses_reasoning_effort(value: &AiReasoningEffort) -> Option<&'static str> { - match value { - AiReasoningEffort::Auto => None, - AiReasoningEffort::None => Some("none"), - AiReasoningEffort::Minimal => Some("minimal"), - AiReasoningEffort::Low => Some("low"), - AiReasoningEffort::Medium => Some("medium"), - AiReasoningEffort::High => Some("high"), - AiReasoningEffort::XHigh => Some("xhigh"), - AiReasoningEffort::Max => Some("max"), - AiReasoningEffort::Ultra => Some("ultra"), - } -} - -pub(super) fn responses_request( - settings: &AiSettings, - model: &ResolvedAiModel, - body: &Value, -) -> AppResult { - let mut request = build_http_client(settings)? - .post(responses_url(model)?) - .json(body); - if let Some(key) = model - .credential - .as_ref() - .and_then(|credential| credential.api_key.as_deref()) - .map(str::trim) - .filter(|value| !value.is_empty()) - { - request = request.bearer_auth(key); - } - Ok(request) -} - -fn responses_url(model: &ResolvedAiModel) -> AppResult { - let base_url = responses_base_url(model)?; - join_api_base_url(&base_url, "responses") -} - -fn responses_base_url(model: &ResolvedAiModel) -> AppResult { - let configured = model - .credential - .as_ref() - .and_then(|credential| credential.base_url.as_deref()) - .map(str::trim) - .filter(|value| !value.is_empty()); - - match (configured, &model.provider_kind) { - (Some(base_url), _) => normalize_api_base_url(base_url), - (None, AiProviderKind::Openai) => Ok(OPENAI_DEFAULT_BASE_URL.to_string()), - (None, AiProviderKind::OpenaiCompatible) => Err(AppError::Config( - "Responses API requires an API base URL for OpenAI-compatible credentials".to_string(), - )), - (None, provider_kind) => Err(AppError::Config(format!( - "Responses API requires an API base URL for {provider_kind:?}" - ))), - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::config::{AiProviderCredential, AiProviderKind}; - - fn resolved_model(base_url: Option<&str>) -> ResolvedAiModel { - ResolvedAiModel { - model_name: "gpt-test".to_string(), - provider_kind: AiProviderKind::OpenaiCompatible, - api_format: AiApiFormat::Responses, - credential: Some(AiProviderCredential { - id: "credential-test".to_string(), - name: "Test".to_string(), - provider_kind: AiProviderKind::OpenaiCompatible, - icon_data_url: None, - api_protocol: None, - api_format: AiApiFormat::Responses, - base_url: base_url.map(str::to_string), - api_key: Some("key".to_string()), - enabled: true, - }), - } - } - - #[test] - fn responses_url_accepts_missing_trailing_slash() { - assert_eq!( - responses_url(&resolved_model(Some("https://api.example.com/v1"))).unwrap(), - "https://api.example.com/v1/responses" - ); - } - - #[test] - fn responses_url_accepts_trailing_slash() { - assert_eq!( - responses_url(&resolved_model(Some("https://api.example.com/v1/"))).unwrap(), - "https://api.example.com/v1/responses" - ); - } - - #[test] - fn responses_url_preserves_query() { - assert_eq!( - responses_url(&resolved_model(Some( - "https://api.example.com/v1?api-version=1" - ))) - .unwrap(), - "https://api.example.com/v1/responses?api-version=1" - ); - } - - #[test] - fn parses_text_delta_event() { - let event = parse_responses_sse_block( - r#"event: response.output_text.delta -data: {"type":"response.output_text.delta","delta":"hello"}"#, - ) - .unwrap(); - - assert!(matches!(event, ResponsesSseEvent::TextDelta(ref text) if text == "hello")); - } - - #[test] - fn parses_reasoning_delta_event() { - let event = parse_responses_sse_block( - r#"data: {"type":"response.reasoning_summary_text.delta","delta":"thinking"}"#, - ) - .unwrap(); - - assert!(matches!(event, ResponsesSseEvent::ReasoningDelta(ref text) if text == "thinking")); - } - - #[test] - fn parses_completed_event_text() { - let event = parse_responses_sse_block( - r#"data: {"type":"response.completed","response":{"output":[{"type":"message","content":[{"type":"output_text","text":"done"}]}]}}"#, - ) - .unwrap(); - - assert!(matches!( - event, - ResponsesSseEvent::Completed { text: Some(ref text), .. } if text == "done" - )); - } - - #[test] - fn parses_error_event() { - let event = parse_responses_sse_block( - r#"event: error -data: {"type":"error","error":{"message":"bad request"}}"#, - ) - .unwrap(); - - assert!(matches!(event, ResponsesSseEvent::Error(ref message) if message == "bad request")); - } - - #[test] - fn rejects_malformed_json() { - let error = parse_responses_sse_block("data: {not-json").unwrap_err(); - - assert!( - error - .to_string() - .contains("Invalid Responses API stream JSON") - ); - } - - #[test] - fn finds_crlf_sse_separator() { - assert_eq!(find_sse_separator("data: {}\r\n\r\nnext"), Some((8, 4))); - } - - #[test] - fn routes_only_openai_protocol_responses_credentials() { - let mut model = resolved_model(Some("https://api.example.com/v1/")); - assert!(uses_responses_api(&model)); - - model.provider_kind = AiProviderKind::Anthropic; - assert!(!uses_responses_api(&model)); - - model.provider_kind = AiProviderKind::Openai; - model.api_format = AiApiFormat::ChatCompletions; - assert!(!uses_responses_api(&model)); - } -} +pub use nyaterm_core::core::ai::responses::*; diff --git a/src-tauri/src/core/ai/stream.rs b/src-tauri/src/core/ai/stream.rs index 77f08b79..e0ef628b 100644 --- a/src-tauri/src/core/ai/stream.rs +++ b/src-tauri/src/core/ai/stream.rs @@ -1,9 +1,6 @@ use std::collections::HashMap; use std::sync::{Mutex, OnceLock}; -use std::time::Duration; -use futures_util::StreamExt; -use genai::chat::{ChatMessage, ChatRequest, ChatStreamEvent}; use std::sync::Arc; use tauri::{AppHandle, Emitter}; use tokio::sync::oneshot; @@ -13,13 +10,8 @@ use crate::core::session::SessionManager; use crate::error::{AppError, AppResult}; use super::agent::{AgentApprovalManager, run_agent_stream}; -use super::history::{append_message, load_history, save_user_message, validate_session_scope}; -use super::model::{build_chat_options, build_client, resolve_request_model}; -use super::parser::{ - bind_command_card_targets, extract_text_from_assistant, parse_model_output, - trim_string_to_option, truncate_preview, -}; -use super::prompt::{build_prompt, system_prompt}; +use super::history::{append_message, save_user_message, validate_session_scope}; +use super::parser::{bind_command_card_targets, parse_model_output, truncate_preview}; use super::redaction::{redact_context, redact_sensitive_text}; use super::types::{ AiChatRequest, AiMessage, AiMessageRole, AiStreamEventPayload, AiStreamStart, uuid, @@ -353,12 +345,13 @@ async fn run_chat_stream( // Ask mode model stream // --------------------------------------------------------------------------- -#[derive(Debug, Clone)] -pub(super) struct AiStreamResult { - pub text: String, - pub reasoning_content: Option, +pub use nyaterm_core::core::ai::stream::AiStreamResult; +pub struct DesktopAiSink<'a>(pub &'a AppHandle); +impl nyaterm_core::core::ai::stream::AiEventSink for DesktopAiSink<'_> { + fn emit(&self, id: &str, payload: AiStreamEventPayload) { + emit_stream_event(self.0, id, payload) + } } - pub(super) async fn run_model_stream( app: &AppHandle, stream_id: &str, @@ -366,165 +359,12 @@ pub(super) async fn run_model_stream( settings: &AiSettings, cancel_rx: &mut oneshot::Receiver<()>, ) -> AppResult { - tracing::debug!( - stream_id = %stream_id, - action = ?request.action, - session_id = ?request.session_id, - "Preparing AI model stream" - ); - - let resolved_model = resolve_request_model(settings, request)?; - let prompt = build_prompt(request, settings); - - let mut messages = vec![ChatMessage::system(system_prompt( - &request.options.language, - ))]; - - if let Some(session_id) = &request.session_id { - let max_turns = request.options.history_turns as usize; - if max_turns > 0 { - if let Ok(history) = load_history(app) { - let history_msgs: Vec<&AiMessage> = history - .messages - .iter() - .filter(|m| m.session_id == *session_id) - .collect(); - let skip = history_msgs.len().saturating_sub(max_turns); - for msg in history_msgs.into_iter().skip(skip) { - match msg.role { - AiMessageRole::User => { - messages.push(ChatMessage::user(&msg.content)); - } - AiMessageRole::Assistant => { - let content = extract_text_from_assistant(&msg.content); - if !content.is_empty() { - messages.push(ChatMessage::assistant(&content)); - } - } - AiMessageRole::System => {} - } - } - } - } - } - - messages.push(ChatMessage::user(prompt)); - - if super::responses::uses_responses_api(&resolved_model) { - return super::responses::run_responses_chat_messages_stream( - app, - stream_id, - request, - settings, - &resolved_model, - &messages, - cancel_rx, - ) - .await; - } - - let client = build_client(&resolved_model, settings)?; - - tracing::debug!( - stream_id = %stream_id, - message_count = messages.len(), - model_name = %resolved_model.model_name, - provider_kind = ?resolved_model.provider_kind, - "Dispatching AI model stream request" - ); - - let chat_req = ChatRequest::new(messages); - let chat_options = build_chat_options(settings); - - let stream_result = tokio::time::timeout( - Duration::from_millis(settings.timeout_ms), - client.exec_chat_stream(&resolved_model.model_name, chat_req, Some(&chat_options)), + nyaterm_core::core::ai::stream::run_model_stream( + &DesktopAiSink(app), + stream_id, + request, + settings, + cancel_rx, ) .await - .map_err(|_| AppError::Config("AI request timed out".to_string()))? - .map_err(|error| AppError::Config(format!("AI request failed: {error}")))?; - - let mut stream = stream_result.stream; - let mut output = String::new(); - let mut reasoning_output = String::new(); - let idle_duration = Duration::from_millis(settings.timeout_ms); - let idle_deadline = tokio::time::sleep(idle_duration); - tokio::pin!(idle_deadline); - - loop { - tokio::select! { - _ = &mut idle_deadline => { - return Err(AppError::Config("AI stream timed out (no data received)".to_string())); - } - _ = &mut *cancel_rx => { - return Err(AppError::Cancelled("AI stream cancelled".to_string())); - } - item = stream.next() => { - idle_deadline.as_mut().reset(tokio::time::Instant::now() + idle_duration); - match item { - Some(Ok(ChatStreamEvent::Chunk(chunk))) => { - let text_delta = chunk.content; - if !text_delta.is_empty() { - output.push_str(&text_delta); - emit_stream_event(app, stream_id, AiStreamEventPayload { - event_type: "delta".to_string(), - stream_id: stream_id.to_string(), - session_id: request.session_id.clone(), - text_delta: Some(text_delta), - reasoning_delta: None, - message: None, - command_cards: vec![], - usage: None, - error: None, - }); - } - } - Some(Ok(ChatStreamEvent::ReasoningChunk(chunk))) => { - let reasoning_delta = chunk.content; - if !reasoning_delta.is_empty() { - reasoning_output.push_str(&reasoning_delta); - emit_stream_event(app, stream_id, AiStreamEventPayload { - event_type: "reasoning_delta".to_string(), - stream_id: stream_id.to_string(), - session_id: request.session_id.clone(), - text_delta: None, - reasoning_delta: Some(reasoning_delta), - message: None, - command_cards: vec![], - usage: None, - error: None, - }); - } - } - Some(Ok(ChatStreamEvent::End(end))) => { - if reasoning_output.is_empty() { - if let Some(captured_reasoning_content) = end.captured_reasoning_content { - reasoning_output = captured_reasoning_content; - } - } - break; - } - None => break, - Some(Ok(_)) => {} - Some(Err(error)) => { - return Err(AppError::Config(format!("AI stream failed: {error}"))); - } - } - } - } - } - - tracing::info!( - stream_id = %stream_id, - text_len = output.len(), - reasoning_len = reasoning_output.len(), - text_preview = %truncate_preview(&output, 200), - reasoning_preview = %truncate_preview(&reasoning_output, 200), - "AI model stream completed" - ); - - Ok(AiStreamResult { - text: output, - reasoning_content: trim_string_to_option(reasoning_output), - }) } diff --git a/src-tauri/src/core/ai/types.rs b/src-tauri/src/core/ai/types.rs index fedee696..29a7593c 100644 --- a/src-tauri/src/core/ai/types.rs +++ b/src-tauri/src/core/ai/types.rs @@ -1,553 +1 @@ -use crate::config::{ - AiAgentKind, AiBackendKind, AiMode, AiModelSource, AiPermissionMode, AiProviderKind, RiskLevel, -}; -use crate::core::capabilities::RiskReasonCode; -use serde::de::Deserializer; -use serde::{Deserialize, Serialize}; -use serde_json::Value; - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -pub enum AiSessionScopeType { - Terminal, - Workspace, - Global, - Unbound, -} - -impl Default for AiSessionScopeType { - fn default() -> Self { - Self::Unbound - } -} - -#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct AiSessionScope { - #[serde(default)] - pub r#type: AiSessionScopeType, - #[serde(default)] - pub target_id: Option, - #[serde(default)] - pub connection_ids: Vec, - #[serde(default)] - pub label: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct AiTerminalTarget { - pub terminal_session_id: String, - #[serde(default)] - pub connection_id: Option, - pub label: String, - #[serde(default)] - pub host: Option, - #[serde(default)] - pub username: Option, - pub session_type: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -#[serde(rename_all = "camelCase")] -pub struct AiTargetContext { - pub target: Option, - #[serde(default)] - pub context: AiContext, -} - -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -#[serde(rename_all = "camelCase")] -pub struct AiAttachment { - pub id: String, - pub name: String, - #[serde(default)] - pub path: Option, - #[serde(default)] - pub mime_type: Option, - #[serde(default)] - pub size_bytes: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct AiCommandCard { - pub id: String, - pub title: String, - pub command: String, - pub explanation: String, - #[serde(default)] - pub risk_level: Option, - #[serde(default)] - pub risk_reason: Option, - pub expected_effect: String, - #[serde(default)] - pub rollback: Option, - #[serde(default)] - pub category: Option, - #[serde(default)] - pub references: Vec, - #[serde(default)] - pub target_terminal_session_id: Option, - #[serde(default)] - pub target: Option, -} - -// --------------------------------------------------------------------------- -// Agent (ReAct) types -// --------------------------------------------------------------------------- - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum AgentActionKind { - ExecuteCommand, - FinalAnswer, -} - -#[derive(Debug, Clone, Copy, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum AgentApprovalReasonCode { - ConfirmEachCommand, - CriticalRisk, - RiskExceedsThreshold, - ExternalAgentPermission, - SafeAutoUnknownOrHighRisk, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct AgentStepAction { - pub kind: AgentActionKind, - #[serde(default)] - pub command: Option, - #[serde(default)] - pub target: Option, - #[serde(default)] - pub risk_level: Option, - #[serde(default)] - pub model_risk_level: Option, - #[serde(default)] - pub local_risk_level: Option, - #[serde(default)] - pub model_risk_reason: Option, - #[serde(default)] - pub local_risk_reason_code: Option, - #[serde(default)] - pub approval_reason_code: Option, - #[serde(default)] - pub answer: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct CommandObservation { - pub output: String, - #[serde(default)] - pub exit_code: Option, - pub duration_ms: u64, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -pub enum AgentStepStatus { - Running, - Completed, - NeedsApproval, - Rejected, - Failed, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct AgentStepPayload { - pub stream_id: String, - #[serde(default)] - pub session_id: Option, - pub step_index: u16, - pub thought: String, - pub action: AgentStepAction, - #[serde(default)] - pub observation: Option, - pub status: AgentStepStatus, - #[serde(default)] - pub error: Option, -} - -/// Parsed single-step agent response from the LLM. -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -#[allow(dead_code)] -pub(super) struct AgentLlmResponse { - #[serde(default)] - pub thought: String, - pub action: String, - #[serde(default)] - pub command: Option, - #[serde(default)] - pub target_terminal_session_id: Option, - #[serde(default, deserialize_with = "deserialize_optional_risk_level")] - pub risk_level: Option, - #[serde(default)] - pub risk_reason: Option, - #[serde(default)] - pub answer: Option, -} - -fn deserialize_optional_risk_level<'de, D>(deserializer: D) -> Result, D::Error> -where - D: Deserializer<'de>, -{ - let value = Option::::deserialize(deserializer)?; - Ok(value.and_then(|raw| parse_risk_level_label(&raw))) -} - -pub(super) fn parse_risk_level_label(value: &str) -> Option { - match value.trim().replace('-', "_").to_ascii_lowercase().as_str() { - "low" => Some(RiskLevel::Low), - "medium" | "moderate" => Some(RiskLevel::Medium), - "high" => Some(RiskLevel::High), - "critical" | "danger" | "dangerous" => Some(RiskLevel::Critical), - _ => None, - } -} - -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -#[serde(rename_all = "camelCase")] -pub struct AiContext { - #[serde(default)] - pub connection_name: Option, - #[serde(default)] - pub host: Option, - #[serde(default)] - pub port: Option, - #[serde(default)] - pub username: Option, - #[serde(default)] - pub cwd: Option, - #[serde(default)] - pub os: Option, - #[serde(default)] - pub arch: Option, - #[serde(default)] - pub recent_output: String, - #[serde(default)] - pub selected_text: String, - #[serde(default)] - pub input_buffer: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum AiAction { - GenerateCommand, - ExplainOutput, - ExplainSelected, - AnalyzeError, - RepairFromSelection, - CustomTerminalAction, - CustomFileAction, -} - -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -#[serde(rename_all = "camelCase")] -pub struct AiRequestOptions { - #[serde(default = "default_max_output_commands")] - pub max_output_commands: u8, - #[serde(default = "default_language")] - pub language: String, - #[serde(default = "default_safety_mode")] - pub safety_mode: String, - #[serde(default = "default_history_turns")] - pub history_turns: u16, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct AiChatRequest { - #[serde(default)] - pub stream_id: Option, - #[serde(default)] - pub session_id: Option, - #[serde(default)] - pub connection_id: Option, - /// The terminal session id to execute commands on (Agent mode). - #[serde(default)] - pub terminal_session_id: Option, - #[serde(default)] - pub owner_scope: AiSessionScope, - #[serde(default)] - pub targets: Vec, - #[serde(default)] - pub target_contexts: Vec, - #[serde(default = "default_mode")] - pub mode: AiMode, - #[serde(default)] - pub agent_kind: AiAgentKind, - #[serde(default)] - pub permission_mode: AiPermissionMode, - #[serde(default)] - pub model_id: Option, - #[serde(default)] - pub model_name: Option, - #[serde(default)] - pub default_target_session_id: Option, - #[serde(default)] - pub existing_external_session_id: Option, - #[serde(default)] - pub attachments: Vec, - pub action: AiAction, - pub user_input: String, - #[serde(default)] - pub context: AiContext, - #[serde(default)] - pub options: AiRequestOptions, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct AiStreamStart { - pub stream_id: String, - pub session_id: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct AiStreamEventPayload { - #[serde(rename = "type")] - pub event_type: String, - pub stream_id: String, - #[serde(default)] - pub session_id: Option, - #[serde(default)] - pub text_delta: Option, - #[serde(default)] - pub reasoning_delta: Option, - #[serde(default)] - pub message: Option, - #[serde(default)] - pub command_cards: Vec, - #[serde(default)] - pub usage: Option, - #[serde(default)] - pub error: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct AiSession { - pub id: String, - #[serde(default)] - pub agent_kind: AiAgentKind, - #[serde(default)] - pub scope: AiSessionScope, - #[serde(default)] - pub connection_id: Option, - pub title: String, - pub created_at: String, - pub updated_at: String, - #[serde(default)] - pub external_session_id: Option, - #[serde(default)] - pub backend_metadata: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -#[serde(rename_all = "camelCase")] -pub struct AiSessionBackendMetadata { - #[serde(default)] - pub backend: AiBackendKind, - #[serde(default)] - pub external_thread_id: Option, - #[serde(default)] - pub codex_terminal_tools_version: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "lowercase")] -pub enum AiMessageRole { - User, - Assistant, - System, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct AiMessage { - pub id: String, - pub session_id: String, - pub role: AiMessageRole, - pub content: String, - pub created_at: String, - #[serde(default)] - pub reasoning_content: Option, - #[serde(default)] - pub command_cards: Vec, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct AiAuditLog { - pub id: String, - #[serde(default)] - pub connection_id: Option, - pub action: String, - #[serde(default)] - pub user_input: Option, - #[serde(default)] - pub generated_command: Option, - #[serde(default)] - pub risk_level: Option, - #[serde(default)] - pub inserted_to_terminal: bool, - #[serde(default)] - pub executed: bool, - #[serde(default)] - pub blocked: bool, - #[serde(default)] - pub source: Option, - #[serde(default)] - pub client: Option, - #[serde(default)] - pub capability: Option, - #[serde(default)] - pub session_id: Option, - #[serde(default)] - pub permission_mode: Option, - #[serde(default)] - pub approval_decision: Option, - #[serde(default)] - pub success: Option, - #[serde(default)] - pub duration_ms: Option, - #[serde(default)] - pub error: Option, - pub created_at: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct AppendAiAuditRequest { - #[serde(default)] - pub connection_id: Option, - pub action: String, - #[serde(default)] - pub user_input: Option, - #[serde(default)] - pub generated_command: Option, - #[serde(default)] - pub risk_level: Option, - #[serde(default)] - pub inserted_to_terminal: bool, - #[serde(default)] - pub executed: bool, - #[serde(default)] - pub blocked: bool, - #[serde(default)] - pub source: Option, - #[serde(default)] - pub client: Option, - #[serde(default)] - pub capability: Option, - #[serde(default)] - pub session_id: Option, - #[serde(default)] - pub permission_mode: Option, - #[serde(default)] - pub approval_decision: Option, - #[serde(default)] - pub success: Option, - #[serde(default)] - pub duration_ms: Option, - #[serde(default)] - pub error: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct AiModelDiscovery { - pub id: String, - pub name: String, - #[serde(default)] - pub backend: AiBackendKind, - #[serde(default)] - pub provider_kind: Option, - #[serde(default)] - pub credential_id: Option, - pub source: AiModelSource, -} - -// --------------------------------------------------------------------------- -// AI capture terminal display events -// --------------------------------------------------------------------------- - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(tag = "type", rename_all = "camelCase")] -pub enum AiCaptureEvent { - #[serde(rename_all = "camelCase")] - CommandStart { command: String, step_index: u16 }, - #[serde(rename_all = "camelCase")] - CommandEnd { - output: String, - exit_code: Option, - duration_ms: u64, - truncated: bool, - }, -} - -// --------------------------------------------------------------------------- -// Default value functions for serde -// --------------------------------------------------------------------------- - -pub(super) fn default_max_output_commands() -> u8 { - 5 -} - -pub(super) fn default_language() -> String { - "en".to_string() -} - -pub(super) fn default_safety_mode() -> String { - "strict".to_string() -} - -pub(super) fn default_history_turns() -> u16 { - 20 -} - -pub(super) fn default_mode() -> AiMode { - AiMode::Ask -} - -// --------------------------------------------------------------------------- -// Shared utility helpers -// --------------------------------------------------------------------------- - -pub(super) fn now_rfc3339() -> String { - time::OffsetDateTime::now_utc() - .format(&time::format_description::well_known::Rfc3339) - .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string()) -} - -pub(super) fn uuid() -> String { - uuid::Uuid::new_v4().to_string() -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn parses_agent_risk_level_case_insensitively() { - let raw = r#"{"thought":"x","action":"execute_command","command":"ls","riskLevel":"HIGH","riskReason":"x"}"#; - let parsed: AgentLlmResponse = serde_json::from_str(raw).unwrap(); - assert_eq!(parsed.risk_level, Some(RiskLevel::High)); - assert_eq!(parsed.risk_reason.as_deref(), Some("x")); - } - - #[test] - fn invalid_agent_risk_level_does_not_fail_response_parse() { - let raw = r#"{"thought":"x","action":"execute_command","command":"ls","riskLevel":"spicy","riskReason":"x"}"#; - let parsed: AgentLlmResponse = serde_json::from_str(raw).unwrap(); - assert_eq!(parsed.risk_level, None); - } -} +pub use nyaterm_core::core::ai::types::*; diff --git a/src-tauri/src/core/capabilities/policy.rs b/src-tauri/src/core/capabilities/policy.rs index 52007b42..76390105 100644 --- a/src-tauri/src/core/capabilities/policy.rs +++ b/src-tauri/src/core/capabilities/policy.rs @@ -1,5 +1,4 @@ use crate::config::{AiPermissionMode, RiskLevel}; -use serde::{Deserialize, Serialize}; use super::CapabilityAccess; @@ -10,17 +9,7 @@ pub enum PolicyDecision { Deny, } -#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] -#[serde(rename_all = "camelCase")] -pub enum RiskReasonCode { - EmptyCommand, - IrreversiblePattern, - UnclassifiedCommand, - PrivilegedMutation, - UnknownCommand, - OrdinaryWrite, - ReadOnlyDiagnostic, -} +pub use nyaterm_core::core::capabilities::RiskReasonCode; #[derive(Debug, Clone)] pub struct RiskAssessment { 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/history/mod.rs b/src-tauri/src/core/history/mod.rs index 0aa2e59e..649ad50e 100644 --- a/src-tauri/src/core/history/mod.rs +++ b/src-tauri/src/core/history/mod.rs @@ -1,16 +1 @@ -use crate::error::{AppError, AppResult}; -use crate::utils::fuzzy::{FuzzyResult, fuzzy_search_items}; -use serde::{Deserialize, Serialize}; -use std::fs; -use std::path::{Path, PathBuf}; -use std::time::{SystemTime, UNIX_EPOCH}; - -const MAX_HISTORY: usize = 5000; -const HISTORY_STORE_VERSION: u32 = 2; - -include!("types.rs"); -include!("store.rs"); -include!("migration.rs"); -include!("sanitize.rs"); -include!("util.rs"); -include!("tests.rs"); +pub use nyaterm_core::core::history::*; 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/mod.rs b/src-tauri/src/core/mod.rs index 81af2d4c..0a777c51 100644 --- a/src-tauri/src/core/mod.rs +++ b/src-tauri/src/core/mod.rs @@ -43,7 +43,7 @@ pub use quick_commands::{ pub use rdp::RdpSessionManager; pub use recording::{ ExistingFileBehavior, InputOrigin, InputSensitivity, RecordingContext, RecordingManager, - RecordingMode, RecordingProfile, RecordingStatus, RotationPolicy, TerminalHistorySearchRequest, + RecordingMode, RecordingProfile, RecordingStatus, TerminalHistorySearchRequest, TerminalHistorySearchResponse, }; pub use session::{ diff --git a/src-tauri/src/core/monitoring/mod.rs b/src-tauri/src/core/monitoring/mod.rs index 2abc41ae..ab2c1f7c 100644 --- a/src-tauri/src/core/monitoring/mod.rs +++ b/src-tauri/src/core/monitoring/mod.rs @@ -1,7 +1,2 @@ -//! Remote resource monitoring parsers and command scripts. - -pub mod ascend_npu; -pub mod docker; -pub mod gpu; -pub mod process; -pub mod stats; +//! Shared transport-neutral monitoring implementation. +pub use nyaterm_core::core::monitoring::*; diff --git a/src-tauri/src/core/network/mod.rs b/src-tauri/src/core/network/mod.rs index 55749284..caa3a4ef 100644 --- a/src-tauri/src/core/network/mod.rs +++ b/src-tauri/src/core/network/mod.rs @@ -1,65 +1,10 @@ -use crate::config::{self, ConnectionNetwork, ProxySettings}; +use crate::config::{ConnectionNetwork, ProxySettings}; use crate::error::{AppError, AppResult}; -use std::net::SocketAddr; use tauri::AppHandle; -use tokio::io::{AsyncRead, AsyncWrite}; -pub trait AsyncReadWrite: AsyncRead + AsyncWrite {} -impl AsyncReadWrite for T {} +pub use nyaterm_core::network::{BoxedTransportStream, OpenedTransport}; -pub type BoxedTransportStream = Box; - -pub struct OpenedTransport { - pub stream: BoxedTransportStream, - pub local_addr: Option, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum TransportRouteKind { - Direct, - Proxy, - SshJump, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ResolvedTransportRoute { - pub kind: TransportRouteKind, - pub proxy_id: Option, - pub proxy_jump_id: Option, -} - -pub fn resolve_transport_route(network: Option<&ConnectionNetwork>) -> ResolvedTransportRoute { - let proxy_jump_id = network - .and_then(|network| network.proxy_jump_id.as_deref()) - .filter(|id| !id.trim().is_empty()) - .map(ToOwned::to_owned); - let proxy_id = network - .and_then(|network| network.proxy_id.as_deref()) - .filter(|id| !id.trim().is_empty()) - .map(ToOwned::to_owned); - - if proxy_jump_id.is_some() { - return ResolvedTransportRoute { - kind: TransportRouteKind::SshJump, - proxy_id, - proxy_jump_id, - }; - } - - if proxy_id.is_some() { - return ResolvedTransportRoute { - kind: TransportRouteKind::Proxy, - proxy_id, - proxy_jump_id: None, - }; - } - - ResolvedTransportRoute { - kind: TransportRouteKind::Direct, - proxy_id: None, - proxy_jump_id: None, - } -} +pub use nyaterm_core::network::{TransportRouteKind, resolve_proxy, resolve_transport_route}; pub async fn open_tcp_transport( app: &AppHandle, @@ -101,18 +46,11 @@ pub async fn open_tcp_transport( } } -async fn open_direct_transport(target_host: &str, target_port: u16) -> AppResult { - let stream = tokio::net::TcpStream::connect((target_host, target_port)) - .await - .map_err(|error| { - AppError::Channel(format!( - "Direct TCP connection to {target_host}:{target_port} failed: {error}" - )) - })?; - let local_addr = stream.local_addr().ok(); +async fn open_direct_transport(host: &str, port: u16) -> AppResult { + let transport = nyaterm_core::network::open_direct_transport(host, port).await?; Ok(OpenedTransport { - stream: Box::new(stream), - local_addr, + stream: transport.stream, + local_addr: transport.local_addr, }) } @@ -121,63 +59,14 @@ async fn open_proxy_transport( target_host: &str, target_port: u16, ) -> AppResult { - let proxy_addr = format!("{}:{}", proxy.host, proxy.port); match proxy.protocol.as_str() { - "socks5" => { - let stream = match (&proxy.username, &proxy.password) { - (Some(user), Some(pass)) => { - tokio_socks::tcp::Socks5Stream::connect_with_password( - proxy_addr.as_str(), - (target_host, target_port), - user, - pass, - ) - .await - } - _ => { - tokio_socks::tcp::Socks5Stream::connect( - proxy_addr.as_str(), - (target_host, target_port), - ) - .await - } - } - .map_err(|error| AppError::Auth(format!("SOCKS5 proxy connection failed: {error}")))? - .into_inner(); - let local_addr = stream.local_addr().ok(); + "socks5" | "http" => { + let transport = + nyaterm_core::network::open_proxy_transport(proxy, target_host, target_port) + .await?; Ok(OpenedTransport { - stream: Box::new(stream), - local_addr, - }) - } - "http" => { - let mut stream = - tokio::net::TcpStream::connect(&proxy_addr) - .await - .map_err(|error| { - AppError::Channel(format!("HTTP proxy connection failed: {error}")) - })?; - match (&proxy.username, &proxy.password) { - (Some(user), Some(pass)) => { - async_http_proxy::http_connect_tokio_with_basic_auth( - &mut stream, - target_host, - target_port, - user, - pass, - ) - .await - } - _ => { - async_http_proxy::http_connect_tokio(&mut stream, target_host, target_port) - .await - } - } - .map_err(|error| AppError::Auth(format!("HTTP proxy tunnel failed: {error}")))?; - let local_addr = stream.local_addr().ok(); - Ok(OpenedTransport { - stream: Box::new(stream), - local_addr, + stream: transport.stream, + local_addr: transport.local_addr, }) } "proxycommand" => { @@ -200,25 +89,6 @@ async fn open_proxy_transport( } } -fn resolve_proxy(app: &AppHandle, proxy_id: &str) -> AppResult { - let proxy_cfg = config::load_proxy_by_id(app, proxy_id)? - .ok_or_else(|| AppError::Config(format!("Proxy '{proxy_id}' not found")))?; - let password = proxy_cfg - .password - .as_ref() - .and_then(|ciphertext| crate::utils::crypto::decrypt(ciphertext).ok()); - - Ok(ProxySettings { - enabled: true, - protocol: proxy_cfg.protocol, - host: proxy_cfg.host, - port: proxy_cfg.port, - command: proxy_cfg.command, - username: proxy_cfg.username, - password, - }) -} - #[cfg(test)] mod tests { use super::*; 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-tauri/src/core/quick_commands/mod.rs b/src-tauri/src/core/quick_commands/mod.rs index 6c542f2b..33b0d920 100644 --- a/src-tauri/src/core/quick_commands/mod.rs +++ b/src-tauri/src/core/quick_commands/mod.rs @@ -1,16 +1,2 @@ -use crate::config::{self, QuickCommand, QuickCommandCategory, QuickCommandsConfig}; -use crate::error::{AppError, AppResult}; -use serde::{Deserialize, Serialize}; -use serde_json::Value; -use std::collections::{BTreeMap, BTreeSet, HashMap}; -use std::io::Read; -use std::sync::RwLock; -use std::time::{SystemTime, UNIX_EPOCH}; -use tauri::AppHandle; -use uuid::Uuid; - -include!("types.rs"); -include!("store.rs"); -include!("parsers.rs"); -include!("merge.rs"); -include!("tests.rs"); +//! Shared quick-command persistence and import parsers. +pub use nyaterm_core::core::quick_commands::*; diff --git a/src-tauri/src/core/recording/types.rs b/src-tauri/src/core/recording/types.rs index 4397d37b..ce91dd25 100644 --- a/src-tauri/src/core/recording/types.rs +++ b/src-tauri/src/core/recording/types.rs @@ -1,33 +1,4 @@ -#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)] -#[serde(rename_all = "snake_case")] -pub enum RecordingMode { - #[default] - Transcript, - Raw, -} - -#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)] -#[serde(rename_all = "snake_case")] -pub enum ExistingFileBehavior { - #[default] - Unique, - Append, - Overwrite, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "snake_case", tag = "type")] -pub enum RotationPolicy { - Session, - Daily, - Size { max_bytes: u64 }, -} - -impl Default for RotationPolicy { - fn default() -> Self { - Self::Session - } -} +pub use nyaterm_core::core::{RecordingMode, ExistingFileBehavior, RotationPolicy}; #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] diff --git a/src-tauri/src/core/remote_desktop/frame.rs b/src-tauri/src/core/remote_desktop/frame.rs index 880e8810..ccac39a7 100644 --- a/src-tauri/src/core/remote_desktop/frame.rs +++ b/src-tauri/src/core/remote_desktop/frame.rs @@ -1,153 +1 @@ -use crate::error::{AppError, AppResult}; - -pub const FRAME_HEADER_BYTES: usize = 44; -pub const PIXEL_FORMAT_BGRA8888: u32 = 1; -pub const PIXEL_FORMAT_RGBA8888: u32 = 2; -const BYTES_PER_PIXEL: usize = 4; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -#[allow(dead_code)] -pub enum RemoteDesktopPixelFormat { - Bgra8888, - Rgba8888, -} - -impl RemoteDesktopPixelFormat { - fn wire_value(self) -> u32 { - match self { - Self::Bgra8888 => PIXEL_FORMAT_BGRA8888, - Self::Rgba8888 => PIXEL_FORMAT_RGBA8888, - } - } -} - -pub struct RemoteDesktopFramePatch<'a> { - pub sequence: u64, - pub desktop_width: u32, - pub desktop_height: u32, - pub x: u32, - pub y: u32, - pub width: u32, - pub height: u32, - pub stride: u32, - pub pixel_format: RemoteDesktopPixelFormat, - pub payload: &'a [u8], -} - -pub fn encode_frame_patch(patch: &RemoteDesktopFramePatch<'_>) -> AppResult> { - validate_frame_patch(patch)?; - let mut frame = Vec::with_capacity( - FRAME_HEADER_BYTES - .checked_add(patch.payload.len()) - .ok_or_else(|| AppError::Config("Remote desktop frame size overflows".to_string()))?, - ); - frame.extend_from_slice(&patch.sequence.to_le_bytes()); - frame.extend_from_slice(&patch.desktop_width.to_le_bytes()); - frame.extend_from_slice(&patch.desktop_height.to_le_bytes()); - frame.extend_from_slice(&patch.x.to_le_bytes()); - frame.extend_from_slice(&patch.y.to_le_bytes()); - frame.extend_from_slice(&patch.width.to_le_bytes()); - frame.extend_from_slice(&patch.height.to_le_bytes()); - frame.extend_from_slice(&patch.stride.to_le_bytes()); - frame.extend_from_slice(&patch.pixel_format.wire_value().to_le_bytes()); - let payload_len = u32::try_from(patch.payload.len()).map_err(|_| { - AppError::Config("Remote desktop frame payload exceeds the wire limit".to_string()) - })?; - frame.extend_from_slice(&payload_len.to_le_bytes()); - frame.extend_from_slice(patch.payload); - Ok(frame) -} - -fn validate_frame_patch(patch: &RemoteDesktopFramePatch<'_>) -> AppResult<()> { - if patch.desktop_width == 0 || patch.desktop_height == 0 { - return Err(AppError::Config( - "Remote desktop framebuffer dimensions must be non-zero".to_string(), - )); - } - if patch.width == 0 || patch.height == 0 { - return Err(AppError::Config( - "Remote desktop frame rectangle must be non-zero".to_string(), - )); - } - let right = patch.x.checked_add(patch.width).ok_or_else(|| { - AppError::Config("Remote desktop frame horizontal bounds overflow".to_string()) - })?; - let bottom = patch.y.checked_add(patch.height).ok_or_else(|| { - AppError::Config("Remote desktop frame vertical bounds overflow".to_string()) - })?; - if right > patch.desktop_width || bottom > patch.desktop_height { - return Err(AppError::Config( - "Remote desktop frame rectangle exceeds framebuffer bounds".to_string(), - )); - } - let row_bytes = usize::try_from(patch.width) - .ok() - .and_then(|width| width.checked_mul(BYTES_PER_PIXEL)) - .ok_or_else(|| AppError::Config("Remote desktop frame row size overflows".to_string()))?; - let stride = usize::try_from(patch.stride) - .map_err(|_| AppError::Config("Remote desktop frame stride is invalid".to_string()))?; - if stride < row_bytes { - return Err(AppError::Config( - "Remote desktop frame stride is too small".to_string(), - )); - } - let required_payload = usize::try_from(patch.height) - .ok() - .and_then(|height| stride.checked_mul(height)) - .ok_or_else(|| { - AppError::Config("Remote desktop frame payload size overflows".to_string()) - })?; - if patch.payload.len() < required_payload { - return Err(AppError::Config( - "Remote desktop frame payload is too small".to_string(), - )); - } - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn encodes_the_shared_frame_header() { - let payload = [1, 2, 3, 255]; - let frame = encode_frame_patch(&RemoteDesktopFramePatch { - sequence: 7, - desktop_width: 10, - desktop_height: 10, - x: 2, - y: 3, - width: 1, - height: 1, - stride: 4, - pixel_format: RemoteDesktopPixelFormat::Rgba8888, - payload: &payload, - }) - .expect("frame should encode"); - - assert_eq!(frame.len(), FRAME_HEADER_BYTES + payload.len()); - assert_eq!(u64::from_le_bytes(frame[0..8].try_into().unwrap()), 7); - assert_eq!(u32::from_le_bytes(frame[36..40].try_into().unwrap()), 2); - assert_eq!(&frame[FRAME_HEADER_BYTES..], &payload); - } - - #[test] - fn rejects_rectangles_outside_the_framebuffer() { - let error = encode_frame_patch(&RemoteDesktopFramePatch { - sequence: 1, - desktop_width: 10, - desktop_height: 10, - x: 10, - y: 0, - width: 1, - height: 1, - stride: 4, - pixel_format: RemoteDesktopPixelFormat::Rgba8888, - payload: &[0; 4], - }) - .expect_err("out-of-bounds rectangle must fail"); - - assert!(error.to_string().contains("exceeds framebuffer bounds")); - } -} +pub use nyaterm_core::remote_desktop_frame::*; diff --git a/src-tauri/src/core/sftp/sftp_backend/session.rs b/src-tauri/src/core/sftp/sftp_backend/session.rs index aa78d7c6..5d2c9bf3 100644 --- a/src-tauri/src/core/sftp/sftp_backend/session.rs +++ b/src-tauri/src/core/sftp/sftp_backend/session.rs @@ -351,8 +351,11 @@ impl SftpBackend { "SFTP subsystem request accepted" ); - let sftp = match SftpSession::new_with_config(channel.into_stream(), config.clone()) - .await + let sftp = match nyaterm_core::ssh::protocol::initialize_sftp( + channel.into_stream(), + config.clone(), + ) + .await { Ok(sftp) => sftp, Err(error) => { diff --git a/src-tauri/src/core/sftp/util.rs b/src-tauri/src/core/sftp/util.rs index 1544b8f3..ac9b60ec 100644 --- a/src-tauri/src/core/sftp/util.rs +++ b/src-tauri/src/core/sftp/util.rs @@ -1,744 +1 @@ -//! Shared helpers for remote file system backends: path quoting, permission -//! formatting, and common type definitions. - -use crate::error::{AppError, AppResult}; -use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; -use serde::{Deserialize, Serialize}; -use sha2::{Digest, Sha256}; - -pub(crate) const SFTP_FILE_TYPE_MASK: u32 = 0o170000; -pub(crate) const POSIX_MODE_MASK: u32 = 0o7777; - -/// Parsed entry from a remote directory listing for the file explorer. -#[derive(Debug, Clone, Serialize)] -pub struct FileEntry { - pub name: String, - pub is_dir: bool, - pub is_symlink: bool, - pub size: u64, - pub permissions: String, - pub owner: String, - pub group: String, - pub mtime: u64, - #[serde(skip_serializing_if = "Option::is_none")] - pub raw_path_token: Option, -} - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct DirectoryChild { - pub name: String, - pub path: String, - pub is_symlink: bool, - #[serde(skip_serializing_if = "Option::is_none")] - pub raw_path_token: Option, -} - -#[derive(Debug, Clone)] -pub(crate) struct RemotePathRef { - display_path: String, - raw_path: Option>, -} - -impl RemotePathRef { - pub(crate) fn new(display_path: &str, raw_path_token: Option<&str>) -> AppResult { - let raw_path = raw_path_token - .filter(|token| !token.trim().is_empty()) - .map(decode_raw_path_token) - .transpose()?; - Ok(Self { - display_path: display_path.to_string(), - raw_path, - }) - } - - pub(crate) fn display_path(&self) -> &str { - &self.display_path - } - - pub(crate) fn raw_path(&self) -> Option<&[u8]> { - self.raw_path.as_deref() - } - - pub(crate) fn sibling(&self, file_name: &str) -> Self { - let display_path = sibling_path(self.display_path().as_bytes(), file_name.as_bytes()) - .map_or_else( - || file_name.to_string(), - |bytes| String::from_utf8_lossy(&bytes).into_owned(), - ); - let raw_path = self - .raw_path() - .and_then(|path| sibling_path(path, file_name.as_bytes())); - Self { - display_path, - raw_path, - } - } -} - -fn sibling_path(path: &[u8], file_name: &[u8]) -> Option> { - if file_name.is_empty() { - return None; - } - let parent = path - .iter() - .rposition(|byte| *byte == b'/') - .map(|index| &path[..index]) - .unwrap_or_default(); - let mut sibling = Vec::with_capacity(parent.len() + file_name.len() + 1); - if parent.is_empty() { - if path.starts_with(b"/") { - sibling.push(b'/'); - } - } else { - sibling.extend_from_slice(parent); - sibling.push(b'/'); - } - sibling.extend_from_slice(file_name); - Some(sibling) -} - -pub(crate) fn raw_path_token(raw_path: &[u8]) -> String { - URL_SAFE_NO_PAD.encode(raw_path) -} - -pub(crate) fn decode_raw_path_token(token: &str) -> AppResult> { - URL_SAFE_NO_PAD - .decode(token) - .map_err(|error| AppError::Config(format!("Invalid remote path token: {error}"))) -} - -#[derive(Debug, Clone, Serialize)] -pub struct FileProperties { - pub name: String, - pub is_dir: bool, - pub is_symlink: bool, - pub symlink_target: Option, - pub size: u64, - pub permissions: String, - pub owner: String, - pub group: String, - pub uid: String, - pub gid: String, - pub mtime: u64, - pub atime: u64, -} - -#[derive(Debug, Clone, Default, Deserialize)] -pub struct RemoteFileAttributeUpdate { - pub mode: Option, - pub owner: Option, - pub group: Option, - #[serde(default)] - pub recursive: bool, -} - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct RemoteTextFile { - pub path: String, - pub content: String, - pub size: u64, - pub mtime: u64, - #[serde(skip_serializing_if = "Option::is_none")] - pub mtime_nanos: Option, - pub content_hash: String, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] -#[serde(rename_all = "snake_case")] -pub enum TextFileUnsupportedReason { - Binary, - UnsupportedEncoding, -} - -#[derive(Debug, Clone, Serialize)] -#[serde(tag = "status", rename_all = "snake_case")] -pub enum TextFileOpenResult { - Text { file: RemoteTextFile }, - Unsupported { reason: TextFileUnsupportedReason }, -} - -pub fn classify_text_file(file: RemoteBinaryFile) -> TextFileOpenResult { - if file.content_bytes.contains(&0) { - return TextFileOpenResult::Unsupported { - reason: TextFileUnsupportedReason::Binary, - }; - } - match String::from_utf8(file.content_bytes) { - Ok(content) => TextFileOpenResult::Text { - file: RemoteTextFile { - path: file.path, - size: file.size, - mtime: file.mtime, - mtime_nanos: file.mtime_nanos, - content_hash: content_hash(content.as_bytes()), - content, - }, - }, - Err(_) => TextFileOpenResult::Unsupported { - reason: TextFileUnsupportedReason::UnsupportedEncoding, - }, - } -} - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct RemoteBinaryFile { - pub path: String, - pub content_bytes: Vec, - pub size: u64, - pub mtime: u64, - #[serde(skip_serializing_if = "Option::is_none")] - pub mtime_nanos: Option, -} - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct WriteRemoteTextResult { - pub status: String, - pub mtime: Option, - pub size: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub mtime_nanos: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub content_hash: Option, -} - -impl WriteRemoteTextResult { - pub fn saved(mtime: u64, size: u64, mtime_nanos: Option, content_hash: String) -> Self { - Self { - status: "saved".to_string(), - mtime: Some(mtime), - size: Some(size), - mtime_nanos, - content_hash: Some(content_hash), - } - } - - pub fn conflict(mtime: u64, size: u64, mtime_nanos: Option) -> Self { - Self { - status: "conflict".to_string(), - mtime: Some(mtime), - size: Some(size), - mtime_nanos, - content_hash: None, - } - } -} - -pub(crate) fn content_hash(bytes: &[u8]) -> String { - let mut hasher = Sha256::new(); - hasher.update(bytes); - hex::encode(hasher.finalize()) -} - -pub(crate) fn ensure_text_bytes(bytes: &[u8], max_bytes: u64) -> AppResult<()> { - if bytes.len() as u64 > max_bytes { - return Err(AppError::Config(format!( - "File is too large to open as text ({} bytes > {} bytes)", - bytes.len(), - max_bytes - ))); - } - if bytes.contains(&0) { - return Err(AppError::Config( - "Binary files are not supported by the built-in editor".to_string(), - )); - } - Ok(()) -} - -/// POSIX shell-safe quoting: wraps `input` in single quotes and escapes any -/// embedded single-quote characters. An empty string returns `''`. -pub(crate) fn sh_quote(input: &str) -> String { - if input.is_empty() { - return "''".to_string(); - } - let escaped = input.replace('\'', "'\\''"); - format!("'{}'", escaped) -} - -pub(crate) fn remote_dir_listing_path(path: &str) -> String { - if path == "/" || path.ends_with('/') { - path.to_string() - } else { - format!("{path}/") - } -} - -/// Convert a POSIX permission bitmask to the classic `ls -l` string like `-rwxr-xr-x`. -pub(crate) fn permissions_to_string(mode: u32, type_char: char) -> String { - let mut s = String::with_capacity(10); - - s.push(type_char); - - s.push(if mode & 0o400 != 0 { 'r' } else { '-' }); - s.push(if mode & 0o200 != 0 { 'w' } else { '-' }); - s.push(match (mode & 0o100 != 0, mode & 0o4000 != 0) { - (true, true) => 's', - (false, true) => 'S', - (true, false) => 'x', - (false, false) => '-', - }); - - s.push(if mode & 0o040 != 0 { 'r' } else { '-' }); - s.push(if mode & 0o020 != 0 { 'w' } else { '-' }); - s.push(match (mode & 0o010 != 0, mode & 0o2000 != 0) { - (true, true) => 's', - (false, true) => 'S', - (true, false) => 'x', - (false, false) => '-', - }); - - s.push(if mode & 0o004 != 0 { 'r' } else { '-' }); - s.push(if mode & 0o002 != 0 { 'w' } else { '-' }); - s.push(match (mode & 0o001 != 0, mode & 0o1000 != 0) { - (true, true) => 't', - (false, true) => 'T', - (true, false) => 'x', - (false, false) => '-', - }); - - s -} - -pub(crate) fn type_char_from_mode(mode: u32) -> char { - match mode & SFTP_FILE_TYPE_MASK { - 0o040000 => 'd', - 0o120000 => 'l', - _ => '-', - } -} - -pub(crate) fn describe_permissions(mode: Option) -> String { - match mode { - Some(mode) => format!( - "{mode:#06o} ({})", - permissions_to_string(mode, type_char_from_mode(mode)) - ), - None => "none".to_string(), - } -} - -pub(crate) fn permissions_string_to_octal_mode(permissions: &str) -> Option { - let chars: Vec = permissions.chars().collect(); - if chars.len() < 10 { - return None; - } - - let mut mode = 0u32; - if chars[1] == 'r' { - mode |= 0o400; - } - if chars[2] == 'w' { - mode |= 0o200; - } - match chars[3] { - 'x' => mode |= 0o100, - 's' => mode |= 0o4100, - 'S' => mode |= 0o4000, - _ => {} - } - if chars[4] == 'r' { - mode |= 0o040; - } - if chars[5] == 'w' { - mode |= 0o020; - } - match chars[6] { - 'x' => mode |= 0o010, - 's' => mode |= 0o2010, - 'S' => mode |= 0o2000, - _ => {} - } - if chars[7] == 'r' { - mode |= 0o004; - } - if chars[8] == 'w' { - mode |= 0o002; - } - match chars[9] { - 'x' => mode |= 0o001, - 't' => mode |= 0o1001, - 'T' => mode |= 0o1000, - _ => {} - } - - Some(format!("{mode:04o}")) -} - -pub(crate) fn scp_finalize_replace_command( - tmp_path: &str, - target_path: &str, - original: Option<&FileProperties>, -) -> String { - let mut commands = Vec::new(); - if let Some(props) = original { - if let Some(owner_group) = scp_owner_group_spec(props) { - commands.push(format!( - "chown {} -- {}", - sh_quote(&owner_group), - sh_quote(tmp_path) - )); - } - if let Some(mode) = permissions_string_to_octal_mode(&props.permissions) { - commands.push(format!( - "chmod {} -- {}", - sh_quote(&mode), - sh_quote(tmp_path) - )); - } - } - commands.push(format!( - "mv -f -- {} {}", - sh_quote(tmp_path), - sh_quote(target_path) - )); - commands.join(" && ") -} - -fn scp_owner_group_spec(props: &FileProperties) -> Option { - let uid = props.uid.trim(); - let gid = props.gid.trim(); - if is_numeric_id(uid) && is_numeric_id(gid) { - return Some(format!("{uid}:{gid}")); - } - - let owner = props.owner.trim(); - let group = props.group.trim(); - if !owner.is_empty() && !group.is_empty() { - return Some(format!("{owner}:{group}")); - } - - None -} - -fn is_numeric_id(value: &str) -> bool { - !value.is_empty() && value.bytes().all(|byte| byte.is_ascii_digit()) -} - -pub(crate) fn owner_or_id(owner: &Option, uid: Option) -> String { - owner - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(ToOwned::to_owned) - .or_else(|| uid.map(|value| value.to_string())) - .unwrap_or_default() -} - -pub(crate) fn group_or_id(group: &Option, gid: Option) -> String { - group - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(ToOwned::to_owned) - .or_else(|| gid.map(|value| value.to_string())) - .unwrap_or_default() -} - -pub(crate) fn parse_octal_mode(mode: &str) -> crate::error::AppResult { - u32::from_str_radix(mode, 8) - .map_err(|_| crate::error::AppError::Channel(format!("Invalid octal mode: {}", mode))) -} - -pub(crate) fn sanitize_download_file_name(name: &str) -> String { - sanitize_download_file_name_for_platform(name, cfg!(windows)) -} - -pub(crate) fn append_safe_local_child_path(parent: &str, child_name: &str) -> String { - std::path::Path::new(parent) - .join(sanitize_download_file_name(child_name)) - .to_string_lossy() - .to_string() -} - -fn sanitize_download_file_name_for_platform(name: &str, windows: bool) -> String { - let base = name; - if base.is_empty() { - return "download".to_string(); - } - - let mut result = String::new(); - let mut chars = base.char_indices().peekable(); - while let Some((index, ch)) = chars.next() { - let is_last = chars.peek().is_none(); - if should_percent_encode_download_char(ch, is_last, windows) { - percent_encode_char(ch, &mut result); - } else { - result.push_str(&base[index..index + ch.len_utf8()]); - } - } - - if result.is_empty() { - result.push_str("download"); - } - - if windows && is_windows_reserved_device_name(&result) { - let mut chars = result.chars(); - if let Some(first) = chars.next() { - let mut escaped = String::new(); - percent_encode_char(first, &mut escaped); - escaped.push_str(chars.as_str()); - result = escaped; - } - } - - result -} - -fn should_percent_encode_download_char(ch: char, is_last: bool, windows: bool) -> bool { - ch == '%' - || ch == '/' - || ch == '\0' - || ch.is_control() - || (windows - && (matches!(ch, '<' | '>' | ':' | '"' | '\\' | '|' | '?' | '*') - || (is_last && matches!(ch, ' ' | '.')))) -} - -fn percent_encode_char(ch: char, output: &mut String) { - let mut bytes = [0u8; 4]; - for byte in ch.encode_utf8(&mut bytes).as_bytes() { - output.push('%'); - output.push_str(&format!("{byte:02X}")); - } -} - -fn is_windows_reserved_device_name(name: &str) -> bool { - let stem = name.split('.').next().unwrap_or(name); - let upper = stem.to_ascii_uppercase(); - matches!(upper.as_str(), "CON" | "PRN" | "AUX" | "NUL") - || (upper.len() == 4 - && (upper.starts_with("COM") || upper.starts_with("LPT")) - && upper.as_bytes()[3].is_ascii_digit() - && upper.as_bytes()[3] != b'0') -} - -#[cfg(test)] -mod tests { - use super::{ - FileProperties, RemoteBinaryFile, RemotePathRef, TextFileOpenResult, - TextFileUnsupportedReason, classify_text_file, content_hash, decode_raw_path_token, - permissions_string_to_octal_mode, raw_path_token, sanitize_download_file_name_for_platform, - scp_finalize_replace_command, - }; - - #[test] - fn text_open_classifies_binary_and_invalid_utf8() { - let file = |content_bytes: Vec| RemoteBinaryFile { - path: "/tmp/file".to_string(), - size: content_bytes.len() as u64, - mtime: 1, - mtime_nanos: None, - content_bytes, - }; - assert!(matches!( - classify_text_file(file(b"hello\0world".to_vec())), - TextFileOpenResult::Unsupported { - reason: TextFileUnsupportedReason::Binary - } - )); - assert!(matches!( - classify_text_file(file(vec![0xff, 0xfe])), - TextFileOpenResult::Unsupported { - reason: TextFileUnsupportedReason::UnsupportedEncoding - } - )); - let opened = classify_text_file(file(b"hello".to_vec())); - assert!(matches!( - opened, - TextFileOpenResult::Text { ref file } - if file.content == "hello" && file.content_hash == content_hash(b"hello") - )); - } - - #[test] - fn raw_path_token_round_trips_non_utf8_bytes() { - let raw_path = b"/home/user/\xce\xc4\xbc\xfe.txt"; - let token = raw_path_token(raw_path); - assert_eq!(decode_raw_path_token(&token).unwrap(), raw_path); - } - - #[test] - fn remote_path_ref_prefers_token_bytes() { - let raw_path = b"/remote/\x80name"; - let token = raw_path_token(raw_path); - let path_ref = RemotePathRef::new("/remote/display-name", Some(&token)).unwrap(); - - assert_eq!(path_ref.display_path(), "/remote/display-name"); - assert_eq!(path_ref.raw_path().unwrap(), raw_path); - } - - #[test] - fn remote_path_sibling_preserves_raw_parent_bytes() { - let raw_path = b"/remote/\x80dir/\x81link"; - let token = raw_path_token(raw_path); - let path_ref = - RemotePathRef::new("/remote/display-dir/display-link", Some(&token)).unwrap(); - let sibling = path_ref.sibling(".nyaterm-link-test"); - - assert_eq!( - sibling.raw_path().unwrap(), - b"/remote/\x80dir/.nyaterm-link-test" - ); - assert_eq!( - sibling.display_path(), - "/remote/display-dir/.nyaterm-link-test" - ); - } - - #[test] - fn percent_encodes_windows_invalid_characters() { - assert_eq!( - sanitize_download_file_name_for_platform("a:c\"d|e?f*.txt", true), - "a%3Cb%3E%3Ac%22d%7Ce%3Ff%2A.txt" - ); - assert_eq!( - sanitize_download_file_name_for_platform("foo\\bar.txt", true), - "foo%5Cbar.txt" - ); - } - - #[test] - fn encodes_windows_reserved_device_names() { - assert_eq!( - sanitize_download_file_name_for_platform("CON.txt", true), - "%43ON.txt" - ); - assert_eq!( - sanitize_download_file_name_for_platform("nul", true), - "%6Eul" - ); - assert_eq!( - sanitize_download_file_name_for_platform("LPT1.log", true), - "%4CPT1.log" - ); - } - - #[test] - fn encodes_windows_trailing_dot_and_space() { - assert_eq!( - sanitize_download_file_name_for_platform("file. ", true), - "file.%20" - ); - assert_eq!( - sanitize_download_file_name_for_platform("file.", true), - "file%2E" - ); - } - - #[test] - fn preserves_readable_safe_characters() { - assert_eq!( - sanitize_download_file_name_for_platform("中文 name-_.txt", true), - "中文 name-_.txt" - ); - } - - #[test] - fn encodes_percent_to_keep_generated_sequences_unambiguous() { - assert_eq!( - sanitize_download_file_name_for_platform("100%.txt", true), - "100%25.txt" - ); - } - - #[test] - fn unix_rules_are_minimal() { - assert_eq!( - sanitize_download_file_name_for_platform("a:b?c*.txt", false), - "a:b?c*.txt" - ); - assert_eq!( - sanitize_download_file_name_for_platform("100%.txt", false), - "100%25.txt" - ); - } - - #[test] - fn parses_ls_permissions_to_octal_mode() { - assert_eq!( - permissions_string_to_octal_mode("-rw-r--r--").as_deref(), - Some("0644") - ); - assert_eq!( - permissions_string_to_octal_mode("-rwxr-xr-x").as_deref(), - Some("0755") - ); - assert_eq!( - permissions_string_to_octal_mode("-rwsr-sr-t").as_deref(), - Some("7755") - ); - assert_eq!( - permissions_string_to_octal_mode("-rwSr-Sr-T").as_deref(), - Some("7644") - ); - assert_eq!(permissions_string_to_octal_mode("bad"), None); - } - - #[test] - fn scp_finalize_replace_prefers_numeric_uid_gid() { - let props = file_properties("-rwsr-sr-t", "kang", "kang", "1000", "1001"); - let command = scp_finalize_replace_command("/tmp/new", "/etc/app.conf", Some(&props)); - - assert!(command.contains("chown '1000:1001' -- '/tmp/new'")); - assert!(command.contains("chmod '7755' -- '/tmp/new'")); - assert!(command.ends_with("mv -f -- '/tmp/new' '/etc/app.conf'")); - } - - #[test] - fn scp_finalize_replace_falls_back_to_owner_group_names() { - let props = file_properties("-rw-r--r--", "kang", "staff", "kang", "staff"); - let command = scp_finalize_replace_command("/tmp/new", "/home/kang/file", Some(&props)); - - assert!(command.contains("chown 'kang:staff' -- '/tmp/new'")); - assert!(command.contains("chmod '0644' -- '/tmp/new'")); - } - - #[test] - fn scp_finalize_replace_orders_chown_before_chmod_before_mv() { - let props = file_properties("-rwSr-Sr-T", "kang", "staff", "1000", "1001"); - let command = scp_finalize_replace_command("/tmp/new", "/home/kang/file", Some(&props)); - - let chown = command.find("chown").unwrap(); - let chmod = command.find("chmod").unwrap(); - let mv = command.find("mv -f").unwrap(); - assert!(chown < chmod); - assert!(chmod < mv); - } - - #[test] - fn scp_finalize_replace_skips_unparseable_mode() { - let props = file_properties("bad", "kang", "staff", "1000", "1001"); - let command = scp_finalize_replace_command("/tmp/new", "/home/kang/file", Some(&props)); - - assert!(command.contains("chown '1000:1001' -- '/tmp/new'")); - assert!(!command.contains("chmod")); - assert!(command.ends_with("mv -f -- '/tmp/new' '/home/kang/file'")); - } - - fn file_properties( - permissions: &str, - owner: &str, - group: &str, - uid: &str, - gid: &str, - ) -> FileProperties { - FileProperties { - name: "file".to_string(), - is_dir: false, - is_symlink: false, - symlink_target: None, - size: 0, - permissions: permissions.to_string(), - owner: owner.to_string(), - group: group.to_string(), - uid: uid.to_string(), - gid: gid.to_string(), - mtime: 0, - atime: 0, - } - } -} +pub use nyaterm_core::ssh::files::*; diff --git a/src-tauri/src/core/ssh/auth.rs b/src-tauri/src/core/ssh/auth.rs index 161cee12..413f86c8 100644 --- a/src-tauri/src/core/ssh/auth.rs +++ b/src-tauri/src/core/ssh/auth.rs @@ -686,22 +686,7 @@ fn resolve_proxy( return Ok(None); }; - let proxy_cfg = crate::config::load_proxy_by_id(app, proxy_id)? - .ok_or_else(|| AppError::Config(format!("Proxy '{}' not found", proxy_id)))?; - let password = proxy_cfg - .password - .as_ref() - .and_then(|ciphertext| crate::utils::crypto::decrypt(ciphertext).ok()); - - Ok(Some(crate::config::ProxySettings { - enabled: true, - protocol: proxy_cfg.protocol, - host: proxy_cfg.host, - port: proxy_cfg.port, - command: proxy_cfg.command, - username: proxy_cfg.username, - password, - })) + Ok(Some(nyaterm_core::network::resolve_proxy(app, proxy_id)?)) } pub(super) async fn authenticate_handle( @@ -1213,10 +1198,10 @@ async fn authenticate_password_with_runtime_prompt( let Some(current_password) = password.as_deref() else { continue; }; - let authenticated = handle - .authenticate_password(&config.username, current_password) - .await - .map_err(|error| AppError::Auth(format!("Authentication failed: {}", error)))?; + let authenticated = + nyaterm_core::ssh::protocol::password(handle, &config.username, current_password) + .await + .map_err(|error| AppError::Auth(format!("Authentication failed: {}", error)))?; match try_keyboard_interactive_after_partial( handle, diff --git a/src-tauri/src/core/ssh/client.rs b/src-tauri/src/core/ssh/client.rs index 753e3013..8fb0d36e 100644 --- a/src-tauri/src/core/ssh/client.rs +++ b/src-tauri/src/core/ssh/client.rs @@ -1,20 +1,16 @@ use super::agent::connect_agent_stream; use super::agent_broker::{AgentBrokerFactory, try_acquire_agent_channel_permit}; use crate::config::{ - SftpSettings, SshAgentEndpoint, SshAgentForwardingConfig, SshAlgorithmMode, - SshAlgorithmPreferences, SshProfile, SshRuntimeMode, SshTerminalType, + SftpSettings, SshAgentEndpoint, SshAgentForwardingConfig, SshAlgorithmPreferences, SshProfile, + SshRuntimeMode, SshTerminalType, }; use crate::error::{AppError, AppResult}; use russh::client; -use russh::keys::{Algorithm, EcdsaCurve, HashAlg, PublicKeyBase64}; -use russh::{Preferred, cipher, kex, mac}; +use russh::keys::PublicKeyBase64; use serde::{Deserialize, Serialize}; -use std::borrow::Cow; use std::collections::HashMap; -use std::convert::TryFrom; use std::fmt; use std::process::Stdio; -use std::str::FromStr; use std::sync::{Arc, Mutex as StdMutex}; use tauri::{AppHandle, Emitter, Manager}; use tokio::io::{AsyncBufReadExt, AsyncRead, AsyncWrite, BufReader}; @@ -70,38 +66,7 @@ fn default_encoding() -> String { "UTF-8".to_string() } -#[derive(Debug, Clone, Serialize, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -pub enum AlgorithmRisk { - Modern, - Legacy, - Insecure, -} - -#[derive(Debug, Clone, Serialize, PartialEq, Eq)] -pub struct AlgorithmOption { - pub id: String, - pub label: String, - pub risk: AlgorithmRisk, -} - -#[derive(Debug, Clone, Serialize, PartialEq, Eq)] -pub struct SshAlgorithmDefaults { - pub kex: Vec, - pub ciphers: Vec, - pub macs: Vec, - pub host_keys: Vec, -} - -#[derive(Debug, Clone, Serialize, PartialEq, Eq)] -pub struct SupportedSshAlgorithms { - pub kex: Vec, - pub ciphers: Vec, - pub macs: Vec, - pub host_keys: Vec, - pub compatible: SshAlgorithmDefaults, - pub secure: SshAlgorithmDefaults, -} +pub use nyaterm_core::ssh::algorithms::*; #[derive(Debug, Clone, Deserialize)] pub struct SshPostLoginConfig { @@ -440,274 +405,6 @@ fn replace_known_host_entry(host_identifier: &str, new_entry: &str) -> AppResult crate::storage::replace_known_host_for_host(host_identifier, new_entry) } -fn compatible_algorithms() -> Preferred { - let mut preferred = Preferred::default(); - - preferred.kex = Cow::Owned(vec![ - kex::MLKEM768X25519_SHA256, - kex::CURVE25519, - kex::CURVE25519_PRE_RFC_8731, - kex::ECDH_SHA2_NISTP256, - kex::ECDH_SHA2_NISTP384, - kex::ECDH_SHA2_NISTP521, - kex::DH_G18_SHA512, - kex::DH_G17_SHA512, - kex::DH_G16_SHA512, - kex::DH_G15_SHA512, - kex::DH_G14_SHA256, - kex::DH_GEX_SHA256, - kex::DH_G14_SHA1, - kex::DH_GEX_SHA1, - kex::DH_G1_SHA1, - kex::EXTENSION_SUPPORT_AS_CLIENT, - kex::EXTENSION_SUPPORT_AS_SERVER, - kex::EXTENSION_OPENSSH_STRICT_KEX_AS_CLIENT, - kex::EXTENSION_OPENSSH_STRICT_KEX_AS_SERVER, - ]); - - preferred.key = Cow::Owned(vec![ - Algorithm::Ed25519, - Algorithm::Ecdsa { - curve: EcdsaCurve::NistP256, - }, - Algorithm::Ecdsa { - curve: EcdsaCurve::NistP384, - }, - Algorithm::Rsa { - hash: Some(HashAlg::Sha512), - }, - Algorithm::Rsa { - hash: Some(HashAlg::Sha256), - }, - // Some network devices advertise malformed P-521 ECDSA host keys; prefer - // plain ssh-rsa first so negotiation can reach authentication. - Algorithm::Rsa { hash: None }, - Algorithm::Ecdsa { - curve: EcdsaCurve::NistP521, - }, - Algorithm::Dsa, - ]); - - preferred.cipher = Cow::Owned(vec![ - cipher::CHACHA20_POLY1305, - cipher::AES_256_GCM, - cipher::AES_128_GCM, - cipher::AES_256_CTR, - cipher::AES_192_CTR, - cipher::AES_128_CTR, - cipher::AES_256_CBC, - cipher::AES_192_CBC, - cipher::AES_128_CBC, - cipher::TRIPLE_DES_CBC, - ]); - - preferred.mac = Cow::Owned(vec![ - mac::HMAC_SHA512_ETM, - mac::HMAC_SHA256_ETM, - mac::HMAC_SHA512, - mac::HMAC_SHA256, - mac::HMAC_SHA1_ETM, - mac::HMAC_SHA1, - ]); - - preferred -} - -fn secure_algorithms() -> Preferred { - Preferred::default() -} - -pub(crate) fn validate_ssh_algorithm_preferences( - preferences: &SshAlgorithmPreferences, -) -> AppResult<()> { - resolve_preferred_algorithms(Some(preferences)).map(|_| ()) -} - -pub(crate) fn resolve_preferred_algorithms( - preferences: Option<&SshAlgorithmPreferences>, -) -> AppResult { - let Some(preferences) = preferences else { - return Ok(compatible_algorithms()); - }; - - match preferences.mode { - SshAlgorithmMode::Compatible => Ok(compatible_algorithms()), - SshAlgorithmMode::Secure => Ok(secure_algorithms()), - SshAlgorithmMode::Custom => custom_algorithms(preferences), - } -} - -fn custom_algorithms(preferences: &SshAlgorithmPreferences) -> AppResult { - let mut preferred = Preferred::default(); - preferred.kex = Cow::Owned(parse_kex_names(&preferences.kex)?); - preferred.cipher = Cow::Owned(parse_cipher_names(&preferences.ciphers)?); - preferred.mac = Cow::Owned(parse_mac_names(&preferences.macs)?); - preferred.key = Cow::Owned(parse_host_key_algorithms(&preferences.host_keys)?); - Ok(preferred) -} - -fn parse_required_list(values: &[String], label: &str, mut parse: F) -> AppResult> -where - F: FnMut(&str) -> Option, -{ - if values.is_empty() { - return Err(AppError::Config(format!( - "SSH algorithm list '{}' must not be empty", - label - ))); - } - - values - .iter() - .map(|value| { - parse(value).ok_or_else(|| { - AppError::Config(format!( - "Unsupported SSH algorithm '{}' in {}", - value, label - )) - }) - }) - .collect() -} - -fn parse_kex_names(values: &[String]) -> AppResult> { - parse_required_list(values, "key exchanges", |value| { - kex::Name::try_from(value).ok() - }) -} - -fn parse_cipher_names(values: &[String]) -> AppResult> { - parse_required_list(values, "ciphers", |value| { - cipher::Name::try_from(value).ok() - }) -} - -fn parse_mac_names(values: &[String]) -> AppResult> { - parse_required_list(values, "MACs", |value| mac::Name::try_from(value).ok()) -} - -fn parse_host_key_algorithms(values: &[String]) -> AppResult> { - parse_required_list(values, "host keys", |value| Algorithm::from_str(value).ok()) -} - -fn defaults_from_preferred(preferred: Preferred) -> SshAlgorithmDefaults { - SshAlgorithmDefaults { - kex: preferred - .kex - .iter() - .map(|algorithm| algorithm.as_ref().to_string()) - .collect(), - ciphers: preferred - .cipher - .iter() - .map(|algorithm| algorithm.as_ref().to_string()) - .collect(), - macs: preferred - .mac - .iter() - .map(|algorithm| algorithm.as_ref().to_string()) - .collect(), - host_keys: preferred.key.iter().map(ToString::to_string).collect(), - } -} - -fn algorithm_option(id: impl Into, risk: AlgorithmRisk) -> AlgorithmOption { - let id = id.into(); - AlgorithmOption { - label: id.clone(), - id, - risk, - } -} - -fn kex_risk(id: &str) -> AlgorithmRisk { - match id { - "diffie-hellman-group1-sha1" - | "diffie-hellman-group14-sha1" - | "diffie-hellman-group-exchange-sha1" => AlgorithmRisk::Insecure, - value if value.starts_with("diffie-hellman-") => AlgorithmRisk::Legacy, - _ => AlgorithmRisk::Modern, - } -} - -fn cipher_risk(id: &str) -> AlgorithmRisk { - match id { - "3des-cbc" => AlgorithmRisk::Insecure, - value if value.ends_with("-cbc") => AlgorithmRisk::Legacy, - _ => AlgorithmRisk::Modern, - } -} - -fn mac_risk(id: &str) -> AlgorithmRisk { - match id { - "hmac-sha1" => AlgorithmRisk::Insecure, - "hmac-sha1-etm@openssh.com" => AlgorithmRisk::Legacy, - _ => AlgorithmRisk::Modern, - } -} - -fn host_key_risk(id: &str) -> AlgorithmRisk { - match id { - "ssh-dss" => AlgorithmRisk::Insecure, - "ssh-rsa" => AlgorithmRisk::Legacy, - _ => AlgorithmRisk::Modern, - } -} - -pub fn get_supported_ssh_algorithms() -> SupportedSshAlgorithms { - let compatible = defaults_from_preferred(compatible_algorithms()); - let secure = defaults_from_preferred(secure_algorithms()); - - let mut kex_ids = compatible.kex.clone(); - for id in &secure.kex { - if !kex_ids.contains(id) { - kex_ids.push(id.clone()); - } - } - - let mut cipher_ids = compatible.ciphers.clone(); - for id in &secure.ciphers { - if !cipher_ids.contains(id) { - cipher_ids.push(id.clone()); - } - } - - let mut mac_ids = compatible.macs.clone(); - for id in &secure.macs { - if !mac_ids.contains(id) { - mac_ids.push(id.clone()); - } - } - - let mut host_key_ids = compatible.host_keys.clone(); - for id in &secure.host_keys { - if !host_key_ids.contains(id) { - host_key_ids.push(id.clone()); - } - } - - SupportedSshAlgorithms { - kex: kex_ids - .into_iter() - .map(|id| algorithm_option(id.clone(), kex_risk(&id))) - .collect(), - ciphers: cipher_ids - .into_iter() - .map(|id| algorithm_option(id.clone(), cipher_risk(&id))) - .collect(), - macs: mac_ids - .into_iter() - .map(|id| algorithm_option(id.clone(), mac_risk(&id))) - .collect(), - host_keys: host_key_ids - .into_iter() - .map(|id| algorithm_option(id.clone(), host_key_risk(&id))) - .collect(), - compatible, - secure, - } -} - impl client::Handler for SshHandler { type Error = russh::Error; @@ -1420,53 +1117,12 @@ pub(super) async fn connect_with_proxy( "Opening SSH transport via proxy" ); - let proxy_addr = format!("{}:{}", proxy.host, proxy.port); match proxy.protocol.as_str() { - "socks5" => { - let stream = match (&proxy.username, &proxy.password) { - (Some(user), Some(pass)) => { - tokio_socks::tcp::Socks5Stream::connect_with_password( - proxy_addr.as_str(), - target, - user, - pass, - ) - .await - } - _ => tokio_socks::tcp::Socks5Stream::connect(proxy_addr.as_str(), target).await, - } - .map_err(|error| { - AppError::Auth(format!("SOCKS5 proxy connection failed: {}", error)) - })?; - client::connect_stream(ssh_config, stream.into_inner(), handler).await - } - "http" => { - let mut stream = - tokio::net::TcpStream::connect(&proxy_addr) - .await - .map_err(|error| { - AppError::Auth(format!("HTTP proxy connection failed: {}", error)) - })?; - - match (&proxy.username, &proxy.password) { - (Some(user), Some(pass)) => { - async_http_proxy::http_connect_tokio_with_basic_auth( - &mut stream, - &config.host, - config.port, - user, - pass, - ) - .await - } - _ => { - async_http_proxy::http_connect_tokio(&mut stream, &config.host, config.port) - .await - } - } - .map_err(|error| AppError::Auth(format!("HTTP proxy tunnel failed: {}", error)))?; - - client::connect_stream(ssh_config, stream, handler).await + "socks5" | "http" => { + let transport = + nyaterm_core::network::open_proxy_transport(proxy, &config.host, config.port) + .await?; + client::connect_stream(ssh_config, transport.stream, handler).await } "proxycommand" => { let stream = open_proxy_command_stream( @@ -1478,7 +1134,11 @@ pub(super) async fn connect_with_proxy( .await?; client::connect_stream(ssh_config, stream, handler).await } - _ => client::connect(ssh_config, target, handler).await, + other => { + return Err(AppError::Config(format!( + "Unsupported proxy protocol '{other}'" + ))); + } } } else { tracing::debug!( @@ -1486,7 +1146,7 @@ pub(super) async fn connect_with_proxy( port = config.port, "Opening direct SSH transport" ); - client::connect(ssh_config, target, handler).await + nyaterm_core::ssh::protocol::connect(ssh_config, target.0, target.1, handler).await } .map_err(|error| AppError::Auth(format!("SSH connection failed: {}", error)))?; diff --git a/src-tauri/src/core/ssh/io.rs b/src-tauri/src/core/ssh/io.rs index b3bf9e18..fcb8167a 100644 --- a/src-tauri/src/core/ssh/io.rs +++ b/src-tauri/src/core/ssh/io.rs @@ -288,42 +288,7 @@ async fn install_remote_shell_integration( .map(|_| ()) } -const CHANNEL_REQUEST_REPLY_TIMEOUT_MS: u64 = 10_000; - -async fn wait_channel_request_reply( - channel: &mut russh::Channel, - request_name: &str, -) -> AppResult<()> { - let reply = timeout( - Duration::from_millis(CHANNEL_REQUEST_REPLY_TIMEOUT_MS), - async { - loop { - match channel.wait().await { - Some(ChannelMsg::Success) => return Ok(()), - Some(ChannelMsg::Failure) => { - return Err(AppError::Channel(format!( - "{request_name} request rejected by server" - ))); - } - Some(ChannelMsg::Close | ChannelMsg::Eof) | None => { - return Err(AppError::Channel(format!( - "SSH channel closed before {request_name} request completed" - ))); - } - Some(_) => {} - } - } - }, - ) - .await; - - match reply { - Ok(result) => result, - Err(_) => Err(AppError::Channel(format!( - "{request_name} request timed out" - ))), - } -} +use nyaterm_core::ssh::protocol::wait_channel_request_reply; async fn close_failed_interactive_channel( channel: &russh::Channel, @@ -1349,10 +1314,10 @@ pub(super) async fn ssh_io_loop( if phase == IoPhase::Suppressing { suppression_diagnostics.record_pre_ready_write(send_data.len()); } - let _ = channel.data(&send_data[..]).await; + let _ = nyaterm_core::ssh::terminal::write(&channel, &send_data[..]).await; } Some(SessionCommand::Resize { cols, rows }) => { - let _ = channel.window_change(cols, rows, 0, 0).await; + let _ = nyaterm_core::ssh::terminal::resize(&channel, cols, rows).await; } Some(SessionCommand::PauseOutput) => { output_paused = true; diff --git a/src-tauri/src/core/ssh/mod.rs b/src-tauri/src/core/ssh/mod.rs index f82bdfc1..0570c004 100644 --- a/src-tauri/src/core/ssh/mod.rs +++ b/src-tauri/src/core/ssh/mod.rs @@ -22,7 +22,7 @@ pub use auth::{ pub use client::{HostKeyVerifyManager, SupportedSshAlgorithms, get_supported_ssh_algorithms}; pub(crate) use client::{ RemoteForwardOpen, SshAuth, SshConfig, SshConnectionHandles, SshHandle, SshRawHandle, - SshStartupCommand, open_proxy_command_stream, validate_ssh_algorithm_preferences, + SshStartupCommand, open_proxy_command_stream, }; pub use session::{create_multiplexed_ssh_session, create_ssh_session}; pub(crate) use session::{create_ssh_handle_for_tunnel, open_ssh_direct_tcpip_stream}; diff --git a/src-tauri/src/core/terminal_session/mod.rs b/src-tauri/src/core/terminal_session/mod.rs index 7f04489e..b397c08c 100644 --- a/src-tauri/src/core/terminal_session/mod.rs +++ b/src-tauri/src/core/terminal_session/mod.rs @@ -4,182 +4,6 @@ pub(crate) mod local; pub(crate) mod serial; pub(crate) mod telnet; -use crate::core::input::remap_del_to_bs; -use encoding_rs::{CoderResult, Decoder, Encoding, UTF_8}; - -pub(crate) fn terminal_encoding(label: &str) -> &'static Encoding { - let trimmed = label.trim(); - if trimmed.is_empty() { - return UTF_8; - } - Encoding::for_label(trimmed.as_bytes()).unwrap_or(UTF_8) -} - -pub(crate) struct TerminalOutputDecoder { - decoder: Decoder, -} - -impl TerminalOutputDecoder { - pub(crate) fn new(encoding: &str) -> Self { - Self { - decoder: terminal_encoding(encoding).new_decoder_without_bom_handling(), - } - } - - pub(crate) fn decode(&mut self, data: &[u8]) -> String { - let capacity = self - .decoder - .max_utf8_buffer_length(data.len()) - .unwrap_or_else(|| data.len().saturating_mul(4)); - let mut output = String::with_capacity(capacity); - let mut total_read = 0; - while total_read < data.len() { - let (result, read, _) = - self.decoder - .decode_to_string(&data[total_read..], &mut output, false); - total_read += read; - match result { - CoderResult::InputEmpty => break, - CoderResult::OutputFull => { - output.reserve( - self.decoder - .max_utf8_buffer_length(data.len() - total_read) - .unwrap_or_else(|| (data.len() - total_read).saturating_mul(4)) - .max(4), - ); - } - } - } - output - } -} - -pub(crate) fn encode_terminal_input(data: &[u8], encoding: &str) -> Vec { - let target = terminal_encoding(encoding); - if target == UTF_8 { - return data.to_vec(); - } - - let mut result = Vec::with_capacity(data.len()); - let mut i = 0; - while i < data.len() { - if data[i] == 0x1b { - let end = ansi_sequence_end(data, i); - result.extend_from_slice(&data[i..end]); - i = end; - continue; - } - - if data[i].is_ascii() { - result.push(data[i]); - i += 1; - continue; - } - - let start = i; - while i < data.len() && !data[i].is_ascii() && data[i] != 0x1b { - i += 1; - } - - let text = String::from_utf8_lossy(&data[start..i]); - let (encoded, _, _) = target.encode(&text); - result.extend_from_slice(&encoded); - } - - result -} - -pub(crate) fn prepare_terminal_write_input( - mut data: Vec, - encoding: &str, - raw: bool, - backspace_as_bs: bool, -) -> Vec { - if raw { - return data; - } - if backspace_as_bs { - remap_del_to_bs(&mut data); - } - encode_terminal_input(&data, encoding) -} - -fn ansi_sequence_end(data: &[u8], start: usize) -> usize { - let mut i = start + 1; - if i >= data.len() { - return i; - } - - if matches!(data[i], b'[' | b']' | b'(' | b')' | b'#') { - i += 1; - while i < data.len() { - let byte = data[i]; - i += 1; - if (0x40..=0x7e).contains(&byte) { - break; - } - } - return i; - } - - (i + 1).min(data.len()) -} - -#[cfg(test)] -mod tests { - use super::{TerminalOutputDecoder, encode_terminal_input, prepare_terminal_write_input}; - - #[test] - fn write_input_encodes_text_but_preserves_raw_bytes_and_del() { - assert_eq!( - prepare_terminal_write_input("测\u{7f}".as_bytes().to_vec(), "GBK", false, true), - vec![0xB2, 0xE2, 0x08] - ); - let raw = vec![0x1b, b'[', b'M', b' ', 0x80, 0xff, 0x7f]; - assert_eq!( - prepare_terminal_write_input(raw.clone(), "GBK", true, true), - raw - ); - } - - #[test] - fn gbk_decoder_preserves_split_multibyte_character() { - let mut decoder = TerminalOutputDecoder::new("GBK"); - assert_eq!(decoder.decode(&[0xB2]), ""); - assert_eq!(decoder.decode(&[0xE2]), "测"); - } - - #[test] - fn gb18030_decoder_preserves_split_multibyte_character() { - let mut decoder = TerminalOutputDecoder::new("GB18030"); - assert_eq!(decoder.decode(&[0xB2]), ""); - assert_eq!(decoder.decode(&[0xE2]), "测"); - } - - #[test] - fn gbk_decoder_handles_utf8_output_expansion() { - let mut decoder = TerminalOutputDecoder::new("GBK"); - let mut input = Vec::new(); - for _ in 0..1024 { - input.extend_from_slice(&[0xB2, 0xE2]); - } - - let output = decoder.decode(&input); - - assert_eq!(output.chars().count(), 1024); - assert!(output.chars().all(|ch| ch == '测')); - } - - #[test] - fn input_encoder_keeps_controls_and_ansi_sequences_raw() { - let input = b"\x1b[200~\x7f\xe6\xb5\x8b\x1b[201~"; - let encoded = encode_terminal_input(input, "GBK"); - assert_eq!( - encoded, - vec![ - 0x1b, b'[', b'2', b'0', b'0', b'~', 0x7f, 0xB2, 0xE2, 0x1b, b'[', b'2', b'0', b'1', - b'~' - ] - ); - } -} +pub(crate) use nyaterm_core::terminal_encoding::{ + TerminalOutputDecoder, encode_terminal_input, prepare_terminal_write_input, +}; diff --git a/src-tauri/src/core/terminal_session/telnet/manager.rs b/src-tauri/src/core/terminal_session/telnet/manager.rs index 77a9adc8..286ca497 100644 --- a/src-tauri/src/core/terminal_session/telnet/manager.rs +++ b/src-tauri/src/core/terminal_session/telnet/manager.rs @@ -27,8 +27,23 @@ pub async fn create_telnet_session( client_timestamp: None, }); let session_id = uuid::Uuid::new_v4().to_string(); - let addr = format!("{}:{}", host, port); - let stream = match await_telnet_connection(TcpStream::connect(&addr), cancel_rx).await { + let stream = match await_telnet_connection( + async { + crate::core::network::open_tcp_transport( + &app, + &host, + port, + config.network.as_ref(), + owner_window_label.clone(), + ) + .await + .map(|opened| opened.stream) + .map_err(std::io::Error::other) + }, + cancel_rx, + ) + .await + { Ok(stream) => stream, Err(AppError::Cancelled(message)) => { log_event(StructuredLog { diff --git a/src-tauri/src/core/terminal_session/telnet/mod.rs b/src-tauri/src/core/terminal_session/telnet/mod.rs index 810be3c5..6e9a89d5 100644 --- a/src-tauri/src/core/terminal_session/telnet/mod.rs +++ b/src-tauri/src/core/terminal_session/telnet/mod.rs @@ -21,25 +21,10 @@ use crate::observability::{StructuredLog, StructuredLogLevel, log_event, log_rat use std::sync::Arc; use tauri::{AppHandle, Emitter, Manager}; use tokio::io::{AsyncReadExt, AsyncWriteExt}; -use tokio::net::TcpStream; use tokio::sync::{Mutex as TokioMutex, mpsc, oneshot}; -const IAC: u8 = 255; -const WILL: u8 = 251; -const WONT: u8 = 252; -const DO: u8 = 253; -const DONT: u8 = 254; -const SB: u8 = 250; -const SE: u8 = 240; +pub use nyaterm_core::telnet::*; -const OPT_ECHO: u8 = 1; -const OPT_SUPPRESS_GO_AHEAD: u8 = 3; -const OPT_NAWS: u8 = 31; - -include!("types.rs"); -include!("negotiation.rs"); -include!("line_editor.rs"); -include!("auto_login.rs"); include!("tests.rs"); include!("manager.rs"); include!("session.rs"); diff --git a/src-tauri/src/core/terminal_session/telnet/session.rs b/src-tauri/src/core/terminal_session/telnet/session.rs index f5635443..781563ba 100644 --- a/src-tauri/src/core/terminal_session/telnet/session.rs +++ b/src-tauri/src/core/terminal_session/telnet/session.rs @@ -38,14 +38,14 @@ async fn telnet_session_task( manager: Arc, mut cmd_rx: SessionCommandReceiver, output_control_tx: SessionCommandSender, - stream: TcpStream, + stream: crate::core::network::BoxedTransportStream, config: TelnetSessionConfig, connection_id: Option, encoding: String, startup_command: Option, ) { let backspace_as_bs = config.backspace_mode == "ctrl_h"; - let (mut reader, mut writer) = stream.into_split(); + let (mut reader, mut writer) = tokio::io::split(stream); let output_event = format!("terminal-output-{}", session_id); let closed_event = format!("session-closed-{}", session_id); let recording_mgr: Option> = app @@ -95,6 +95,7 @@ async fn telnet_session_task( let mut buf = [0u8; 4096]; let mut zmodem_detector = ZmodemDetector::new(); let mut output_decoder = TerminalOutputDecoder::new(&encoding_reader); + let mut telnet_decoder = TelnetDecoder::default(); 'reader: loop { while *pause_rx.borrow() { if pause_rx.changed().await.is_err() { @@ -105,10 +106,10 @@ async fn telnet_session_task( Ok(0) => break, Ok(n) => { let visible = if reader_config.raw_tcp_cli { - unescape_iac_iac(&buf[..n]) + buf[..n].to_vec() } else { let neg_tx = negotiate_tx.clone(); - strip_telnet_commands(&buf[..n], &mut |cmd, opt| { + telnet_decoder.decode(&buf[..n], &mut |cmd, opt| { let resp = negotiate_response( cmd, opt, @@ -171,9 +172,7 @@ async fn telnet_session_task( let visible = if zmodem_download_oo_drain_reader.lock().await.is_active() { let mut drain = zmodem_download_oo_drain_reader.lock().await; - drain - .filter(&visible, std::time::Instant::now()) - .to_vec() + drain.filter(&visible, std::time::Instant::now()).to_vec() } else { visible }; @@ -243,9 +242,7 @@ async fn telnet_session_task( { let mut auto = auto_login_for_reader.lock().await; if let Some(auto) = auto.as_mut() { - for action in - auto.handle_text(&text, std::time::Instant::now()) - { + for action in auto.handle_text(&text, std::time::Instant::now()) { let _ = auto_login_tx.send(action); } } diff --git a/src-tauri/src/core/terminal_session/telnet/tests.rs b/src-tauri/src/core/terminal_session/telnet/tests.rs index cab398af..95d7a7ad 100644 --- a/src-tauri/src/core/terminal_session/telnet/tests.rs +++ b/src-tauri/src/core/terminal_session/telnet/tests.rs @@ -1,24 +1,8 @@ #[cfg(test)] mod tests { - use super::{ - DO, IAC, OPT_NAWS, OPT_SUPPRESS_GO_AHEAD, TelnetEnterMode, TelnetLineEditor, - TelnetAutoLogin, TelnetAutoLoginAction, TelnetAutoLoginConfig, - TelnetAutoLoginCredentials, TelnetSessionConfig, WILL, maybe_build_naws, - await_telnet_connection, escape_telnet_application_data, negotiate_response, normalize_enter_bytes, split_write_chunks, - strip_telnet_commands, - }; + use super::await_telnet_connection; use crate::error::AppError; - use std::time::Instant; use tokio::sync::oneshot; - - #[test] - fn standard_negotiation_responds_by_default() { - assert_eq!( - negotiate_response(WILL, OPT_SUPPRESS_GO_AHEAD, true, true), - vec![IAC, DO, OPT_SUPPRESS_GO_AHEAD] - ); - } - #[tokio::test] async fn connection_wait_can_be_cancelled_before_connect_finishes() { let (cancel_tx, cancel_rx) = oneshot::channel(); @@ -38,337 +22,11 @@ mod tests { #[tokio::test] async fn connection_wait_returns_connect_result_without_cancellation() { - let result = await_telnet_connection( - std::future::ready(Ok::<_, std::io::Error>(42_u8)), - None, - ) - .await - .expect("connect result"); + let result = + await_telnet_connection(std::future::ready(Ok::<_, std::io::Error>(42_u8)), None) + .await + .expect("connect result"); assert_eq!(result, 42); } - - #[test] - fn send_sga_false_rejects_sga() { - assert_ne!( - negotiate_response(WILL, OPT_SUPPRESS_GO_AHEAD, true, false), - vec![IAC, DO, OPT_SUPPRESS_GO_AHEAD] - ); - } - - #[test] - fn send_naws_false_rejects_naws_negotiation() { - assert_ne!( - negotiate_response(DO, OPT_NAWS, false, true), - vec![IAC, WILL, OPT_NAWS] - ); - } - - #[test] - fn raw_mode_can_suppress_negotiation_responses() { - let config = TelnetSessionConfig { - raw_tcp_cli: true, - ..Default::default() - }; - let mut responses = Vec::new(); - if !config.raw_tcp_cli { - let _ = strip_telnet_commands(&[IAC, WILL, OPT_SUPPRESS_GO_AHEAD], &mut |cmd, opt| { - responses.push(negotiate_response( - cmd, - opt, - config.send_naws, - config.send_sga, - )); - }); - } - assert!(responses.is_empty()); - } - - #[test] - fn binary_input_escapes_iac_only_for_telnet_transport() { - let mouse_report = b"\x1b[M \xff\x80"; - assert_eq!( - escape_telnet_application_data(mouse_report, false), - b"\x1b[M \xff\xff\x80" - ); - assert_eq!( - escape_telnet_application_data(mouse_report, true), - mouse_report - ); - } - - #[test] - fn send_naws_false_prevents_naws_resize_payload() { - let config = TelnetSessionConfig { - send_naws: false, - ..Default::default() - }; - assert!(maybe_build_naws(80, 24, &config).is_none()); - } - - #[test] - fn raw_mode_prevents_naws_resize_payload() { - let config = TelnetSessionConfig { - raw_tcp_cli: true, - ..Default::default() - }; - assert!(maybe_build_naws(80, 24, &config).is_none()); - } - - #[test] - fn enter_conversion_maps_carriage_return() { - assert_eq!( - normalize_enter_bytes(b"show\r", TelnetEnterMode::Crlf), - b"show\r\n" - ); - assert_eq!( - normalize_enter_bytes(b"show\r", TelnetEnterMode::Cr), - b"show\r" - ); - assert_eq!( - normalize_enter_bytes(b"show\r", TelnetEnterMode::Lf), - b"show\n" - ); - } - - #[test] - fn force_character_at_a_time_preserves_utf8_order() { - let chunks = split_write_chunks("a中\r".as_bytes(), true); - assert_eq!( - chunks, - vec![b"a".to_vec(), "中".as_bytes().to_vec(), b"\r".to_vec()] - ); - let joined: Vec = chunks.into_iter().flatten().collect(); - assert_eq!(joined, "a中\r".as_bytes()); - } - - #[test] - fn strip_telnet_commands_emits_naws_response_request() { - let mut seen = Vec::new(); - let visible = strip_telnet_commands(b"hi\xff\xfd\x1f", &mut |cmd, opt| { - seen.push((cmd, opt)); - }); - assert_eq!(visible, b"hi"); - assert_eq!(seen, vec![(DO, OPT_NAWS)]); - } - - #[test] - fn local_line_editor_backspace_updates_buffer() { - let mut editor = TelnetLineEditor::default(); - let result = editor.process(b"abc\x7f", TelnetEnterMode::Cr); - - assert_eq!(editor.buffer(), "ab"); - assert_eq!(result.display, "abc\x08 \x08"); - assert!(result.writes.is_empty()); - } - - #[test] - fn local_line_editor_sends_buffer_on_enter() { - let mut editor = TelnetLineEditor::default(); - let result = editor.process(b"abc\x7fd\r", TelnetEnterMode::Cr); - - assert_eq!(editor.buffer(), ""); - assert_eq!(result.writes, vec![b"abd\r".to_vec()]); - assert_eq!(result.display, "abc\x08 \x08d\r\n"); - - let mut editor = TelnetLineEditor::default(); - let result = editor.process(b"abc\x7fd\r", TelnetEnterMode::Crlf); - assert_eq!(result.writes, vec![b"abd\r\n".to_vec()]); - - let mut editor = TelnetLineEditor::default(); - let result = editor.process(b"abc\x7fd\r", TelnetEnterMode::Lf); - assert_eq!(result.writes, vec![b"abd\n".to_vec()]); - } - - #[test] - fn local_line_editor_backspace_removes_one_utf8_char() { - let mut editor = TelnetLineEditor::default(); - let result = editor.process("中a\u{7f}".as_bytes(), TelnetEnterMode::Cr); - - assert_eq!(editor.buffer(), "中"); - assert_eq!(result.display, "中a\x08 \x08"); - - let result = editor.process(b"\x7f", TelnetEnterMode::Cr); - assert_eq!(editor.buffer(), ""); - assert_eq!(result.display, "\x08 \x08"); - } - - #[test] - fn local_line_editor_passes_controls_without_buffering() { - let mut editor = TelnetLineEditor::default(); - let result = editor.process(b"a\x03\x04\x1b[A\x1b[3~", TelnetEnterMode::Cr); - - assert_eq!(editor.buffer(), ""); - assert_eq!( - result.writes, - vec![vec![0x03], vec![0x04], b"\x1b[A".to_vec()] - ); - assert_eq!(result.display, "a\x08 \x08"); - } - - fn auto_login() -> TelnetAutoLogin { - TelnetAutoLogin::new( - TelnetAutoLoginConfig::default(), - TelnetAutoLoginCredentials { - username: "admin".to_string(), - password: Some("secret".to_string()), - }, - TelnetEnterMode::Cr, - Instant::now(), - ) - .expect("auto login") - } - - fn sent_payloads(actions: Vec) -> Vec> { - actions - .into_iter() - .filter_map(|action| match action { - TelnetAutoLoginAction::Send(data) => Some(data), - _ => None, - }) - .collect() - } - - #[test] - fn auto_login_detects_split_username_and_password_prompts() { - let mut login = auto_login(); - - assert!(login.handle_text("User", Instant::now()).is_empty()); - assert_eq!( - sent_payloads(login.handle_text("name:", Instant::now())), - vec![b"admin\r".to_vec()] - ); - assert!(login.handle_text("Pass", Instant::now()).is_empty()); - assert_eq!( - sent_payloads(login.handle_text("word:", Instant::now())), - vec![b"secret\r".to_vec()] - ); - } - - #[test] - fn auto_login_detects_chinese_prompts() { - let mut login = auto_login(); - - assert_eq!( - sent_payloads(login.handle_text("用户名:", Instant::now())), - vec![b"admin\r".to_vec()] - ); - assert_eq!( - sent_payloads(login.handle_text("密码:", Instant::now())), - vec![b"secret\r".to_vec()] - ); - } - - #[test] - fn auto_login_detects_linux_getty_host_login_prompt() { - let mut login = auto_login(); - - assert_eq!( - sent_payloads(login.handle_text( - "\r\nLinux 6.8.0-107-generic (kaikai) (pts/3)\r\n\r\nkaikai login: ", - Instant::now() - )), - vec![b"admin\r".to_vec()] - ); - } - - #[test] - fn auto_login_detects_password_prompt_without_colon() { - let mut login = auto_login(); - - assert_eq!( - sent_payloads(login.handle_text("Input Password", Instant::now())), - vec![b"secret\r".to_vec()] - ); - } - - #[test] - fn auto_login_sends_wake_enter_once() { - let mut login = auto_login(); - - assert_eq!( - sent_payloads(login.handle_text("Press RETURN to get started.", Instant::now())), - vec![b"\r".to_vec()] - ); - assert!(login - .handle_text("Press RETURN to get started.", Instant::now()) - .is_empty()); - } - - #[test] - fn auto_login_does_not_treat_last_login_as_username_prompt() { - let mut login = auto_login(); - - assert!(login - .handle_text("Last login: Mon Jul 13 12:00:00", Instant::now()) - .is_empty()); - assert!(login - .handle_text("Previous login: Mon Jul 13 12:00:00", Instant::now()) - .is_empty()); - } - - #[test] - fn auto_login_sends_username_before_password_in_same_chunk() { - let mut login = auto_login(); - - assert_eq!( - sent_payloads(login.handle_text("Username:\nPassword:", Instant::now())), - vec![b"admin\r".to_vec(), b"secret\r".to_vec()] - ); - } - - #[test] - fn auto_login_completes_on_shell_prompt_after_credentials() { - let mut login = auto_login(); - let _ = login.handle_text("Username:", Instant::now()); - let _ = login.handle_text("Password:", Instant::now()); - - assert!(login - .handle_text("router#", Instant::now()) - .contains(&TelnetAutoLoginAction::Complete)); - } - - #[test] - fn auto_login_failure_retries_then_disables() { - let mut login = TelnetAutoLogin::new( - TelnetAutoLoginConfig { - max_retries: 1, - ..TelnetAutoLoginConfig::default() - }, - TelnetAutoLoginCredentials { - username: String::new(), - password: Some("secret".to_string()), - }, - TelnetEnterMode::Cr, - Instant::now(), - ) - .expect("auto login"); - - assert_eq!( - sent_payloads(login.handle_text("Password:", Instant::now())), - vec![b"secret\r".to_vec()] - ); - assert!(login - .handle_text("Authentication failed", Instant::now()) - .is_empty()); - assert_eq!( - sent_payloads(login.handle_text("Password:", Instant::now())), - vec![b"secret\r".to_vec()] - ); - assert!(login - .handle_text("Authentication failed", Instant::now()) - .contains(&TelnetAutoLoginAction::Disable)); - } - - #[test] - fn auto_login_user_input_disables_but_automated_input_does_not() { - let mut login = auto_login(); - - assert_eq!(login.handle_user_input(true), None); - assert_eq!( - login.handle_user_input(false), - Some(TelnetAutoLoginAction::Disable) - ); - assert!(login.handle_text("Username:", Instant::now()).is_empty()); - } } diff --git a/src-tauri/src/core/translate/mod.rs b/src-tauri/src/core/translate/mod.rs index b2bf9e2c..e67f1b5e 100644 --- a/src-tauri/src/core/translate/mod.rs +++ b/src-tauri/src/core/translate/mod.rs @@ -1,66 +1,2 @@ -mod ali; -mod baidu; -mod deepl; -mod google; -mod microsoft; -mod youdao; - -use crate::config::TranslationSettings; -use crate::error::{AppError, AppResult}; -use serde::{Deserialize, Serialize}; - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct TranslateResult { - pub original: String, - pub translated: String, - pub detected_language: String, - pub provider: String, -} -pub async fn translate( - provider: &str, - text: &str, - target_lang: &str, - settings: &TranslationSettings, -) -> AppResult { - let result = match provider { - "google" => google::translate(text, target_lang).await, - "microsoft" => microsoft::translate(text, target_lang).await, - "deepl" => deepl::translate(text, target_lang, &settings.deepl_api_key).await, - "baidu" => { - baidu::translate( - text, - target_lang, - &settings.baidu_app_id, - &settings.baidu_app_key, - ) - .await - } - "ali" => { - ali::translate( - text, - target_lang, - &settings.ali_app_id, - &settings.ali_app_key, - ) - .await - } - "youdao" => { - youdao::translate( - text, - target_lang, - &settings.youdao_app_id, - &settings.youdao_app_key, - ) - .await - } - _ => Err(AppError::Translation(format!( - "Unknown provider: {provider}" - ))), - }; - - result.map(|mut r| { - r.original = text.to_string(); - r.provider = provider.to_string(); - r - }) -} +//! Shared transport-neutral translation providers. +pub use nyaterm_core::core::translate::*; diff --git a/src-tauri/src/core/vnc.rs b/src-tauri/src/core/vnc.rs index ea4277f7..c326271d 100644 --- a/src-tauri/src/core/vnc.rs +++ b/src-tauri/src/core/vnc.rs @@ -1,246 +1,56 @@ -use crate::config::{self, ConnectionAuth, ConnectionNetwork, ConnectionType}; -use crate::core::network::open_tcp_transport; +//! Native event/IPC adapter for the shared VNC protocol engine. use crate::error::{AppError, AppResult}; -use serde::{Deserialize, Serialize}; -use sha2::{Digest, Sha256}; -use std::collections::{HashMap, VecDeque}; +use nyaterm_core::vnc::{ + self as shared, + runtime::{FrameChannel, VncContext}, +}; +pub use nyaterm_core::vnc::{VncConnectConfig, VncInputEvent}; use std::sync::Arc; -use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; -use tauri::async_runtime::JoinHandle; use tauri::ipc::{Channel, InvokeResponseBody}; use tauri::{AppHandle, Emitter}; -use tokio::sync::{Mutex, mpsc, oneshot, watch}; -use tokio::time::{Duration, timeout}; -use vnc::{ - ClientKeyEvent, ClientMouseEvent, PixelFormat, Rect, Screen, VncClient, VncConnector, - VncEncoding, VncError, VncEvent, VncLimits, VncSecurityPolicy, VncServerKey, X11Event, -}; -use zeroize::Zeroizing; -use crate::core::remote_desktop::frame::{ - RemoteDesktopFramePatch, RemoteDesktopPixelFormat, encode_frame_patch, -}; - -const MAX_FRAMEBUFFER_WIDTH: u16 = 7680; -const MAX_FRAMEBUFFER_HEIGHT: u16 = 4320; -const MAX_VNC_CLIPBOARD_BYTES: usize = 1024 * 1024; -const MAX_INPUT_BATCH: usize = 256; -const WORKER_COMMAND_CHANNEL_CAPACITY: usize = 256; -const MAX_PENDING_FRAMES: usize = 2; -const CONNECT_TIMEOUT: Duration = Duration::from_secs(15); -const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(30); -const RA2_HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(75); -const SERVER_KEY_PROMPT_TIMEOUT: Duration = Duration::from_mins(1); -const WORKER_SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(3); -const EVENT_POLL_INTERVAL: Duration = Duration::from_millis(8); -const UPDATE_REQUEST_INTERVAL: Duration = Duration::from_millis(16); - -#[derive(Debug, Clone, Serialize, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -pub enum VncSessionState { - Connecting, - Authenticating, - Negotiating, - Active, - Reconnecting, - Disconnected, - Failed, -} - -#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -pub enum VncErrorKind { - Transport, - Authentication, - Protocol, - Encoding, - Clipboard, - Internal, -} - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct VncStateEvent { - pub session_id: String, - pub state: VncSessionState, - pub message: Option, - pub error_kind: Option, -} - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct VncServerKeyVerifyEvent { - pub request_id: String, - pub session_id: String, - pub host: String, - pub port: u16, - pub fingerprint: String, - pub key_bits: u32, - pub known_host_status: String, - pub target_window_label: String, -} - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct VncClipboardEvent { - pub session_id: String, - pub text: String, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase", tag = "type")] -pub enum VncInputEvent { - #[serde(rename = "key")] - Key { keysym: u32, pressed: bool }, - #[serde(rename = "pointer")] - Pointer { - x: u16, - y: u16, - #[serde(alias = "buttonMask")] - button_mask: u8, - }, - #[serde(rename = "release-all-keys")] - ReleaseAllKeys, -} - -#[derive(Debug)] -enum VncWorkerCommand { - Input(VncInputEvent), - Clipboard(String), - FullRefresh, -} - -struct VncServerKeyPending { - session_id: String, - generation: u64, - responder: oneshot::Sender, -} - -#[derive(Debug, Clone)] -pub struct VncConnectConfig { - pub owner_window_label: String, - pub session_id: String, - pub connection_id: String, - pub name: String, - pub host: String, - pub port: u16, - pub username: String, - pub password: Option, - pub security_mode: String, - pub scale_mode: String, - pub clipboard_enabled: bool, - pub reconnect_enabled: bool, - pub reconnect_max_attempts: u32, - pub shared: bool, - pub view_only: bool, - pub network: Option, -} - -struct VncFramebuffer { - width: u16, - height: u16, - rgba: Vec, -} - -impl VncFramebuffer { - fn new(width: u16, height: u16) -> AppResult { - validate_framebuffer_dimensions(width, height)?; - let len = usize::from(width) - .checked_mul(usize::from(height)) - .and_then(|pixels| pixels.checked_mul(4)) - .ok_or_else(|| AppError::Config("VNC framebuffer size overflows".to_string()))?; - Ok(Self { - width, - height, - rgba: vec![0; len], - }) - } - - fn apply_rgba(&mut self, rect: Rect, pixels: &[u8]) -> AppResult<()> { - validate_rectangle(rect, self.width, self.height)?; - let row_bytes = usize::from(rect.width) - .checked_mul(4) - .ok_or_else(|| AppError::Config("VNC rectangle row size overflows".to_string()))?; - let required = row_bytes - .checked_mul(usize::from(rect.height)) - .ok_or_else(|| AppError::Config("VNC rectangle payload size overflows".to_string()))?; - if pixels.len() != required { - return Err(AppError::Config( - "VNC rectangle payload length is invalid".to_string(), - )); - } - let framebuffer_stride = usize::from(self.width) * 4; - for row in 0..usize::from(rect.height) { - let src_start = row * row_bytes; - let dst_start = - (usize::from(rect.y) + row) * framebuffer_stride + usize::from(rect.x) * 4; - self.rgba[dst_start..dst_start + row_bytes] - .copy_from_slice(&pixels[src_start..src_start + row_bytes]); - } - Ok(()) - } - - fn patch_bytes(&self, sequence: u64, rect: Rect, pixels: &[u8]) -> AppResult> { - encode_frame_patch(&RemoteDesktopFramePatch { - sequence, - desktop_width: u32::from(self.width), - desktop_height: u32::from(self.height), - x: u32::from(rect.x), - y: u32::from(rect.y), - width: u32::from(rect.width), - height: u32::from(rect.height), - stride: u32::from(rect.width) * 4, - pixel_format: RemoteDesktopPixelFormat::Rgba8888, - payload: pixels, - }) - } - - fn full_frame_bytes(&self, sequence: u64) -> AppResult> { - self.patch_bytes( - sequence, - Rect { - x: 0, - y: 0, - width: self.width, - height: self.height, - }, - &self.rgba, - ) +fn context(app: &AppHandle) -> VncContext { + let events_app = app.clone(); + let transport_app = app.clone(); + VncContext { + events: Arc::new(move |target, event, payload| { + let result = if let Some(target) = target { + events_app.emit_to(target, event, payload) + } else { + events_app.emit(event, payload) + }; + result.map_err(|e| AppError::Channel(e.to_string())) + }), + transport: Arc::new(move |host, port, network, owner| { + let app = transport_app.clone(); + Box::pin(async move { + crate::core::network::open_tcp_transport(&app, &host, port, network.as_ref(), owner) + .await + }) + }), } } - -pub struct VncSession { - pub config: VncConnectConfig, - state: Mutex, - message: Mutex>, - error_kind: Mutex>, - generation: AtomicU64, - frame_sequence: AtomicU64, - frame_attach_id: AtomicU64, - frame_channel: Mutex)>>, - pending_frames: Mutex>>, - framebuffer: Mutex>, - command_sender: Mutex>>, - cancel_sender: Mutex>>, - worker: Mutex>>, - close_requested: AtomicBool, - trust_commit_guard: std::sync::Mutex<()>, - pending_server_keys: Arc>>, +pub fn load_saved_vnc_config( + app: &AppHandle, + id: &str, + owner: String, +) -> AppResult { + shared::load_saved_vnc_config(&context(app), id, owner) } - pub struct VncSessionManager { - sessions: Mutex>>, - pending_server_keys: Arc>>, + inner: Arc, +} +impl Default for VncSessionManager { + fn default() -> Self { + Self::new() + } } - impl VncSessionManager { pub fn new() -> Self { Self { - sessions: Mutex::new(HashMap::new()), - pending_server_keys: Arc::new(Mutex::new(HashMap::new())), + inner: Arc::new(shared::VncSessionManager::new()), } } - pub async fn create_session( self: &Arc, app: AppHandle, @@ -251,1457 +61,45 @@ impl VncSessionManager { "VNC session requires an owner main window".into(), )); } - let session_id = config.session_id.clone(); - let session = Arc::new(VncSession { - config, - state: Mutex::new(VncSessionState::Connecting), - message: Mutex::new(None), - error_kind: Mutex::new(None), - generation: AtomicU64::new(0), - frame_sequence: AtomicU64::new(0), - frame_attach_id: AtomicU64::new(0), - frame_channel: Mutex::new(None), - pending_frames: Mutex::new(VecDeque::with_capacity(MAX_PENDING_FRAMES)), - framebuffer: Mutex::new(None), - command_sender: Mutex::new(None), - cancel_sender: Mutex::new(None), - worker: Mutex::new(None), - close_requested: AtomicBool::new(false), - trust_commit_guard: std::sync::Mutex::new(()), - pending_server_keys: self.pending_server_keys.clone(), - }); - self.sessions - .lock() - .await - .insert(session_id.clone(), session.clone()); - emit_state(&app, &session, VncSessionState::Connecting, None, None); - tracing::info!( - session_id = %session_id, - connection_id = %session.config.connection_id, - name = %session.config.name, - scale_mode = %session.config.scale_mode, - "Created VNC session" - ); - spawn_session_worker(app.clone(), session).await; - let _ = app.emit("sessions-changed", ()); - Ok(session_id) + self.inner.create_session(context(&app), config).await } - pub async fn attach_frame_channel( &self, app: &AppHandle, - session_id: &str, + id: &str, channel: Channel, ) -> AppResult<()> { - let session = self.get(session_id).await?; - let attach_id = session.frame_attach_id.fetch_add(1, Ordering::AcqRel) + 1; - *session.frame_channel.lock().await = Some((attach_id, channel)); - flush_pending_frames(&session, attach_id).await; - send_full_frame(&session, Some(attach_id)).await?; - let _ = send_worker_command(&session, VncWorkerCommand::FullRefresh).await; - replay_state(app, &session).await; - Ok(()) - } - - pub async fn send_input(&self, session_id: &str, events: Vec) -> AppResult<()> { - let session = self.get(session_id).await?; - if session.config.view_only { - return Err(AppError::Config( - "VNC input is disabled for a view-only session".to_string(), - )); - } - if events.len() > MAX_INPUT_BATCH { - return Err(AppError::Config(format!( - "VNC input batch exceeds {MAX_INPUT_BATCH} events" - ))); - } - let sender = session - .command_sender - .lock() + self.inner + .attach_frame_channel( + &context(app), + id, + FrameChannel(Arc::new(move |frame| { + channel + .send(InvokeResponseBody::Raw(frame)) + .map_err(|e| AppError::Channel(e.to_string())) + })), + ) .await - .clone() - .ok_or_else(|| AppError::Channel("VNC worker channel is not active".to_string()))?; - for event in events { - sender - .send(VncWorkerCommand::Input(event)) - .await - .map_err(|_| AppError::Channel("VNC worker channel closed".to_string()))?; - } - Ok(()) } - - pub async fn set_clipboard_text(&self, session_id: &str, text: String) -> AppResult<()> { - let session = self.get(session_id).await?; - if session.config.view_only || !session.config.clipboard_enabled { - return Err(AppError::Config( - "VNC clipboard sending is disabled".to_string(), - )); - } - if text.len() > MAX_VNC_CLIPBOARD_BYTES || !text.chars().all(|ch| u32::from(ch) <= 0xff) { - return Err(AppError::Config( - "VNC clipboard text must be Latin-1 and no larger than 1 MiB".to_string(), - )); - } - send_worker_command(&session, VncWorkerCommand::Clipboard(text)).await + pub async fn detach_frame_channel(&self, id: &str) -> AppResult<()> { + self.inner.detach_frame_channel(id).await } - - pub async fn reconnect(self: &Arc, app: AppHandle, session_id: &str) -> AppResult<()> { - let session = self.get(session_id).await?; - invalidate_vnc_generation(&session, false); - stop_worker(&session).await; - self.cancel_pending_server_keys_for_session(app.clone(), session_id) - .await; - session.close_requested.store(false, Ordering::Release); - *session.framebuffer.lock().await = None; - session.pending_frames.lock().await.clear(); - session.frame_sequence.store(0, Ordering::Release); - emit_state(&app, &session, VncSessionState::Reconnecting, None, None); - spawn_session_worker(app, session).await; - Ok(()) + pub async fn send_input(&self, id: &str, events: Vec) -> AppResult<()> { + self.inner.send_input(id, events).await } - - pub async fn close(&self, app: &AppHandle, session_id: &str) -> AppResult<()> { - let Some(session) = self.sessions.lock().await.remove(session_id) else { - return Ok(()); - }; - invalidate_vnc_generation(&session, true); - stop_worker(&session).await; - self.cancel_pending_server_keys_for_session(app.clone(), session_id) - .await; - session.frame_attach_id.fetch_add(1, Ordering::AcqRel); - *session.frame_channel.lock().await = None; - session.pending_frames.lock().await.clear(); - *session.framebuffer.lock().await = None; - set_state(&session, VncSessionState::Disconnected, None, None).await; - emit_state(app, &session, VncSessionState::Disconnected, None, None); - let _ = app.emit("sessions-changed", ()); - Ok(()) + pub async fn set_clipboard_text(&self, id: &str, text: String) -> AppResult<()> { + self.inner.set_clipboard_text(id, text).await } - - pub async fn respond_server_key(&self, request_id: &str, accepted: bool) -> AppResult<()> { - let Some(pending) = self.pending_server_keys.lock().await.remove(request_id) else { - return Err(AppError::Cancelled(format!( - "No pending VNC server key request with id '{request_id}'" - ))); - }; - let session = self.get(&pending.session_id).await?; - if session.generation.load(Ordering::Acquire) != pending.generation - || session.close_requested.load(Ordering::Acquire) - { - return Ok(()); - } - let _ = pending.responder.send(accepted); - Ok(()) + pub async fn reconnect(self: &Arc, app: AppHandle, id: &str) -> AppResult<()> { + self.inner.reconnect(context(&app), id).await } - - async fn cancel_pending_server_keys_for_session(&self, app: AppHandle, session_id: &str) { - let mut pending = self.pending_server_keys.lock().await; - let request_ids = pending - .iter() - .filter(|(_, request)| request.session_id == session_id) - .map(|(request_id, _)| request_id.clone()) - .collect::>(); - for request_id in request_ids { - if let Some(request) = pending.remove(&request_id) { - let _ = request.responder.send(false); - let _ = app.emit( - "vnc-server-key-verify-resolved", - serde_json::json!({"requestId": request_id}), - ); - } - } + pub async fn respond_server_key(&self, id: &str, accepted: bool) -> AppResult<()> { + self.inner.respond_server_key(id, accepted).await + } + pub async fn close(&self, app: &AppHandle, id: &str) -> AppResult<()> { + self.inner.close(&context(app), id).await } - pub async fn close_all(&self, app: &AppHandle) { - let ids = self - .sessions - .lock() - .await - .keys() - .cloned() - .collect::>(); - for id in ids { - if let Err(error) = self.close(app, &id).await { - tracing::warn!(session_id = %id, "Failed to close VNC session: {error}"); - } - } - } - - async fn get(&self, session_id: &str) -> AppResult> { - self.sessions - .lock() - .await - .get(session_id) - .cloned() - .ok_or_else(|| { - AppError::SessionNotFound(format!("VNC session '{session_id}' not found")) - }) - } -} - -impl Default for VncSessionManager { - fn default() -> Self { - Self::new() - } -} - -async fn spawn_session_worker(app: AppHandle, session: Arc) { - let (cancel_tx, cancel_rx) = watch::channel(false); - *session.cancel_sender.lock().await = Some(cancel_tx); - let worker_session = session.clone(); - let handle = tauri::async_runtime::spawn(async move { - run_session_worker(app, worker_session, cancel_rx).await; - }); - *session.worker.lock().await = Some(handle); -} - -async fn run_session_worker( - app: AppHandle, - session: Arc, - mut cancel_rx: watch::Receiver, -) { - let mut attempt = 0_u32; - loop { - if session.close_requested.load(Ordering::Acquire) || *cancel_rx.borrow() { - return; - } - let generation = session.generation.fetch_add(1, Ordering::AcqRel) + 1; - let state = if attempt == 0 { - VncSessionState::Connecting - } else { - VncSessionState::Reconnecting - }; - set_state(&session, state.clone(), None, None).await; - emit_state(&app, &session, state, None, None); - - match run_protocol_generation(&app, &session, generation, &mut cancel_rx).await { - Ok(()) => return, - Err((kind, message, retryable)) => { - if session.close_requested.load(Ordering::Acquire) || *cancel_rx.borrow() { - return; - } - if !retryable - || !session.config.reconnect_enabled - || attempt >= session.config.reconnect_max_attempts - { - set_state( - &session, - VncSessionState::Failed, - Some(message.clone()), - Some(kind), - ) - .await; - emit_state( - &app, - &session, - VncSessionState::Failed, - Some(message), - Some(kind), - ); - let _ = app.emit("sessions-changed", ()); - return; - } - attempt += 1; - let delay = reconnect_delay(attempt); - let sleep = tokio::time::sleep(delay); - tokio::pin!(sleep); - tokio::select! { - _ = &mut sleep => {}, - changed = cancel_rx.changed() => { - if changed.is_err() || *cancel_rx.borrow() { return; } - } - } - } - } - } -} - -async fn run_protocol_generation( - app: &AppHandle, - session: &Arc, - generation: u64, - cancel_rx: &mut watch::Receiver, -) -> Result<(), (VncErrorKind, String, bool)> { - let (host, port) = vnc_connect_target(&session.config); - let transport = tokio::select! { - result = timeout(CONNECT_TIMEOUT, open_tcp_transport( - app, - host, - port, - session.config.network.as_ref(), - Some(session.config.owner_window_label.clone()), - )) => { - result.map_err(|_| (VncErrorKind::Transport, "VNC connection timed out".to_string(), true))? - .map_err(|error| (VncErrorKind::Transport, format!("Unable to connect to the VNC server: {error}"), true))? - } - changed = cancel_rx.changed() => { - let _ = changed; - return Ok(()); - } - }; - let stream = transport.stream; - - set_state(session, VncSessionState::Authenticating, None, None).await; - emit_state(app, session, VncSessionState::Authenticating, None, None); - let password = Zeroizing::new(session.config.password.clone().unwrap_or_default()); - if session.config.security_mode == "vnc-auth" && password.len() > 8 { - return Err(( - VncErrorKind::Authentication, - "Classic VNC authentication passwords must be 8 bytes or fewer".to_string(), - false, - )); - } - - let limits = VncLimits { - max_framebuffer_width: MAX_FRAMEBUFFER_WIDTH, - max_framebuffer_height: MAX_FRAMEBUFFER_HEIGHT, - max_framebuffer_pixels: usize::from(MAX_FRAMEBUFFER_WIDTH) - * usize::from(MAX_FRAMEBUFFER_HEIGHT), - max_clipboard_bytes: MAX_VNC_CLIPBOARD_BYTES, - max_rectangles_per_update: 1024, - max_encoded_payload_bytes: 64 * 1024 * 1024, - max_decoded_payload_bytes: usize::from(MAX_FRAMEBUFFER_WIDTH) - * usize::from(MAX_FRAMEBUFFER_HEIGHT) - * 4, - channel_capacity: 32, - ..VncLimits::default() - }; - let auth_password = password.to_string(); - let has_credentials = session.config.password.is_some(); - let handshake_timeout = if session.config.security_mode == "auto" && has_credentials { - RA2_HANDSHAKE_TIMEOUT - } else { - HANDSHAKE_TIMEOUT - }; - let trust_candidate = Arc::new(std::sync::Mutex::new(None::)); - let verifier_trust_candidate = trust_candidate.clone(); - let authenticated_key = Arc::new(std::sync::Mutex::new(None::)); - let notified_key = authenticated_key.clone(); - let verifier_app = app.clone(); - let verifier_session = session.clone(); - let state = VncConnector::new(stream) - .set_auth_method(async move { Ok(auth_password) }) - .set_credentials_available(has_credentials) - .set_username(session.config.username.clone()) - .set_server_key_verifier(move |server_key| { - let app = verifier_app.clone(); - let session = verifier_session.clone(); - let trust_candidate = verifier_trust_candidate.clone(); - async move { - if let Some(fingerprint) = - verify_vnc_server_key(&app, &session, generation, &server_key).await? - { - *trust_candidate.lock().expect("VNC trust candidate lock") = Some(fingerprint); - } - Ok(()) - } - }) - .set_server_key_authenticated(move |key| { - *notified_key.lock().expect("VNC authenticated key lock") = - Some(vnc_key_fingerprint(&key)); - }) - .set_security_policy(security_policy(&session.config.security_mode)) - .set_pixel_format(PixelFormat::rgba()) - .set_limits(limits) - .add_encoding(VncEncoding::DesktopSizePseudo) - .add_encoding(VncEncoding::Zrle) - .add_encoding(VncEncoding::Tight) - .add_encoding(VncEncoding::Raw) - .allow_shared(session.config.shared) - .build() - .map_err(classify_vnc_error)?; - let handshake_result = tokio::select! { - biased; - changed = cancel_rx.changed() => { let _ = changed; None }, - result = timeout(handshake_timeout, state.try_start()) => { Some(result) }, - }; - // Also clean up a prompt if the outer handshake timeout dropped its future. - resolve_vnc_prompts(app, session, generation).await; - let Some(handshake_result) = handshake_result else { - return Ok(()); - }; - let client = handshake_result - .map_err(|_| { - ( - VncErrorKind::Transport, - "VNC protocol negotiation timed out".to_string(), - true, - ) - })? - .and_then(|state| state.finish()) - .map_err(classify_vnc_error)?; - { - // Serialize the trust write with close/reconnect invalidation. No await - // separates the final generation/cancellation check from the storage write. - let _guard = session - .trust_commit_guard - .lock() - .expect("VNC trust commit lock"); - let trust_candidate = trust_candidate.lock().expect("VNC trust candidate lock"); - let authenticated_key = authenticated_key - .lock() - .expect("VNC authenticated key lock"); - commit_vnc_trust( - trust_candidate.as_deref(), - authenticated_key.as_deref(), - generation, - session.generation.load(Ordering::Acquire), - session.close_requested.load(Ordering::Acquire) || *cancel_rx.borrow(), - |fingerprint| { - crate::storage::upsert_vnc_known_host( - &session.config.host, - session.config.port, - fingerprint, - ) - }, - ) - .map_err(|error| (VncErrorKind::Internal, error.to_string(), false))?; - } - - set_state(session, VncSessionState::Negotiating, None, None).await; - emit_state(app, session, VncSessionState::Negotiating, None, None); - let (command_tx, mut command_rx) = mpsc::channel(WORKER_COMMAND_CHANNEL_CAPACITY); - *session.command_sender.lock().await = Some(command_tx); - let mut pressed_keys = Vec::::new(); - let mut poll = tokio::time::interval(EVENT_POLL_INTERVAL); - poll.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); - let mut refresh_due = false; - let refresh_delay = tokio::time::sleep(Duration::from_secs(86_400)); - tokio::pin!(refresh_delay); - - loop { - if session.generation.load(Ordering::Acquire) != generation { - let _ = client.close().await; - return Ok(()); - } - tokio::select! { - changed = cancel_rx.changed() => { - let _ = changed; - release_pressed_keys(&client, &mut pressed_keys).await; - let _ = client.close().await; - return Ok(()); - } - _ = poll.tick() => { - loop { - match client.poll_event().await { - Ok(Some(event)) => { - handle_vnc_event(app, session, generation, event).await?; - refresh_due = true; - refresh_delay.as_mut().reset(tokio::time::Instant::now() + UPDATE_REQUEST_INTERVAL); - } - Ok(None) => break, - Err(error) => return Err(classify_vnc_error(error)), - } - } - } - _ = &mut refresh_delay, if refresh_due => { - client.input(X11Event::Refresh).await.map_err(classify_vnc_error)?; - refresh_due = false; - } - command = command_rx.recv() => { - let Some(command) = command else { - let _ = client.close().await; - return Ok(()); - }; - handle_worker_command(&client, command, &mut pressed_keys).await?; - } - } - } -} - -fn vnc_connect_target(config: &VncConnectConfig) -> (&str, u16) { - (config.host.as_str(), config.port) -} - -fn security_policy(mode: &str) -> VncSecurityPolicy { - match mode { - "none" => VncSecurityPolicy::NoneOnly, - "vnc-auth" => VncSecurityPolicy::VncAuthOnly, - _ => VncSecurityPolicy::Auto, - } -} - -fn vnc_key_fingerprint(key: &VncServerKey) -> String { - format!("SHA256:{}", hex::encode(Sha256::digest(key.encoded()))) -} - -fn invalidate_vnc_generation(session: &VncSession, closing: bool) { - let _guard = session - .trust_commit_guard - .lock() - .expect("VNC trust commit lock"); - session.generation.fetch_add(1, Ordering::AcqRel); - if closing { - session.close_requested.store(true, Ordering::Release); - } -} - -fn commit_vnc_trust( - trust_candidate: Option<&str>, - authenticated_key: Option<&str>, - generation: u64, - current_generation: u64, - cancelled: bool, - save: impl FnOnce(&str) -> AppResult<()>, -) -> AppResult<()> { - if generation == current_generation - && !cancelled - && let (Some(trust_candidate), Some(authenticated_key)) = - (trust_candidate, authenticated_key) - && trust_candidate == authenticated_key - { - save(trust_candidate)?; - } - Ok(()) -} - -async fn resolve_vnc_prompts(app: &AppHandle, session: &VncSession, generation: u64) { - let mut pending = session.pending_server_keys.lock().await; - let ids: Vec<_> = pending - .iter() - .filter(|(_, request)| { - request.session_id == session.config.session_id && request.generation == generation - }) - .map(|(id, _)| id.clone()) - .collect(); - for id in ids { - pending.remove(&id); - let _ = app.emit( - "vnc-server-key-verify-resolved", - serde_json::json!({"requestId": id}), - ); - } -} - -async fn verify_vnc_server_key( - app: &AppHandle, - session: &Arc, - generation: u64, - server_key: &VncServerKey, -) -> Result, VncError> { - if session.generation.load(Ordering::Acquire) != generation - || session.close_requested.load(Ordering::Acquire) - { - return Err(VncError::Ra2ServerKeyRejected( - "stale VNC connection generation".to_string(), - )); - } - - let fingerprint = vnc_key_fingerprint(server_key); - let known_host_status = crate::storage::check_vnc_known_host( - &session.config.host, - session.config.port, - &fingerprint, - ) - .map_err(|error| VncError::Ra2ServerKeyRejected(error.to_string()))?; - let confirmed_new_trust = confirm_vnc_trust( - known_host_status, - prompt_vnc_server_key( - app, - session, - generation, - server_key, - fingerprint.clone(), - known_host_status, - ), - ) - .await?; - Ok(confirmed_new_trust.then_some(fingerprint)) -} - -async fn confirm_vnc_trust( - known_host_status: crate::storage::KnownHostCheck, - prompt: impl std::future::Future>, -) -> Result { - if known_host_status == crate::storage::KnownHostCheck::Match { - return Ok(false); - } - prompt.await?; - Ok(true) -} - -async fn prompt_vnc_server_key( - app: &AppHandle, - session: &Arc, - generation: u64, - server_key: &VncServerKey, - fingerprint: String, - known_host_status: crate::storage::KnownHostCheck, -) -> Result<(), VncError> { - let request_id = uuid::Uuid::new_v4().to_string(); - let (tx, rx) = oneshot::channel(); - session.pending_server_keys.lock().await.insert( - request_id.clone(), - VncServerKeyPending { - session_id: session.config.session_id.clone(), - generation, - responder: tx, - }, - ); - let payload = VncServerKeyVerifyEvent { - request_id: request_id.clone(), - session_id: session.config.session_id.clone(), - host: session.config.host.clone(), - port: session.config.port, - fingerprint, - key_bits: server_key.bits(), - target_window_label: session.config.owner_window_label.clone(), - known_host_status: match known_host_status { - crate::storage::KnownHostCheck::Match => "match", - crate::storage::KnownHostCheck::HostSeen => "changed", - crate::storage::KnownHostCheck::UnknownHost => "unknown", - } - .to_string(), - }; - app.emit_to( - &session.config.owner_window_label, - "vnc-server-key-verify", - payload, - ) - .map_err(|error| VncError::Ra2ServerKeyRejected(error.to_string()))?; - - let response = timeout(SERVER_KEY_PROMPT_TIMEOUT, rx).await; - session.pending_server_keys.lock().await.remove(&request_id); - let _ = app.emit( - "vnc-server-key-verify-resolved", - serde_json::json!({"requestId": request_id}), - ); - let Ok(Ok(accepted)) = response else { - return Err(VncError::Ra2ServerKeyRejected( - "verification timed out".to_string(), - )); - }; - if session.generation.load(Ordering::Acquire) != generation - || session.close_requested.load(Ordering::Acquire) - { - return Err(VncError::Ra2ServerKeyRejected( - "stale VNC connection generation".to_string(), - )); - } - if !accepted { - return Err(VncError::Ra2ServerKeyRejected( - "user rejected the server key".to_string(), - )); - } - Ok(()) -} - -async fn handle_vnc_event( - app: &AppHandle, - session: &Arc, - generation: u64, - event: VncEvent, -) -> Result<(), (VncErrorKind, String, bool)> { - if session.generation.load(Ordering::Acquire) != generation { - return Ok(()); - } - match event { - VncEvent::SetResolution(Screen { width, height }) => { - let framebuffer = VncFramebuffer::new(width, height) - .map_err(|error| (VncErrorKind::Protocol, error.to_string(), false))?; - *session.framebuffer.lock().await = Some(framebuffer); - } - VncEvent::RawImage(rect, pixels) => { - let mut framebuffer_guard = session.framebuffer.lock().await; - if framebuffer_guard.is_none() { - let width = rect.x.checked_add(rect.width).ok_or_else(|| { - ( - VncErrorKind::Protocol, - "VNC rectangle bounds overflow".to_string(), - false, - ) - })?; - let height = rect.y.checked_add(rect.height).ok_or_else(|| { - ( - VncErrorKind::Protocol, - "VNC rectangle bounds overflow".to_string(), - false, - ) - })?; - *framebuffer_guard = Some( - VncFramebuffer::new(width, height) - .map_err(|error| (VncErrorKind::Protocol, error.to_string(), false))?, - ); - } - let framebuffer = framebuffer_guard.as_mut().expect("framebuffer initialized"); - framebuffer - .apply_rgba(rect, &pixels) - .map_err(|error| (VncErrorKind::Protocol, error.to_string(), false))?; - let sequence = session.frame_sequence.fetch_add(1, Ordering::AcqRel); - let frame = framebuffer - .patch_bytes(sequence, rect, &pixels) - .map_err(|error| (VncErrorKind::Internal, error.to_string(), false))?; - drop(framebuffer_guard); - queue_or_send_frame(session, frame).await; - let was_active = matches!(*session.state.lock().await, VncSessionState::Active); - if !was_active { - set_state(session, VncSessionState::Active, None, None).await; - emit_state(app, session, VncSessionState::Active, None, None); - let _ = app.emit("sessions-changed", ()); - } - } - VncEvent::Text(text) => { - if session.config.clipboard_enabled && text.len() <= MAX_VNC_CLIPBOARD_BYTES { - let _ = app.emit( - format!("vnc-clipboard-{}", session.config.session_id).as_str(), - VncClipboardEvent { - session_id: session.config.session_id.clone(), - text, - }, - ); - } - } - VncEvent::Error(message) => { - return Err(classify_vnc_event_error(message)); - } - VncEvent::JpegImage(_, _) => { - return Err(( - VncErrorKind::Encoding, - "The server sent a Tight JPEG event instead of decoded RGBA pixels".to_string(), - false, - )); - } - VncEvent::Copy(_, _) | VncEvent::SetCursor(_, _) => { - return Err(( - VncErrorKind::Encoding, - "The server sent an unrequested VNC encoding".to_string(), - false, - )); - } - VncEvent::SetPixelFormat(_) | VncEvent::Bell => {} - _ => {} - } - Ok(()) -} - -fn classify_vnc_event_error(message: String) -> (VncErrorKind, String, bool) { - let lower = message.to_ascii_lowercase(); - let kind = if lower.contains("image") - || lower.contains("encoding") - || lower.contains("zrle") - || lower.contains("tight") - || lower.contains("jpeg") - { - VncErrorKind::Encoding - } else { - VncErrorKind::Protocol - }; - (kind, message, false) -} - -async fn handle_worker_command( - client: &VncClient, - command: VncWorkerCommand, - pressed_keys: &mut Vec, -) -> Result<(), (VncErrorKind, String, bool)> { - match command { - VncWorkerCommand::Input(input) => send_vnc_input(client, input, pressed_keys).await, - VncWorkerCommand::Clipboard(text) => { - client - .input(X11Event::CopyText(text)) - .await - .map_err(|error| { - let (_, message, retryable) = classify_vnc_error(error); - (VncErrorKind::Clipboard, message, retryable) - }) - } - VncWorkerCommand::FullRefresh => client - .input(X11Event::FullRefresh) - .await - .map_err(classify_vnc_error), - } -} - -async fn send_vnc_input( - client: &VncClient, - input: VncInputEvent, - pressed_keys: &mut Vec, -) -> Result<(), (VncErrorKind, String, bool)> { - match input { - VncInputEvent::Key { keysym, pressed } => { - if pressed { - if !pressed_keys.contains(&keysym) { - pressed_keys.push(keysym); - } - } else { - pressed_keys.retain(|key| *key != keysym); - } - client - .input(X11Event::KeyEvent(ClientKeyEvent { - keycode: keysym, - down: pressed, - })) - .await - .map_err(classify_vnc_error)?; - } - VncInputEvent::Pointer { x, y, button_mask } => { - client - .input(X11Event::PointerEvent(ClientMouseEvent { - position_x: x, - position_y: y, - bottons: button_mask, - })) - .await - .map_err(classify_vnc_error)?; - } - VncInputEvent::ReleaseAllKeys => release_pressed_keys(client, pressed_keys).await, - } - Ok(()) -} - -async fn release_pressed_keys(client: &VncClient, pressed_keys: &mut Vec) { - for keysym in pressed_keys.drain(..) { - let _ = client - .input(X11Event::KeyEvent(ClientKeyEvent { - keycode: keysym, - down: false, - })) - .await; - } -} - -async fn send_worker_command( - session: &Arc, - command: VncWorkerCommand, -) -> AppResult<()> { - let sender = session - .command_sender - .lock() - .await - .clone() - .ok_or_else(|| AppError::Channel("VNC worker channel is not active".to_string()))?; - sender - .send(command) - .await - .map_err(|_| AppError::Channel("VNC worker channel closed".to_string())) -} - -async fn stop_worker(session: &Arc) { - if let Some(cancel) = session.cancel_sender.lock().await.take() { - let _ = cancel.send(true); - } - *session.command_sender.lock().await = None; - if let Some(mut worker) = session.worker.lock().await.take() { - if timeout(WORKER_SHUTDOWN_TIMEOUT, &mut worker).await.is_err() { - worker.abort(); - let _ = worker.await; - } - } -} - -async fn queue_or_send_frame(session: &Arc, frame: Vec) { - let attach_id = session.frame_attach_id.load(Ordering::Acquire); - let sent = { - let mut channel = session.frame_channel.lock().await; - match channel.as_ref() { - Some((current_attach_id, sender)) if *current_attach_id == attach_id => { - if sender.send(InvokeResponseBody::Raw(frame.clone())).is_ok() { - true - } else { - if channel.as_ref().is_some_and(|(id, _)| *id == attach_id) { - *channel = None; - } - false - } - } - _ => false, - } - }; - if sent { - return; - } - - let mut pending = session.pending_frames.lock().await; - while pending.len() >= MAX_PENDING_FRAMES { - pending.pop_front(); - } - pending.push_back(frame); -} - -async fn flush_pending_frames(session: &Arc, attach_id: u64) { - let channel = session.frame_channel.lock().await; - let Some((current_attach_id, channel)) = channel.as_ref() else { - return; - }; - if *current_attach_id != attach_id { - return; - } - let mut pending = session.pending_frames.lock().await; - while let Some(frame) = pending.pop_front() { - if channel.send(InvokeResponseBody::Raw(frame)).is_err() { - break; - } - } -} - -async fn send_full_frame(session: &Arc, attach_id: Option) -> AppResult<()> { - let frame = { - let framebuffer = session.framebuffer.lock().await; - let Some(framebuffer) = framebuffer.as_ref() else { - return Ok(()); - }; - let sequence = session.frame_sequence.fetch_add(1, Ordering::AcqRel); - framebuffer.full_frame_bytes(sequence)? - }; - if let Some(expected_attach_id) = attach_id { - let mut channel = session.frame_channel.lock().await; - let failed = channel - .as_ref() - .filter(|(current_attach_id, _)| *current_attach_id == expected_attach_id) - .is_some_and(|(_, channel)| { - channel - .send(InvokeResponseBody::Raw(frame.clone())) - .is_err() - }); - if failed - && channel - .as_ref() - .is_some_and(|(current_attach_id, _)| *current_attach_id == expected_attach_id) - { - *channel = None; - } - } else { - queue_or_send_frame(session, frame).await; - } - Ok(()) -} - -async fn set_state( - session: &VncSession, - state: VncSessionState, - message: Option, - error_kind: Option, -) { - *session.state.lock().await = state; - *session.message.lock().await = message; - *session.error_kind.lock().await = error_kind; -} - -fn emit_state( - app: &AppHandle, - session: &VncSession, - state: VncSessionState, - message: Option, - error_kind: Option, -) { - let _ = app.emit( - format!("vnc-state-{}", session.config.session_id).as_str(), - VncStateEvent { - session_id: session.config.session_id.clone(), - state, - message, - error_kind, - }, - ); -} - -async fn replay_state(app: &AppHandle, session: &VncSession) { - emit_state( - app, - session, - session.state.lock().await.clone(), - session.message.lock().await.clone(), - *session.error_kind.lock().await, - ); -} - -fn classify_vnc_error(error: VncError) -> (VncErrorKind, String, bool) { - match error { - VncError::NoPassword | VncError::WrongPassword => ( - VncErrorKind::Authentication, - "VNC authentication failed".to_string(), - false, - ), - VncError::CredentialTooLong { .. } | VncError::Ra2ServerKeyRejected(_) => { - (VncErrorKind::Authentication, error.to_string(), false) - } - VncError::UnsupportedSecurityType - | VncError::RequiredSecurityTypeUnavailable(_) - | VncError::InvalidSecurityType(_) => ( - VncErrorKind::Authentication, - format!( - "The VNC server requires an unsupported security type. Currently supported: None, VNC Authentication, and RA2_256 in Auto mode. Details: {error}" - ), - false, - ), - VncError::InvalidEncoding(_) | VncError::InvalidImageData => { - (VncErrorKind::Encoding, error.to_string(), false) - } - VncError::IoError(ref source) if source.kind() == std::io::ErrorKind::InvalidData => { - (VncErrorKind::Protocol, error.to_string(), false) - } - VncError::IoError(_) => (VncErrorKind::Transport, error.to_string(), true), - VncError::LimitExceeded { .. } - | VncError::InvalidDimensions - | VncError::IntegerOverflow(_) - | VncError::WrongPixelFormat - | VncError::WrongServerMessage - | VncError::InvalidSecurityResult(_) - | VncError::SecurityFailure(_) - | VncError::InvalidRa2KeyLength { .. } - | VncError::InvalidRa2PublicKey - | VncError::InvalidRa2EncryptedRandomLength { .. } - | VncError::InvalidRa2RandomLength(_) - | VncError::Ra2ServerHashMismatch - | VncError::InvalidRa2Subtype(_) - | VncError::Ra2ServerKeyVerifierRequired - | VncError::InvalidRa2RecordLimit(_) - | VncError::Ra2Crypto(_) => (VncErrorKind::Protocol, error.to_string(), false), - _ => (VncErrorKind::Internal, error.to_string(), true), - } -} - -fn reconnect_delay(attempt: u32) -> Duration { - Duration::from_secs(match attempt { - 0 | 1 => 1, - 2 => 2, - 3 => 4, - 4 => 8, - 5 => 15, - _ => 30, - }) -} - -fn validate_framebuffer_dimensions(width: u16, height: u16) -> AppResult<()> { - if width == 0 || height == 0 || width > MAX_FRAMEBUFFER_WIDTH || height > MAX_FRAMEBUFFER_HEIGHT - { - return Err(AppError::Config(format!( - "VNC framebuffer {width}x{height} is outside the supported range" - ))); - } - Ok(()) -} - -fn validate_rectangle(rect: Rect, desktop_width: u16, desktop_height: u16) -> AppResult<()> { - let right = rect - .x - .checked_add(rect.width) - .ok_or_else(|| AppError::Config("VNC rectangle horizontal bounds overflow".to_string()))?; - let bottom = rect - .y - .checked_add(rect.height) - .ok_or_else(|| AppError::Config("VNC rectangle vertical bounds overflow".to_string()))?; - if rect.width == 0 || rect.height == 0 || right > desktop_width || bottom > desktop_height { - return Err(AppError::Config( - "VNC rectangle exceeds framebuffer bounds".to_string(), - )); - } - Ok(()) -} - -pub fn load_saved_vnc_config( - app: &AppHandle, - connection_id: &str, - owner_window_label: String, -) -> AppResult { - let connection = config::load_connection_by_id(app, connection_id)?; - let network = connection.network.clone(); - let password = resolve_vnc_password(app, connection.auth.as_ref())?; - let ConnectionType::Vnc { - host, - port, - username, - security, - display, - clipboard, - reconnect, - shared, - view_only, - } = connection.config - else { - return Err(AppError::Config( - "Connection is not a VNC connection".to_string(), - )); - }; - if matches!(security.mode.as_str(), "vnc-auth") && password.is_none() { - return Err(AppError::Config( - "VNC Authentication requires a password".to_string(), - )); - } - if security.mode == "vnc-auth" - && let Some(password) = password.as_ref() - && password.len() > 8 - { - return Err(AppError::Config( - "Classic VNC authentication passwords must be 8 bytes or fewer".to_string(), - )); - } - - Ok(VncConnectConfig { - owner_window_label, - session_id: uuid::Uuid::new_v4().to_string(), - connection_id: connection_id.to_string(), - name: connection.name, - host, - port, - username, - password, - security_mode: security.mode, - scale_mode: display.scale_mode, - clipboard_enabled: clipboard.enabled, - reconnect_enabled: reconnect.enabled, - reconnect_max_attempts: reconnect.max_attempts, - shared, - view_only, - network, - }) -} - -fn resolve_vnc_password( - app: &AppHandle, - auth: Option<&ConnectionAuth>, -) -> AppResult> { - let Some(auth) = auth else { - return Ok(None); - }; - if auth.mode != "password" { - return Ok(None); - } - if let Some(password_id) = auth.password_id.as_deref().filter(|id| !id.is_empty()) { - let account = config::load_saved_account(app, None, Some(password_id))?; - return config::decrypt_account_password(account.as_ref()); - } - crate::utils::crypto::decrypt_optional(&auth.password) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn framebuffer_rejects_oversized_and_out_of_bounds_data() { - assert!(VncFramebuffer::new(7681, 1).is_err()); - let mut framebuffer = VncFramebuffer::new(10, 10).expect("framebuffer"); - assert!( - framebuffer - .apply_rgba( - Rect { - x: 10, - y: 0, - width: 1, - height: 1, - }, - &[0; 4] - ) - .is_err() - ); - } - - #[test] - fn framebuffer_applies_patches_and_builds_full_refreshes() { - let mut framebuffer = VncFramebuffer::new(2, 2).expect("framebuffer"); - framebuffer - .apply_rgba( - Rect { - x: 1, - y: 1, - width: 1, - height: 1, - }, - &[1, 2, 3, 255], - ) - .expect("patch"); - let frame = framebuffer.full_frame_bytes(7).expect("full frame"); - assert_eq!(&frame[0..8], &7_u64.to_le_bytes()); - assert_eq!(&frame[40..44], &16_u32.to_le_bytes()); - assert_eq!(&frame[56..60], &[1, 2, 3, 255]); - } - - #[test] - fn reconnect_delay_is_bounded() { - assert_eq!(reconnect_delay(1), Duration::from_secs(1)); - assert_eq!(reconnect_delay(100), Duration::from_secs(30)); - } - - #[tokio::test] - async fn vnc_tofu_commits_only_confirmed_successful_current_handshakes() { - use crate::storage::{KnownHostCheck, Storage}; - for (name, old_key, accepted, authenticated, current_generation, cancelled, expected) in [ - ( - "unknown-success", - None, - true, - true, - 1, - false, - KnownHostCheck::Match, - ), - ( - "unknown-reject", - None, - false, - false, - 1, - false, - KnownHostCheck::UnknownHost, - ), - ( - "hash-mismatch", - None, - true, - false, - 1, - false, - KnownHostCheck::UnknownHost, - ), - ( - "transport-error", - None, - true, - false, - 1, - false, - KnownHostCheck::UnknownHost, - ), - ( - "timeout", - None, - true, - false, - 1, - false, - KnownHostCheck::UnknownHost, - ), - ( - "changed-success", - Some("SHA256:old"), - true, - true, - 1, - false, - KnownHostCheck::Match, - ), - ( - "changed-reject", - Some("SHA256:old"), - false, - false, - 1, - false, - KnownHostCheck::HostSeen, - ), - ( - "stale-generation", - None, - true, - true, - 2, - false, - KnownHostCheck::UnknownHost, - ), - ( - "cancelled", - None, - true, - true, - 1, - true, - KnownHostCheck::UnknownHost, - ), - ] { - let dir = - std::env::temp_dir().join(format!("vnc-tofu-{name}-{}", uuid::Uuid::new_v4())); - let storage = Storage::open(&dir).unwrap(); - if let Some(old_key) = old_key { - storage - .upsert_vnc_known_host("pi.local", 5900, old_key) - .unwrap(); - } - let status = storage - .check_vnc_known_host("pi.local", 5900, "SHA256:new") - .unwrap(); - let trust_candidate = match confirm_vnc_trust(status, async { - if accepted { - Ok(()) - } else { - Err(VncError::Ra2ServerKeyRejected("rejected".into())) - } - }) - .await - { - Ok(true) => Some("SHA256:new"), - Ok(false) | Err(_) => None, - }; - let authenticated_key = authenticated.then_some("SHA256:new"); - commit_vnc_trust( - trust_candidate, - authenticated_key, - 1, - current_generation, - cancelled, - |fingerprint| storage.upsert_vnc_known_host("pi.local", 5900, fingerprint), - ) - .unwrap(); - assert_eq!( - storage - .check_vnc_known_host("pi.local", 5900, "SHA256:new") - .unwrap(), - expected, - "{name}" - ); - if old_key.is_some() && expected != KnownHostCheck::Match { - assert_eq!( - storage - .check_vnc_known_host("pi.local", 5900, "SHA256:old") - .unwrap(), - KnownHostCheck::Match - ); - } - drop(storage); - std::fs::remove_dir_all(dir).unwrap(); - } - } - - #[tokio::test] - async fn matching_vnc_key_does_not_poll_user_confirmation() { - assert!( - !confirm_vnc_trust(crate::storage::KnownHostCheck::Match, async { - panic!("matching key must never prompt"); - }) - .await - .unwrap() - ); - } - - #[tokio::test] - async fn matching_vnc_key_cannot_overwrite_newer_explicit_trust() { - use crate::storage::{KnownHostCheck, Storage}; - - let dir = std::env::temp_dir().join(format!("vnc-tofu-race-{}", uuid::Uuid::new_v4())); - let storage = Storage::open(&dir).unwrap(); - storage - .upsert_vnc_known_host("pi.local", 5900, "SHA256:k1") - .unwrap(); - - let status = storage - .check_vnc_known_host("pi.local", 5900, "SHA256:k1") - .unwrap(); - let trust_candidate = confirm_vnc_trust(status, async { - panic!("matching key must never prompt"); - }) - .await - .unwrap() - .then_some("SHA256:k1"); - assert!(trust_candidate.is_none()); - - storage - .upsert_vnc_known_host("pi.local", 5900, "SHA256:k2") - .unwrap(); - - commit_vnc_trust( - trust_candidate, - Some("SHA256:k1"), - 1, - 1, - false, - |fingerprint| storage.upsert_vnc_known_host("pi.local", 5900, fingerprint), - ) - .unwrap(); - - assert_eq!( - storage - .check_vnc_known_host("pi.local", 5900, "SHA256:k2") - .unwrap(), - KnownHostCheck::Match - ); - drop(storage); - std::fs::remove_dir_all(dir).unwrap(); - } - - #[test] - fn vnc_verification_payload_routes_to_its_session_owner() { - let payload = VncServerKeyVerifyEvent { - request_id: "request".into(), - session_id: "session".into(), - host: "pi.local".into(), - port: 5900, - fingerprint: "key".into(), - key_bits: 1024, - known_host_status: "unknown".into(), - target_window_label: "main-second".into(), - }; - assert_eq!( - serde_json::to_value(payload).unwrap()["targetWindowLabel"], - "main-second" - ); - } - - #[test] - fn security_mode_maps_to_fail_closed_policy() { - assert_eq!(security_policy("none"), VncSecurityPolicy::NoneOnly); - assert_eq!(security_policy("vnc-auth"), VncSecurityPolicy::VncAuthOnly); - assert_eq!(security_policy("auto"), VncSecurityPolicy::Auto); - } - - #[test] - fn connect_target_preserves_ipv6_literals_without_host_port_concatenation() { - let config = VncConnectConfig { - owner_window_label: "main-test".into(), - session_id: "vnc-test".to_string(), - connection_id: "connection-test".to_string(), - name: "IPv6 VNC".to_string(), - host: "::1".to_string(), - port: 5900, - username: String::new(), - password: None, - security_mode: "none".to_string(), - scale_mode: "fit".to_string(), - clipboard_enabled: true, - reconnect_enabled: false, - reconnect_max_attempts: 0, - shared: true, - view_only: false, - network: None, - }; - - assert_eq!(vnc_connect_target(&config), ("::1", 5900)); - } - - #[tokio::test] - async fn pending_frame_queue_keeps_latest_frames_under_pressure() { - let session = Arc::new(VncSession { - config: VncConnectConfig { - owner_window_label: "main-test".into(), - session_id: "vnc-test".to_string(), - connection_id: "connection-test".to_string(), - name: "Test VNC".to_string(), - host: "127.0.0.1".to_string(), - port: 5900, - username: String::new(), - password: None, - security_mode: "none".to_string(), - scale_mode: "fit".to_string(), - clipboard_enabled: true, - reconnect_enabled: false, - reconnect_max_attempts: 0, - shared: true, - view_only: false, - network: None, - }, - state: Mutex::new(VncSessionState::Connecting), - message: Mutex::new(None), - error_kind: Mutex::new(None), - generation: AtomicU64::new(0), - frame_sequence: AtomicU64::new(0), - frame_attach_id: AtomicU64::new(0), - frame_channel: Mutex::new(None), - pending_frames: Mutex::new(VecDeque::with_capacity(MAX_PENDING_FRAMES)), - framebuffer: Mutex::new(None), - command_sender: Mutex::new(None), - cancel_sender: Mutex::new(None), - worker: Mutex::new(None), - close_requested: AtomicBool::new(false), - trust_commit_guard: std::sync::Mutex::new(()), - pending_server_keys: Arc::new(Mutex::new(HashMap::new())), - }); - - queue_or_send_frame(&session, vec![1]).await; - queue_or_send_frame(&session, vec![2]).await; - queue_or_send_frame(&session, vec![3]).await; - - let pending = session.pending_frames.lock().await; - assert_eq!(pending.len(), MAX_PENDING_FRAMES); - assert_eq!(pending[0], vec![2]); - assert_eq!(pending[1], vec![3]); + self.inner.close_all(&context(app)).await } } diff --git a/src-tauri/src/error.rs b/src-tauri/src/error.rs index 0f5a016d..35c700d9 100644 --- a/src-tauri/src/error.rs +++ b/src-tauri/src/error.rs @@ -1,108 +1 @@ -//! Unified error type for Tauri commands. -//! -//! Provides `AppError` and `AppResult` for consistent error handling across the app. - -use serde::Serialize; - -/// Unified error type for all Tauri commands. -/// -/// Implements `Serialize` so Tauri can pass the error message to the frontend. -/// Uses `thiserror` for ergonomic `From` conversions and `Display` formatting. -#[derive(Debug, thiserror::Error)] -pub enum AppError { - #[error("SSH error: {0}")] - Ssh(#[from] russh::Error), - - #[error("SSH key error: {0}")] - SshKey(#[from] russh::keys::Error), - - #[error("IO error: {0}")] - Io(#[from] std::io::Error), - - #[error("JSON error: {0}")] - Json(#[from] serde_json::Error), - - #[error("{0}")] - SessionNotFound(String), - - #[error("{0}")] - Auth(String), - - #[error("{0}")] - Config(String), - - #[cfg_attr(not(windows), allow(dead_code))] - #[error("{0}")] - Unsupported(String), - - #[error("{0}")] - Storage(String), - - #[error("{0}")] - Channel(String), - - #[error("{0}")] - Cancelled(String), - - #[error("SFTP error: {0}")] - Sftp(#[from] russh_sftp::client::error::Error), - - #[error("Crypto error: {0}")] - Crypto(String), - - #[error("{0}")] - CloudSync(#[from] CloudSyncError), - - #[error("Translation error: {0}")] - Translation(String), -} - -#[derive(Debug, thiserror::Error, Clone, PartialEq, Eq)] -pub enum CloudSyncError { - #[error( - "Remote sync metadata is inconsistent: latest points to {revision} but the referenced snapshot is missing." - )] - SnapshotMissing { revision: String }, - - #[error( - "New sync snapshot was not accepted by remote storage (candidate revision {revision}; latest was not changed)." - )] - SnapshotNotAccepted { revision: String }, - - #[error("Remote sync storage rejected file '{filename}' (gist may be at file capacity).")] - RemoteFileRejected { filename: String }, - - #[error( - "Remote sync snapshot revision mismatch: latest points to {pointer_revision} but snapshot contains {snapshot_revision}." - )] - RevisionMismatch { - pointer_revision: String, - snapshot_revision: String, - }, - - #[error("Remote sync snapshot hash mismatch: expected {expected} but got {actual}.")] - HashMismatch { expected: String, actual: String }, - - #[error( - "Remote sync was updated by another device: expected {expected_revision:?} but found {actual_revision:?}." - )] - ConcurrentUpdate { - expected_revision: Option, - actual_revision: Option, - }, - - #[error("Remote sync snapshot {revision} is corrupted.")] - CorruptedSnapshot { revision: String }, -} - -impl Serialize for AppError { - fn serialize(&self, serializer: S) -> Result - where - S: serde::Serializer, - { - serializer.serialize_str(&self.to_string()) - } -} - -/// Convenience alias for `Result`. -pub type AppResult = Result; +pub use nyaterm_core::error::*; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 648aea6d..ea4511e5 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -272,6 +272,7 @@ pub fn run() { cmd::rdp::respond_rdp_certificate, cmd::vnc::create_vnc_session, cmd::vnc::vnc_attach_frame_channel, + cmd::vnc::vnc_detach_frame_channel, cmd::vnc::vnc_input_batch, cmd::vnc::vnc_set_clipboard_text, cmd::vnc::vnc_reconnect, diff --git a/src-tauri/src/storage/mod.rs b/src-tauri/src/storage/mod.rs index f3f203c4..632cb783 100644 --- a/src-tauri/src/storage/mod.rs +++ b/src-tauri/src/storage/mod.rs @@ -1,455 +1 @@ -//! redb-backed repository for `NyaTerm`'s user data. - -#![allow(dead_code)] - -mod credentials; -mod history; -mod known_hosts; -mod master_key; -mod migration; -mod notes; -mod rdp_known_hosts; -mod sessions; -mod settings_impl; -mod tables; -mod util; -mod vnc_known_hosts; - -#[cfg(test)] -mod tests; - -pub(crate) use known_hosts::KnownHostEntry; -pub(crate) use rdp_known_hosts::RdpCertificateMetadata; - -#[allow(unused_imports)] -pub use tables::{ - COMMAND_HISTORY_TABLE, CONNECTIONS_TABLE, CREDENTIALS_TABLE, GROUPS_TABLE, - IDX_CONNECTIONS_BY_GROUP_TABLE, IDX_CONNECTIONS_BY_LAST_USED_TABLE, - IDX_CONNECTIONS_BY_PROTOCOL_TABLE, KNOWN_HOSTS_TABLE, META_TABLE, NOTE_FOLDERS_TABLE, - NOTES_TABLE, OTP_ACCOUNTS_TABLE, PROXIES_TABLE, RDP_KNOWN_HOSTS_TABLE, SETTINGS_TABLE, - TUNNELS_TABLE, VNC_KNOWN_HOSTS_TABLE, -}; - -use crate::error::{AppError, AppResult}; -use redb::Database; -use serde::{Serialize, de::DeserializeOwned}; -use std::fs; -use std::path::{Path, PathBuf}; -use std::sync::{Arc, OnceLock}; - -use migration::backup_database_before_migration_if_needed; -use tables::DATABASE_FILE; -use util::storage_error; - -static STORAGE: OnceLock> = OnceLock::new(); - -#[derive(Debug)] -pub struct Storage { - pub(super) db: Database, - pub(super) db_path: PathBuf, - pub(super) pending_migration_backup: Option, -} - -#[derive(Debug, Clone)] -pub(super) struct BackupInfo { - pub(super) path: PathBuf, - pub(super) created_at_ms: u64, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum SettingsDocKey { - AppSettings, - QuickCommands, - CloudSyncSettings, - CloudSyncState, - AiHistory, - AiAudit, - SftpFileBackendCache, - WindowState, - ProxyGroups, - TunnelGroups, -} -impl SettingsDocKey { - fn storage_key(self) -> &'static str { - match self { - Self::AppSettings => tables::SETTINGS_DEFAULT, - Self::QuickCommands => "settings/doc/quick-command", - Self::CloudSyncSettings => "settings/doc/cloud-sync", - Self::CloudSyncState => "settings/doc/cloud-sync-state", - Self::AiHistory => "settings/doc/ai-history", - Self::AiAudit => "settings/doc/ai-audit", - Self::SftpFileBackendCache => "settings/doc/file-backend-cache", - Self::WindowState => "settings/doc/window-state", - Self::ProxyGroups => "settings/doc/proxy-groups", - Self::TunnelGroups => "settings/doc/tunnel-groups", - } - } - - fn legacy_key(self) -> Option<&'static str> { - match self { - Self::AppSettings => Some(tables::LEGACY_JSON_SETTINGS), - Self::QuickCommands => Some(tables::LEGACY_JSON_QUICK_COMMAND), - Self::CloudSyncSettings => Some(tables::LEGACY_JSON_CLOUD_SYNC), - Self::CloudSyncState => Some(tables::LEGACY_JSON_CLOUD_SYNC_STATE), - Self::AiHistory => Some(tables::LEGACY_JSON_AI_HISTORY), - Self::AiAudit => Some(tables::LEGACY_JSON_AI_AUDIT), - Self::SftpFileBackendCache => Some("file-backend-cache"), - Self::WindowState | Self::ProxyGroups | Self::TunnelGroups => None, - } - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum KnownHostCheck { - Match, - HostSeen, - UnknownHost, -} - -pub fn init(config_dir: &Path) -> AppResult<()> { - fs::create_dir_all(config_dir)?; - let storage = Arc::new(Storage::open(config_dir)?); - if STORAGE.set(storage).is_err() { - tracing::debug!("redb storage was already initialized"); - } - Ok(()) -} - -#[cfg(test)] -fn database_path(config_dir: &Path) -> PathBuf { - config_dir.join(DATABASE_FILE) -} - -pub(crate) fn load_settings_doc( - key: SettingsDocKey, -) -> AppResult { - Ok(storage()?.get_settings_doc(key)?.unwrap_or_default()) -} - -pub(crate) fn save_settings_doc(key: SettingsDocKey, value: &T) -> AppResult<()> { - storage()?.save_settings_doc(key, value) -} - -pub(crate) fn update_settings_doc(key: SettingsDocKey, updater: F) -> AppResult -where - T: DeserializeOwned + Default + Serialize, - F: FnOnce(&mut T) -> AppResult, -{ - storage()?.update_settings_doc(key, updater) -} - -pub(crate) fn load_sessions() -> AppResult { - storage()?.load_sessions() -} - -pub(crate) fn replace_sessions(config: &crate::config::SessionsConfig) -> AppResult<()> { - storage()?.replace_sessions(config) -} - -pub(crate) fn get_connection( - connection_id: &str, -) -> AppResult> { - storage()?.get_connection_with_secret(connection_id) -} - -pub(crate) fn get_connection_without_secret( - connection_id: &str, -) -> AppResult> { - storage()?.get_connection(connection_id) -} - -pub(crate) fn mark_connection_used(connection_id: &str) -> AppResult<()> { - storage()?.mark_connection_used(connection_id) -} - -pub(crate) fn list_passwords() -> AppResult> { - storage()?.list_passwords() -} - -pub(crate) fn replace_passwords(config: &crate::config::PasswordsConfig) -> AppResult<()> { - storage()?.replace_passwords(config) -} - -pub(crate) fn list_ssh_keys() -> AppResult> { - storage()?.list_ssh_keys() -} - -pub(crate) fn replace_ssh_keys(config: &crate::config::KeysConfig) -> AppResult<()> { - storage()?.replace_ssh_keys(config) -} - -pub(crate) fn list_credentials() -> AppResult> { - storage()?.list_credentials() -} - -pub(crate) fn replace_credentials(config: &crate::config::CredentialsConfig) -> AppResult<()> { - storage()?.replace_credentials(config) -} - -pub(crate) fn list_otp_accounts() -> AppResult> { - storage()?.list_otp_accounts() -} - -pub(crate) fn replace_otp_accounts(config: &crate::config::OtpConfig) -> AppResult<()> { - storage()?.replace_otp_accounts(config) -} - -pub(crate) fn list_proxies() -> AppResult> { - storage()?.list_proxies() -} - -pub(crate) fn replace_proxies(proxies: &[crate::config::ProxyConfig]) -> AppResult<()> { - storage()?.replace_proxies(proxies) -} - -pub(crate) fn list_tunnels() -> AppResult> { - storage()?.list_tunnels() -} - -pub(crate) fn replace_tunnels(tunnels: &[crate::config::TunnelConfig]) -> AppResult<()> { - storage()?.replace_tunnels(tunnels) -} - -pub(crate) fn list_command_history_entries( - limit: usize, -) -> AppResult> { - storage()?.list_recent_command_history(limit) -} - -pub(crate) fn replace_command_history_entries( - entries: &[crate::core::history::HistoryEntry], -) -> AppResult<()> { - storage()?.replace_command_history(entries) -} - -pub(crate) fn list_note_folders() -> AppResult> { - storage()?.list_note_folders() -} - -pub(crate) fn list_notes() -> AppResult> { - storage()?.list_notes() -} - -pub(crate) fn list_note_summaries() -> AppResult> { - storage()?.list_note_summaries() -} - -pub(crate) fn get_note(note_id: &str) -> AppResult> { - storage()?.get_note(note_id) -} - -pub(crate) fn create_note_folder( - parent_id: Option, - name: Option, -) -> AppResult { - storage()?.create_note_folder(parent_id, name) -} - -pub(crate) fn create_note( - parent_id: Option, - title: Option, - markdown: Option, -) -> AppResult { - storage()?.create_note(parent_id, title, markdown) -} - -pub(crate) fn update_note( - note_id: &str, - title: String, - markdown: String, - expected_revision: u64, - force: bool, -) -> AppResult { - storage()?.update_note(note_id, title, markdown, expected_revision, force) -} - -pub(crate) fn rename_note_node( - node_kind: &str, - node_id: &str, - name: String, -) -> AppResult { - storage()?.rename_note_node(node_kind, node_id, name) -} - -pub(crate) fn move_note_node( - node_kind: &str, - node_id: &str, - parent_id: Option, - sort_order: i64, -) -> AppResult { - storage()?.move_note_node(node_kind, node_id, parent_id, sort_order) -} - -pub(crate) fn delete_note_node( - node_kind: &str, - node_id: &str, -) -> AppResult { - storage()?.delete_note_node(node_kind, node_id) -} - -pub(crate) fn load_notes_snapshot() -> AppResult { - storage()?.load_notes_snapshot() -} - -pub(crate) fn replace_notes_snapshot(snapshot: &crate::config::NotesSnapshot) -> AppResult<()> { - storage()?.replace_notes_snapshot(snapshot) -} - -pub(crate) fn check_known_host( - host_identifier: &str, - key_type: &str, - key_base64: &str, -) -> AppResult { - storage()?.check_known_host(host_identifier, key_type, key_base64) -} - -pub(crate) fn upsert_known_host(line: &str) -> AppResult<()> { - storage()?.upsert_known_host(line) -} - -pub(crate) fn replace_known_host_for_host(host_identifier: &str, line: &str) -> AppResult<()> { - storage()?.replace_known_host_for_host(host_identifier, line) -} - -pub(crate) fn render_known_hosts_export() -> AppResult { - storage()?.render_known_hosts_export() -} - -pub(crate) fn replace_known_hosts_export(content: &str) -> AppResult<()> { - storage()?.replace_known_hosts_export(content) -} - -pub(crate) fn list_known_hosts() -> AppResult> { - storage()?.list_known_hosts() -} - -pub(crate) fn delete_known_host(id: &str) -> AppResult<()> { - storage()?.delete_known_host(id) -} - -pub(crate) fn clear_known_hosts() -> AppResult<()> { - storage()?.clear_known_hosts() -} - -pub(crate) fn check_rdp_known_host( - host: &str, - port: u16, - sha256_fingerprint: &str, -) -> AppResult { - storage()?.check_rdp_known_host(host, port, sha256_fingerprint) -} - -pub(crate) fn upsert_rdp_known_host( - host: &str, - port: u16, - sha256_fingerprint: &str, - certificate: RdpCertificateMetadata, -) -> AppResult<()> { - storage()?.upsert_rdp_known_host(host, port, sha256_fingerprint, certificate) -} - -pub(crate) fn check_vnc_known_host( - host: &str, - port: u16, - sha256_fingerprint: &str, -) -> AppResult { - storage()?.check_vnc_known_host(host, port, sha256_fingerprint) -} - -pub(crate) fn upsert_vnc_known_host( - host: &str, - port: u16, - sha256_fingerprint: &str, -) -> AppResult<()> { - storage()?.upsert_vnc_known_host(host, port, sha256_fingerprint) -} - -pub(crate) fn load_master_key_token() -> AppResult> { - storage()?.load_master_key_token() -} - -pub(crate) fn save_master_key_token(token: &str) -> AppResult<()> { - storage()?.save_master_key_token(token) -} - -fn storage() -> AppResult> { - if let Some(storage) = STORAGE.get() { - return Ok(storage.clone()); - } - let config_dir = default_config_dir()?; - init(&config_dir)?; - STORAGE - .get() - .cloned() - .ok_or_else(|| AppError::Storage("redb storage did not initialize".to_string())) -} -fn default_config_dir() -> AppResult { - #[cfg(test)] - { - return Ok( - std::env::temp_dir().join(format!("nyaterm-test-storage-{}", std::process::id())) - ); - } - - #[cfg(not(test))] - { - let home = dirs::home_dir() - .ok_or_else(|| AppError::Config("cannot determine home directory".to_string()))?; - Ok(home.join(".nyaterm")) - } -} - -impl Storage { - pub fn open(config_dir: &Path) -> AppResult { - fs::create_dir_all(config_dir)?; - Self::open_path(&config_dir.join(DATABASE_FILE)) - } - fn open_path(db_path: &Path) -> AppResult { - let pending_migration_backup = if db_path.exists() { - backup_database_before_migration_if_needed(db_path)? - } else { - None - }; - - let db = if db_path.exists() { - Database::open(db_path).map_err(storage_error)? - } else { - Database::create(db_path).map_err(storage_error)? - }; - let storage = Self { - db, - db_path: db_path.to_path_buf(), - pending_migration_backup, - }; - storage.migrate_if_needed()?; - storage.record_successful_v3_startup_and_cleanup_backups()?; - Ok(storage) - } - pub fn get_schema_version(&self) -> AppResult { - Ok(self.get_schema_version_optional()?.unwrap_or(1)) - } - pub fn set_schema_version(&self, version: u32) -> AppResult<()> { - let txn = self.db.begin_write().map_err(storage_error)?; - util::write_meta_u32(&txn, tables::META_SCHEMA_VERSION, version)?; - txn.commit().map_err(storage_error)?; - Ok(()) - } - pub fn migrate_if_needed(&self) -> AppResult<()> { - match self.get_schema_version_optional()? { - Some(version) if version >= tables::SCHEMA_VERSION => { - self.ensure_current_v3_tables()?; - return Ok(()); - } - Some(version) => { - tracing::info!(schema_version = version, "Migrating redb storage schema"); - } - None => {} - } - if self.has_legacy_data()? || self.get_schema_version_optional()?.is_some() { - self.migrate_to_v3()?; - } else { - self.initialize_v3_schema()?; - } - Ok(()) - } -} +pub use nyaterm_core::storage::*; diff --git a/src-tauri/src/utils/crypto.rs b/src-tauri/src/utils/crypto.rs index 3076bbae..406b4cec 100644 --- a/src-tauri/src/utils/crypto.rs +++ b/src-tauri/src/utils/crypto.rs @@ -1,378 +1 @@ -//! AES-256-GCM encryption for SSH credentials stored in redb JSON documents. -//! -//! ## Key hierarchy -//! -//! ```text -//! wrapping_key = SHA-256("nyaterm-key-wrap-v1:" || master_password_or_home_path) -//! master.key = redb text doc containing base64( wrap_nonce[12] || AES-256-GCM(wrapping_key, master_key[32]) ) -//! sessions doc = { "password": base64( nonce[12] || AES-256-GCM(master_key, plaintext) ), … } -//! ``` -//! -//! When a master password is configured the wrapping key is derived from it; -//! otherwise installed mode uses the user's home directory path as the key -//! material, while portable mode uses `data/config/portable.key`. -//! -//! The master password itself is stored in the settings document encrypted with the -//! home-path-derived key (via [`encrypt_settings_secret`]) to avoid a circular -//! dependency during bootstrap. - -use crate::error::{AppError, AppResult}; -use aes_gcm::aead::{Aead, OsRng}; -use aes_gcm::{AeadCore, Aes256Gcm, Key, KeyInit}; -use base64::Engine; -use base64::engine::general_purpose::STANDARD as B64; -use sha2::{Digest, Sha256}; -use std::path::PathBuf; -use std::sync::RwLock; - -const WRAPPING_KEY_PREFIX: &[u8] = b"nyaterm-key-wrap-v1:"; -const LEGACY_WRAPPING_KEY_PREFIX: &[u8] = b"dragonfly-key-wrap-v1:"; - -static MASTER_PASSWORD: RwLock> = RwLock::new(None); -static PORTABLE_KEY_PATH: RwLock> = RwLock::new(None); - -/// Set the master password used for wrapping key derivation. -/// Pass `None` to revert to home-path-based derivation. -pub fn set_master_password(password: Option) { - let mut pw = MASTER_PASSWORD.write().unwrap(); - *pw = password.filter(|s| !s.is_empty()); -} - -/// Set the portable-mode key path used when no master password is configured. -pub fn set_portable_key_path(path: Option) { - let mut portable_key_path = PORTABLE_KEY_PATH.write().unwrap(); - *portable_key_path = path; -} - -/// Returns the currently configured master password, if any. -pub fn get_master_password() -> Option { - MASTER_PASSWORD.read().unwrap().clone() -} - -/// Derives a wrapping key from the given material. -/// -/// When `password` is `Some`, uses the password as key material. -/// Otherwise falls back to the runtime key material. -fn derive_wrapping_key_with_prefix( - prefix: &[u8], - password: Option<&str>, -) -> AppResult> { - let mut h = Sha256::new(); - h.update(prefix); - match password { - Some(pw) => h.update(pw.as_bytes()), - None => h.update(fallback_key_material()?.as_slice()), - } - let digest = h.finalize(); - Ok(*Key::::from_slice(&digest)) -} - -fn fallback_key_material() -> AppResult> { - if let Some(path) = PORTABLE_KEY_PATH.read().unwrap().clone() { - return read_or_create_portable_key_material(path); - } - - let home = dirs::home_dir() - .ok_or_else(|| AppError::Crypto("cannot determine home directory".into()))?; - Ok(home.to_string_lossy().as_bytes().to_vec()) -} - -fn read_or_create_portable_key_material(path: PathBuf) -> AppResult> { - if path.exists() { - let material = std::fs::read_to_string(&path)?; - let material = material.trim(); - if material.is_empty() { - return Err(AppError::Crypto(format!( - "portable key file is empty: {}", - path.display() - ))); - } - return Ok(material.as_bytes().to_vec()); - } - - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent)?; - } - - use aes_gcm::aead::rand_core::RngCore as _; - let mut bytes = [0u8; 32]; - OsRng.fill_bytes(&mut bytes); - let material = B64.encode(bytes); - std::fs::write(&path, format!("{material}\n"))?; - Ok(material.into_bytes()) -} - -fn derive_wrapping_key(password: Option<&str>) -> AppResult> { - derive_wrapping_key_with_prefix(WRAPPING_KEY_PREFIX, password) -} - -fn derive_legacy_wrapping_key(password: Option<&str>) -> AppResult> { - derive_wrapping_key_with_prefix(LEGACY_WRAPPING_KEY_PREFIX, password) -} - -/// Derives the wrapping key from the current master password (or home path). -fn get_wrapping_key() -> AppResult> { - let pw = get_master_password(); - derive_wrapping_key(pw.as_deref()) -} - -/// Wraps `master_key` with `wrapping_key` and writes it to redb `master.key`. -fn write_wrapped_master_key( - master_key: &Key, - wrapping_key: &Key, -) -> AppResult<()> { - let cipher = Aes256Gcm::new(wrapping_key); - let nonce = Aes256Gcm::generate_nonce(&mut OsRng); - let wrapped = cipher - .encrypt(&nonce, master_key.as_slice()) - .map_err(|e| AppError::Crypto(format!("wrap master.key: {e}")))?; - - let mut combined = nonce.to_vec(); - combined.extend_from_slice(&wrapped); - - crate::storage::save_master_key_token(&B64.encode(&combined))?; - Ok(()) -} - -/// Unwraps the master key from the raw bytes of `master.key`. -fn unwrap_master_key_bytes(raw: &[u8], wrapping_key: &Key) -> AppResult> { - if raw.len() < 13 { - return Err(AppError::Crypto("master.key file is malformed".into())); - } - let cipher = Aes256Gcm::new(wrapping_key); - let (nonce_bytes, ciphertext) = raw.split_at(12); - let nonce = aes_gcm::Nonce::from_slice(nonce_bytes); - let master_key_bytes = cipher - .decrypt(nonce, ciphertext) - .map_err(|e| AppError::Crypto(format!("unwrap master.key: {e}")))?; - - if master_key_bytes.len() != 32 { - return Err(AppError::Crypto("master key length mismatch".into())); - } - Ok(*Key::::from_slice(&master_key_bytes)) -} - -fn unwrap_master_key_with_compatible_wrapping( - raw: &[u8], - password: Option<&str>, -) -> AppResult<(Key, bool)> { - let wrapping_key = derive_wrapping_key(password)?; - match unwrap_master_key_bytes(raw, &wrapping_key) { - Ok(master_key) => Ok((master_key, false)), - Err(new_error) => { - let legacy_wrapping_key = derive_legacy_wrapping_key(password)?; - match unwrap_master_key_bytes(raw, &legacy_wrapping_key) { - Ok(master_key) => Ok((master_key, true)), - Err(legacy_error) => Err(AppError::Crypto(format!( - "unwrap master.key: NyaTerm key prefix failed ({new_error}); legacy Dragonfly key prefix failed ({legacy_error})" - ))), - } - } - } -} - -/// Loads the master key from redb `master.key`, creating it on first use. -fn get_master_key() -> AppResult> { - if let Some(encoded) = crate::storage::load_master_key_token()? { - let raw = B64 - .decode(encoded.trim()) - .map_err(|e| AppError::Crypto(format!("decode master.key: {e}")))?; - - let password = get_master_password(); - let (master_key, used_legacy_prefix) = - unwrap_master_key_with_compatible_wrapping(&raw, password.as_deref())?; - if used_legacy_prefix { - let wrapping_key = derive_wrapping_key(password.as_deref())?; - write_wrapped_master_key(&master_key, &wrapping_key)?; - } - Ok(master_key) - } else { - let master_key = Aes256Gcm::generate_key(OsRng); - let wrapping_key = get_wrapping_key()?; - write_wrapped_master_key(&master_key, &wrapping_key)?; - Ok(master_key) - } -} - -pub fn verify_master_key_token() -> AppResult<()> { - let _ = get_master_key()?; - Ok(()) -} - -/// Re-wraps the existing master key when the master password changes. -/// -/// `old_password` is the previous master password (`None` = home-path-based). -/// `new_password` is the new master password (`None` = revert to home-path-based). -pub fn rewrap_master_key(old_password: Option<&str>, new_password: Option<&str>) -> AppResult<()> { - let master_key = if let Some(encoded) = crate::storage::load_master_key_token()? { - let raw = B64 - .decode(encoded.trim()) - .map_err(|e| AppError::Crypto(format!("decode master.key: {e}")))?; - - unwrap_master_key_with_compatible_wrapping(&raw, old_password)?.0 - } else { - Aes256Gcm::generate_key(OsRng) - }; - - let new_wrapping = derive_wrapping_key(new_password)?; - write_wrapped_master_key(&master_key, &new_wrapping)?; - Ok(()) -} - -// --------------------------------------------------------------------------- -// Settings-secret helpers (for the master password field in the settings document) -// --------------------------------------------------------------------------- - -/// Encrypts a value using the home-path-derived key directly (not via master.key). -/// -/// Used exclusively for storing the master password in settings.json to avoid -/// a circular dependency: we need the master password to unwrap master.key, -/// but master.key is needed to call [`encrypt`]. -pub fn encrypt_settings_secret(plaintext: &str) -> AppResult { - let key = derive_wrapping_key(None)?; - let cipher = Aes256Gcm::new(&key); - let nonce = Aes256Gcm::generate_nonce(&mut OsRng); - let ciphertext = cipher - .encrypt(&nonce, plaintext.as_bytes()) - .map_err(|e| AppError::Crypto(format!("encryption failed: {e}")))?; - - let mut combined = nonce.to_vec(); - combined.extend_from_slice(&ciphertext); - Ok(B64.encode(&combined)) -} - -/// Decrypts a value produced by [`encrypt_settings_secret`]. -pub fn decrypt_settings_secret(token: &str) -> AppResult { - let raw = B64 - .decode(token) - .map_err(|e| AppError::Crypto(format!("invalid base64: {e}")))?; - - if raw.len() < 13 { - return Err(AppError::Crypto("ciphertext too short".into())); - } - - let key = derive_wrapping_key(None)?; - match decrypt_settings_secret_bytes(&raw, &key) { - Ok(value) => Ok(value), - Err(new_error) => { - let legacy_key = derive_legacy_wrapping_key(None)?; - decrypt_settings_secret_bytes(&raw, &legacy_key).map_err(|legacy_error| { - AppError::Crypto(format!( - "settings secret decryption failed: NyaTerm key prefix failed ({new_error}); legacy Dragonfly key prefix failed ({legacy_error})" - )) - }) - } - } -} - -fn decrypt_settings_secret_bytes(raw: &[u8], key: &Key) -> AppResult { - let cipher = Aes256Gcm::new(key); - let (nonce_bytes, ciphertext) = raw.split_at(12); - let nonce = aes_gcm::Nonce::from_slice(nonce_bytes); - let plaintext = cipher - .decrypt(nonce, ciphertext) - .map_err(|e| AppError::Crypto(format!("decryption failed: {e}")))?; - String::from_utf8(plaintext).map_err(|e| AppError::Crypto(format!("invalid UTF-8: {e}"))) -} - -// --------------------------------------------------------------------------- -// Public encrypt / decrypt (use the master key from master.key) -// --------------------------------------------------------------------------- - -/// Encrypts `plaintext` with AES-256-GCM. -/// -/// Returns `base64( nonce[12] || ciphertext+tag )`. -pub fn encrypt(plaintext: &str) -> AppResult { - let key = get_master_key()?; - let cipher = Aes256Gcm::new(&key); - let nonce = Aes256Gcm::generate_nonce(&mut OsRng); - let ciphertext = cipher - .encrypt(&nonce, plaintext.as_bytes()) - .map_err(|e| AppError::Crypto(format!("encryption failed: {e}")))?; - - let mut combined = nonce.to_vec(); - combined.extend_from_slice(&ciphertext); - Ok(B64.encode(&combined)) -} - -/// Decrypts a `base64( nonce || ciphertext )` token produced by [`encrypt`]. -pub fn decrypt(token: &str) -> AppResult { - let key = get_master_key()?; - let cipher = Aes256Gcm::new(&key); - let raw = B64 - .decode(token) - .map_err(|e| AppError::Crypto(format!("invalid base64: {e}")))?; - - if raw.len() < 13 { - return Err(AppError::Crypto("ciphertext too short".into())); - } - - let (nonce_bytes, ciphertext) = raw.split_at(12); - let nonce = aes_gcm::Nonce::from_slice(nonce_bytes); - let plaintext = cipher - .decrypt(nonce, ciphertext) - .map_err(|e| AppError::Crypto(format!("decryption failed: {e}")))?; - - String::from_utf8(plaintext).map_err(|e| AppError::Crypto(format!("invalid UTF-8: {e}"))) -} - -/// Decrypts an optional token, returning `None` when the input is `None` or empty. -pub fn decrypt_optional(token: &Option) -> AppResult> { - match token { - Some(t) if !t.is_empty() => Ok(Some(decrypt(t)?)), - _ => Ok(None), - } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn wrap_key_for_test(master_key: &Key, wrapping_key: &Key) -> Vec { - let cipher = Aes256Gcm::new(wrapping_key); - let nonce = Aes256Gcm::generate_nonce(&mut OsRng); - let wrapped = cipher - .encrypt(&nonce, master_key.as_slice()) - .expect("wrap test key"); - let mut combined = nonce.to_vec(); - combined.extend_from_slice(&wrapped); - combined - } - - fn encrypt_settings_secret_with_key_for_test(plaintext: &str, key: &Key) -> String { - let cipher = Aes256Gcm::new(key); - let nonce = Aes256Gcm::generate_nonce(&mut OsRng); - let ciphertext = cipher - .encrypt(&nonce, plaintext.as_bytes()) - .expect("encrypt settings secret"); - let mut combined = nonce.to_vec(); - combined.extend_from_slice(&ciphertext); - B64.encode(combined) - } - - #[test] - fn legacy_dragonfly_master_key_prefix_can_be_unwrapped() { - let master_key = Aes256Gcm::generate_key(OsRng); - let legacy_wrapping_key = - derive_legacy_wrapping_key(Some("secret")).expect("legacy wrapping key"); - let raw = wrap_key_for_test(&master_key, &legacy_wrapping_key); - - let (unwrapped, used_legacy_prefix) = - unwrap_master_key_with_compatible_wrapping(&raw, Some("secret")) - .expect("unwrap with compatibility"); - - assert!(used_legacy_prefix); - assert_eq!(unwrapped.as_slice(), master_key.as_slice()); - } - - #[test] - fn legacy_dragonfly_settings_secret_prefix_can_be_decrypted() { - let legacy_key = derive_legacy_wrapping_key(None).expect("legacy settings key"); - let token = encrypt_settings_secret_with_key_for_test("stored-password", &legacy_key); - - assert_eq!( - decrypt_settings_secret(&token).expect("decrypt legacy settings secret"), - "stored-password" - ); - } -} +pub use nyaterm_core::utils::crypto::*; diff --git a/src-tauri/src/utils/fonts.rs b/src-tauri/src/utils/fonts.rs index 804bd89b..71f0e008 100644 --- a/src-tauri/src/utils/fonts.rs +++ b/src-tauri/src/utils/fonts.rs @@ -1,268 +1 @@ -use font_kit::font::Font; -use font_kit::source::SystemSource; -use serde::Serialize; - -pub const DEFAULT_TERMINAL_FONT_FAMILY: &str = "\"JetBrainsMono Nerd Font Mono\", \"JetBrains Mono\", \"Cascadia Mono\", \"SF Mono\", Menlo, Monaco, Consolas, \"Liberation Mono\", monospace"; -const LEGACY_DEFAULT_TERMINAL_FONT_FAMILY: &str = "\"JetBrains Mono\", \"Cascadia Mono\", \"SF Mono\", Menlo, Monaco, Consolas, \"Liberation Mono\", monospace"; - -#[cfg(target_os = "macos")] -pub const DEFAULT_UI_FONT_FAMILY: &str = "system-ui, -apple-system, BlinkMacSystemFont, \"PingFang SC\", \"Helvetica Neue\", Arial, sans-serif"; - -#[cfg(target_os = "windows")] -pub const DEFAULT_UI_FONT_FAMILY: &str = - "system-ui, \"Segoe UI\", \"Microsoft YaHei\", \"Noto Sans SC\", Arial, sans-serif"; - -#[cfg(not(any(target_os = "macos", target_os = "windows")))] -pub const DEFAULT_UI_FONT_FAMILY: &str = - "system-ui, \"Noto Sans SC\", \"Noto Sans CJK SC\", \"Helvetica Neue\", Arial, sans-serif"; - -const BUILT_IN_MONOSPACE_FONTS: &[&str] = &["JetBrainsMono Nerd Font Mono", "JetBrains Mono"]; -const GENERIC_MONOSPACE_FAMILY: &str = "monospace"; - -#[derive(Debug, Clone, Serialize)] -pub struct FontInfo { - pub family: String, - pub monospace: bool, -} - -pub fn list_system_font_infos() -> Vec { - let source = SystemSource::new(); - let Ok(mut families) = source.all_families() else { - return Vec::new(); - }; - - families.sort(); - families.dedup(); - families - .into_iter() - .map(|family| FontInfo { - monospace: is_system_font_family_monospace_with_source(&source, &family), - family, - }) - .collect() -} - -pub fn list_system_font_families() -> Vec { - let Ok(mut families) = SystemSource::new().all_families() else { - return Vec::new(); - }; - - families.sort(); - families.dedup(); - families -} - -pub fn normalize_terminal_font_family(font_family: &str) -> String { - if is_default_terminal_font_family(font_family) { - return DEFAULT_TERMINAL_FONT_FAMILY.to_string(); - } - - if is_legacy_default_terminal_font_family(font_family) { - return DEFAULT_TERMINAL_FONT_FAMILY.to_string(); - } - - let source = SystemSource::new(); - let mut kept = Vec::new(); - - for raw_family in split_font_family_stack(font_family) { - let family = unquote_font_family(&raw_family); - if is_generic_monospace_family(&family) { - push_unique_font_family(&mut kept, GENERIC_MONOSPACE_FAMILY.to_string()); - continue; - } - - if is_built_in_monospace_family(&family) - || is_system_font_family_monospace_with_source(&source, &family) - { - push_unique_font_family(&mut kept, raw_family.trim().to_string()); - } - } - - if kept.is_empty() { - DEFAULT_TERMINAL_FONT_FAMILY.to_string() - } else { - kept.join(", ") - } -} - -fn is_built_in_monospace_family(family: &str) -> bool { - BUILT_IN_MONOSPACE_FONTS - .iter() - .any(|known| known.eq_ignore_ascii_case(family)) -} - -fn is_default_terminal_font_family(font_family: &str) -> bool { - font_family - .trim() - .eq_ignore_ascii_case(DEFAULT_TERMINAL_FONT_FAMILY) -} - -fn is_legacy_default_terminal_font_family(font_family: &str) -> bool { - font_family - .trim() - .eq_ignore_ascii_case(LEGACY_DEFAULT_TERMINAL_FONT_FAMILY) -} - -fn is_generic_monospace_family(family: &str) -> bool { - family.eq_ignore_ascii_case(GENERIC_MONOSPACE_FAMILY) -} - -fn is_system_font_family_monospace_with_source(source: &SystemSource, family: &str) -> bool { - let Ok(family_handle) = source.select_family_by_name(family) else { - return false; - }; - - family_handle - .fonts() - .iter() - .filter_map(|handle| Font::from_handle(handle).ok()) - .any(|font| is_font_monospace(&font)) -} - -fn is_font_monospace(font: &Font) -> bool { - font.is_monospace() || has_equal_sample_advances(&font) -} - -fn has_equal_sample_advances(font: &Font) -> bool { - let mut advances = Vec::new(); - - for ch in ['i', 'W', '0', 'm', ' '] { - let Some(glyph_id) = font.glyph_for_char(ch) else { - return false; - }; - let Ok(advance) = font.advance(glyph_id) else { - return false; - }; - advances.push(advance.x()); - } - - let Some(first) = advances.first().copied() else { - return false; - }; - advances - .iter() - .all(|advance| (advance - first).abs() <= f32::EPSILON) -} - -fn split_font_family_stack(stack: &str) -> Vec { - let mut result = Vec::new(); - let mut current = String::new(); - let mut quote: Option = None; - let mut escaping = false; - - for ch in stack.chars() { - if escaping { - current.push(ch); - escaping = false; - continue; - } - - if ch == '\\' { - current.push(ch); - escaping = true; - continue; - } - - match quote { - Some(active_quote) if ch == active_quote => { - quote = None; - current.push(ch); - } - Some(_) => current.push(ch), - None if ch == '\'' || ch == '"' => { - quote = Some(ch); - current.push(ch); - } - None if ch == ',' => { - let trimmed = current.trim(); - if !trimmed.is_empty() { - result.push(trimmed.to_string()); - } - current.clear(); - } - None => current.push(ch), - } - } - - let trimmed = current.trim(); - if !trimmed.is_empty() { - result.push(trimmed.to_string()); - } - - result -} - -fn unquote_font_family(family: &str) -> String { - let trimmed = family.trim(); - if trimmed.len() >= 2 { - let first = trimmed.chars().next(); - let last = trimmed.chars().next_back(); - if matches!( - (first, last), - (Some('\''), Some('\'')) | (Some('"'), Some('"')) - ) { - return trimmed[1..trimmed.len() - 1].to_string(); - } - } - trimmed.to_string() -} - -fn push_unique_font_family(families: &mut Vec, family: String) { - let normalized = unquote_font_family(&family); - if families - .iter() - .map(|existing| unquote_font_family(existing)) - .any(|existing| existing.eq_ignore_ascii_case(&normalized)) - { - return; - } - families.push(family); -} - -#[cfg(test)] -mod tests { - use super::{ - DEFAULT_TERMINAL_FONT_FAMILY, normalize_terminal_font_family, split_font_family_stack, - unquote_font_family, - }; - - #[test] - fn splits_font_stack_without_splitting_inside_quotes() { - assert_eq!( - split_font_family_stack("JetBrains Mono, \"Noto, Sans\", monospace"), - vec!["JetBrains Mono", "\"Noto, Sans\"", "monospace"] - ); - } - - #[test] - fn removes_matching_wrapping_quotes() { - assert_eq!(unquote_font_family("\"Cascadia Mono\""), "Cascadia Mono"); - assert_eq!(unquote_font_family("'JetBrains Mono'"), "JetBrains Mono"); - } - - #[test] - fn normalizes_legacy_default_to_nerd_font_default() { - assert_eq!( - normalize_terminal_font_family( - "\"JetBrains Mono\", \"Cascadia Mono\", \"SF Mono\", Menlo, Monaco, Consolas, \"Liberation Mono\", monospace" - ), - DEFAULT_TERMINAL_FONT_FAMILY - ); - } - - #[test] - fn keeps_current_default_terminal_font_stack() { - assert_eq!( - normalize_terminal_font_family(DEFAULT_TERMINAL_FONT_FAMILY), - DEFAULT_TERMINAL_FONT_FAMILY - ); - } - - #[test] - fn keeps_custom_jetbrains_mono_stack_without_forcing_nerd_font() { - assert_eq!( - normalize_terminal_font_family("\"JetBrains Mono\", monospace"), - "\"JetBrains Mono\", monospace" - ); - } -} +pub use nyaterm_core::utils::fonts::*; diff --git a/src-tauri/src/utils/fuzzy.rs b/src-tauri/src/utils/fuzzy.rs index 992461cc..83851f77 100644 --- a/src-tauri/src/utils/fuzzy.rs +++ b/src-tauri/src/utils/fuzzy.rs @@ -1,224 +1 @@ -//! Fuzzy search using nucleo-matcher. - -use nucleo_matcher::pattern::{AtomKind, CaseMatching, Normalization, Pattern}; -use nucleo_matcher::{Config, Matcher, Utf32Str}; -use serde::{Deserialize, Serialize}; - -/// Single fuzzy match with score and highlighted character indices. -#[derive(Debug, Clone, Serialize)] -pub struct FuzzyResult { - pub command: String, - pub score: u32, - pub indices: Vec, - /// Provider tag: "history", "quickCommand", etc. - pub source: String, - /// Text shown in the suggestion panel (may differ from `command`). - pub display: String, -} - -/// Candidate passed from the frontend for stateless fuzzy scoring. -#[derive(Debug, Clone, Deserialize)] -pub struct FuzzySearchCandidate { - pub id: String, - pub value: String, - pub display: String, -} - -/// Generic frontend candidate match with stable id and value. -#[derive(Debug, Clone, Serialize)] -pub struct FuzzyCandidateResult { - pub id: String, - pub value: String, - pub display: String, - pub score: u32, - pub indices: Vec, -} - -/// Generic fuzzy search over `(display_text, value)` pairs. -/// -/// Matches against `display_text` (shown in the suggestion panel). -/// `value` is stored as `command` in the result (used when filling/executing). -/// `source` tags every result so the frontend can distinguish providers. -pub fn fuzzy_search_items( - items: &[(&str, &str)], - pattern_str: &str, - source: &str, - limit: usize, - min_command_length: Option, - max_command_length: Option, -) -> Vec { - let pattern_str = pattern_str.trim(); - if pattern_str.is_empty() { - return Vec::new(); - } - - let pattern = Pattern::new( - pattern_str, - CaseMatching::Smart, - Normalization::Smart, - AtomKind::Fuzzy, - ); - - if pattern.atoms.is_empty() { - return Vec::new(); - } - - let mut matcher = Matcher::new(Config::DEFAULT.match_paths()); - let mut buf = Vec::new(); - - let mut scored: Vec<(usize, u32)> = Vec::new(); - for (idx, (display, value)) in items.iter().enumerate() { - let value_length = value.chars().count(); - - if min_command_length.is_some_and(|min| value_length < min) { - continue; - } - - if max_command_length.is_some_and(|max| value_length > max) { - continue; - } - - let haystack = Utf32Str::new(display, &mut buf); - if let Some(score) = pattern.score(haystack, &mut matcher) { - scored.push((idx, score)); - } - } - - scored.sort_by(|a, b| b.1.cmp(&a.1).then(b.0.cmp(&a.0))); - scored.truncate(limit); - - let mut results = Vec::with_capacity(scored.len()); - for (idx, score) in scored { - let (display, value) = &items[idx]; - let haystack = Utf32Str::new(display, &mut buf); - let mut indices = Vec::new(); - pattern.indices(haystack, &mut matcher, &mut indices); - indices.sort_unstable(); - indices.dedup(); - - results.push(FuzzyResult { - command: (*value).to_string(), - score, - indices, - source: source.to_string(), - display: (*display).to_string(), - }); - } - - results -} - -/// Generic fuzzy search over frontend-provided candidates. -/// -/// This keeps business state on the frontend while sharing the Rust fuzzy scorer. -pub fn fuzzy_search_candidates( - items: &[FuzzySearchCandidate], - pattern_str: &str, - limit: usize, -) -> Vec { - let pattern_str = pattern_str.trim(); - if pattern_str.is_empty() { - return Vec::new(); - } - - let pattern = Pattern::new( - pattern_str, - CaseMatching::Smart, - Normalization::Smart, - AtomKind::Fuzzy, - ); - - if pattern.atoms.is_empty() { - return Vec::new(); - } - - let mut matcher = Matcher::new(Config::DEFAULT.match_paths()); - let mut buf = Vec::new(); - - let mut scored: Vec<(usize, u32)> = Vec::new(); - for (idx, item) in items.iter().enumerate() { - let haystack = Utf32Str::new(&item.display, &mut buf); - if let Some(score) = pattern.score(haystack, &mut matcher) { - scored.push((idx, score)); - } - } - - scored.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0))); - scored.truncate(limit); - - let mut results = Vec::with_capacity(scored.len()); - for (idx, score) in scored { - let item = &items[idx]; - let haystack = Utf32Str::new(&item.display, &mut buf); - let mut indices = Vec::new(); - pattern.indices(haystack, &mut matcher, &mut indices); - indices.sort_unstable(); - indices.dedup(); - - results.push(FuzzyCandidateResult { - id: item.id.clone(), - value: item.value.clone(), - display: item.display.clone(), - score, - indices, - }); - } - - results -} - -#[cfg(test)] -mod tests { - use super::{FuzzySearchCandidate, fuzzy_search_candidates}; - - fn candidate(id: &str, display: &str) -> FuzzySearchCandidate { - FuzzySearchCandidate { - id: id.to_string(), - value: id.to_string(), - display: display.to_string(), - } - } - - #[test] - fn fuzzy_candidates_return_empty_for_blank_pattern() { - let items = [candidate("session:1", "production ssh")]; - - let results = fuzzy_search_candidates(&items, " ", 10); - - assert!(results.is_empty()); - } - - #[test] - fn fuzzy_candidates_respect_limit_and_return_stable_ids() { - let items = [ - candidate("session:1", "production ssh root@10.0.0.1"), - candidate("connection:1", "production database postgres"), - candidate("session:2", "local shell"), - ]; - - let results = fuzzy_search_candidates(&items, "prod", 1); - - assert_eq!(results.len(), 1); - assert!(matches!( - results[0].id.as_str(), - "session:1" | "connection:1" - )); - assert_eq!(results[0].value, results[0].id); - } - - #[test] - fn fuzzy_candidates_sort_by_score_then_original_order() { - let items = [ - candidate("first", "docker image"), - candidate("second", "docker image"), - candidate("third", "documentation index"), - ]; - - let results = fuzzy_search_candidates(&items, "docker image", 10); - - assert!(results.len() >= 2); - assert_eq!(results[0].id, "first"); - assert_eq!(results[1].id, "second"); - assert!(results[0].score >= results[1].score); - } -} +pub use nyaterm_core::utils::fuzzy::*; diff --git a/src-tauri/src/utils/url.rs b/src-tauri/src/utils/url.rs index 387a870e..76f66efe 100644 --- a/src-tauri/src/utils/url.rs +++ b/src-tauri/src/utils/url.rs @@ -1,81 +1 @@ -use reqwest::Url; - -use crate::error::{AppError, AppResult}; - -pub fn normalize_api_base_url(value: &str) -> AppResult { - let trimmed = value.trim(); - if trimmed.is_empty() { - return Ok(String::new()); - } - - let mut url = Url::parse(trimmed) - .map_err(|err| AppError::Config(format!("Invalid API base URL '{trimmed}': {err}")))?; - if !url.path().ends_with('/') { - url.set_path(&format!("{}/", url.path())); - } - Ok(url.to_string()) -} - -pub fn join_api_base_url(base_url: &str, suffix: &str) -> AppResult { - let normalized = normalize_api_base_url(base_url)?; - let url = Url::parse(&normalized).map_err(|err| { - AppError::Config(format!( - "Invalid API base URL '{}': {err}", - normalized.as_str() - )) - })?; - let original_query = url.query().map(str::to_string); - let mut full_url = url.join(suffix.trim_start_matches('/')).map_err(|err| { - AppError::Config(format!( - "Failed to join API path '{}' to '{}': {err}", - suffix, normalized - )) - })?; - full_url.set_query(original_query.as_deref()); - Ok(full_url.to_string()) -} - -pub fn normalize_storage_endpoint(value: &str) -> String { - value.trim().trim_end_matches('/').to_string() -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn api_base_url_is_directory_url() { - assert_eq!( - normalize_api_base_url("https://api.example.com/v1").unwrap(), - "https://api.example.com/v1/" - ); - assert_eq!( - normalize_api_base_url("https://api.example.com/v1/").unwrap(), - "https://api.example.com/v1/" - ); - } - - #[test] - fn api_base_url_preserves_query() { - assert_eq!( - normalize_api_base_url(" https://api.example.com/v1?api-version=1 ").unwrap(), - "https://api.example.com/v1/?api-version=1" - ); - } - - #[test] - fn api_base_url_join_preserves_query() { - assert_eq!( - join_api_base_url("https://api.example.com/v1?api-version=1", "models").unwrap(), - "https://api.example.com/v1/models?api-version=1" - ); - } - - #[test] - fn storage_endpoint_trims_whitespace_and_trailing_slashes() { - assert_eq!( - normalize_storage_endpoint(" https://dav.example.com/remote.php/webdav// "), - "https://dav.example.com/remote.php/webdav" - ); - } -} +pub use nyaterm_core::utils::url::*; diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 298a37d4..bbcdaa68 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -10,6 +10,7 @@ "frontendDist": "../dist" }, "app": { + "macOSPrivateApi": true, "windows": [ { "label": "main", diff --git a/src-tauri/vendor/russh-sftp/src/client/session.rs b/src-tauri/vendor/russh-sftp/src/client/session.rs index 77487520..8f2cf48c 100644 --- a/src-tauri/vendor/russh-sftp/src/client/session.rs +++ b/src-tauri/vendor/russh-sftp/src/client/session.rs @@ -2,10 +2,10 @@ use std::sync::Arc; use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}; use super::{ + RawSftpSession, error::Error, fs::{File, Metadata, ReadDir}, rawsession::{Limits, SftpResult}, - RawSftpSession, }; use crate::{ client::Config, @@ -349,6 +349,29 @@ impl SftpSession { self.session.rename(oldpath, newpath).await.map(|_| ()) } + /// Rename a file or directory using raw bytes for both paths. + /// Atomically replace a destination via the OpenSSH POSIX rename extension. + /// Unsupported servers return an error without removing the destination. + pub async fn rename_replace(&self, oldpath: &str, newpath: &str) -> SftpResult<()> { + let mut data = Vec::new(); + for path in [oldpath, newpath] { + let len = u32::try_from(path.len()).map_err(|_| Error::UnexpectedPacket)?; + data.extend_from_slice(&len.to_be_bytes()); + data.extend_from_slice(path.as_bytes()); + } + match self + .session + .extended("posix-rename@openssh.com", data) + .await? + { + crate::protocol::Packet::Status(status) if status.status_code == StatusCode::Ok => { + Ok(()) + } + crate::protocol::Packet::Status(status) => Err(Error::Status(status)), + _ => Err(Error::UnexpectedPacket), + } + } + /// Rename a file or directory using raw bytes for both paths. pub async fn rename_bytes( &self, diff --git a/src/App.tsx b/src/App.tsx index 831fa29d..e187dd85 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,4 +1,5 @@ -import { listen } from "@tauri-apps/api/event"; +import { supports } from "@/lib/backend/runtime"; +import { listen } from "@/lib/backend/api"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { toast } from "sonner"; @@ -222,7 +223,8 @@ function App() { } = useApp(); const uiConfig = appSettings.ui; const portable = runtimeInfo.portable; - const remoteStatsEnabled = uiConfig.show_remote_stats ?? true; + const remoteStatsEnabled = + supports("remoteMonitoring") && (uiConfig.show_remote_stats ?? true); const updateAutoIconForSessionStart = useCallback( (connectionId: string | null | undefined, sessionId: string) => { void updateConnectionAutoIconAfterSessionStart({ @@ -239,7 +241,7 @@ function App() { useEffect(() => { if (!settingsLoaded) return; - import("@tauri-apps/api/window").then(({ getCurrentWindow }) => { + import("@/lib/backend/platform/window").then(({ getCurrentWindow }) => { const currentWindow = getCurrentWindow(); setOwnerMainWindowLabel(currentWindow.label); currentWindow.show(); @@ -377,7 +379,13 @@ function App() { setFloatingPanels(next); if (lastFloatingSide !== side) return; setLastFloatingSide( - side === "left" ? (next.right ? "right" : null) : next.left ? "left" : null, + side === "left" + ? next.right + ? "right" + : null + : next.left + ? "left" + : null, ); }, [floatingPanels, lastFloatingSide], @@ -548,17 +556,25 @@ function App() { let disposed = false; let unlistenResize: (() => void) | undefined; let unlistenFocus: (() => void) | undefined; - void import("@tauri-apps/api/window").then(async ({ getCurrentWindow }) => { - if (disposed) return; - const currentWindow = getCurrentWindow(); - const syncFullscreen = async () => { - const fullscreen = await currentWindow.isFullscreen().catch(() => false); - if (!disposed) setNativeFullscreen(fullscreen); - }; - await syncFullscreen(); - unlistenResize = await currentWindow.onResized(() => void syncFullscreen()); - unlistenFocus = await currentWindow.onFocusChanged(() => void syncFullscreen()); - }); + void import("@/lib/backend/platform/window").then( + async ({ getCurrentWindow }) => { + if (disposed) return; + const currentWindow = getCurrentWindow(); + const syncFullscreen = async () => { + const fullscreen = await currentWindow + .isFullscreen() + .catch(() => false); + if (!disposed) setNativeFullscreen(fullscreen); + }; + await syncFullscreen(); + unlistenResize = await currentWindow.onResized( + () => void syncFullscreen(), + ); + unlistenFocus = await currentWindow.onFocusChanged( + () => void syncFullscreen(), + ); + }, + ); return () => { disposed = true; unlistenResize?.(); @@ -575,7 +591,7 @@ function App() { useEffect(() => { let cancelled = false; - import("@tauri-apps/api/window") + import("@/lib/backend/platform/window") .then(({ getCurrentWindow }) => { if (cancelled) return; return getCurrentWindow().setTitle(windowTitle); @@ -624,7 +640,10 @@ function App() { failureContext?: string; runtimeModeOverride?: SshRuntimeMode; propagateError?: boolean; - onPending?: (pending: { tabId: string; createRequestId: string }) => void; + onPending?: (pending: { + tabId: string; + createRequestId: string; + }) => void; onSuccess?: (sessionId: string) => void; }, ) => { @@ -738,52 +757,72 @@ function App() { let unlistenOpen: (() => void) | undefined; let unlistenCancel: (() => void) | undefined; - void listen("mcp-session-open-request", ({ payload }) => { - if (disposed || !eventTargetsCurrentWindow(payload.targetWindowLabel)) return; - void (async () => { - const connections = savedConnections.some((item) => item.id === payload.connectionId) - ? savedConnections - : await invoke("get_saved_connections"); - if (cancelledMcpSessionOpenRequestsRef.current.delete(payload.requestId)) return; - const connection = connections.find((item) => item.id === payload.connectionId); - if (!connection || connection.type === "rdp" || connection.type === "vnc") { + void listen( + "mcp-session-open-request", + ({ payload }) => { + if (disposed || !eventTargetsCurrentWindow(payload.targetWindowLabel)) + return; + void (async () => { + const connections = savedConnections.some( + (item) => item.id === payload.connectionId, + ) + ? savedConnections + : await invoke("get_saved_connections"); + if ( + cancelledMcpSessionOpenRequestsRef.current.delete(payload.requestId) + ) + return; + const connection = connections.find( + (item) => item.id === payload.connectionId, + ); + if ( + !connection || + connection.type === "rdp" || + connection.type === "vnc" + ) { + await invoke("respond_mcp_session_open", { + requestId: payload.requestId, + sessionId: null, + error: + "The saved connection does not exist or is not a supported terminal connection.", + }); + return; + } + + let openedSessionId: string | null = null; + await connectSavedConnection(connection, { + failureContext: "MCP session open failed", + propagateError: true, + onPending: (pending) => { + mcpSessionOpenRequestsRef.current.set(payload.requestId, pending); + }, + onSuccess: (sessionId) => { + openedSessionId = sessionId; + }, + }); await invoke("respond_mcp_session_open", { requestId: payload.requestId, - sessionId: null, - error: "The saved connection does not exist or is not a supported terminal connection.", + sessionId: openedSessionId, + error: openedSessionId + ? null + : "The MCP session-open request did not create a session.", }); - return; - } - - let openedSessionId: string | null = null; - await connectSavedConnection(connection, { - failureContext: "MCP session open failed", - propagateError: true, - onPending: (pending) => { - mcpSessionOpenRequestsRef.current.set(payload.requestId, pending); - }, - onSuccess: (sessionId) => { - openedSessionId = sessionId; - }, - }); - await invoke("respond_mcp_session_open", { - requestId: payload.requestId, - sessionId: openedSessionId, - error: openedSessionId ? null : "The MCP session-open request did not create a session.", - }); - })() - .catch((error) => { - void invoke("respond_mcp_session_open", { - requestId: payload.requestId, - sessionId: null, - error: getErrorMessage(error), - }).catch(() => {}); - }) - .finally(() => { - mcpSessionOpenRequestsRef.current.delete(payload.requestId); - cancelledMcpSessionOpenRequestsRef.current.delete(payload.requestId); - }); - }).then((dispose) => { + })() + .catch((error) => { + void invoke("respond_mcp_session_open", { + requestId: payload.requestId, + sessionId: null, + error: getErrorMessage(error), + }).catch(() => {}); + }) + .finally(() => { + mcpSessionOpenRequestsRef.current.delete(payload.requestId); + cancelledMcpSessionOpenRequestsRef.current.delete( + payload.requestId, + ); + }); + }, + ).then((dispose) => { if (disposed) dispose(); else unlistenOpen = dispose; }); @@ -1424,7 +1463,7 @@ function App() { : sendSessionInput(sessionId, data, options); void sendInput.catch(() => {}); - import("@tauri-apps/api/event").then(({ emit }) => { + import("@/lib/backend/api").then(({ emit }) => { emit(`focus-terminal-${sessionId}`); }); }, @@ -1659,24 +1698,27 @@ function App() { const handleQuitApplication = useCallback(() => { setShowQuitConfirm(false); - void requestFileDocumentClose(collectFileDocumentPaneIds(tabs), async () => { - await persistWorkspaceLayoutNow().catch((error) => { - logger.error({ - domain: "settings.persistence", - event: "workspace_layout.persist_before_quit_failed", - message: "Failed to persist workspace layout before quit", - error, + void requestFileDocumentClose( + collectFileDocumentPaneIds(tabs), + async () => { + await persistWorkspaceLayoutNow().catch((error) => { + logger.error({ + domain: "settings.persistence", + event: "workspace_layout.persist_before_quit_failed", + message: "Failed to persist workspace layout before quit", + error, + }); }); - }); - await invoke("quit_application"); - }); + await invoke("quit_application"); + }, + ); }, [persistWorkspaceLayoutNow, requestFileDocumentClose, tabs]); useEffect(() => { if (!settingsLoaded) return; let unlistenCloseRequested: (() => void) | undefined; - import("@tauri-apps/api/window") + import("@/lib/backend/platform/window") .then(({ getCurrentWindow }) => { const currentWindow = getCurrentWindow(); return currentWindow.onCloseRequested(async (event) => { @@ -1702,23 +1744,26 @@ function App() { return; } - await requestFileDocumentClose(collectFileDocumentPaneIds(tabs), async () => { - await persistWorkspaceLayoutNow().catch((error) => { - logger.error({ - domain: "settings.persistence", - event: "workspace_layout.persist_before_close_failed", - message: "Failed to persist workspace layout before close", - error, + await requestFileDocumentClose( + collectFileDocumentPaneIds(tabs), + async () => { + await persistWorkspaceLayoutNow().catch((error) => { + logger.error({ + domain: "settings.persistence", + event: "workspace_layout.persist_before_close_failed", + message: "Failed to persist workspace layout before close", + error, + }); }); - }); - allowProgrammaticWindowCloseRef.current = true; - await currentWindow.close().catch(() => { - allowProgrammaticWindowCloseRef.current = false; - }); - window.setTimeout(() => { - allowProgrammaticWindowCloseRef.current = false; - }, 1000); - }); + allowProgrammaticWindowCloseRef.current = true; + await currentWindow.close().catch(() => { + allowProgrammaticWindowCloseRef.current = false; + }); + window.setTimeout(() => { + allowProgrammaticWindowCloseRef.current = false; + }, 1000); + }, + ); }); }) .then((unlisten) => { @@ -1757,7 +1802,7 @@ function App() { return; } - void import("@tauri-apps/api/window").then(({ getCurrentWindow }) => + void import("@/lib/backend/platform/window").then(({ getCurrentWindow }) => getCurrentWindow().close(), ); }, [appSettings.general.confirm_on_close, appSettings.general.minimize_to_tray, tabs.length]); @@ -2072,7 +2117,7 @@ function App() { toast.info(t("tabCtx.reconnecting")); try { - if (pane.connectionId) { + if (supports("nativeWindows") && pane.connectionId) { await invoke("mark_tunnels_reconnecting_for_connection", { connectionId: pane.connectionId, }).catch(() => {}); @@ -2106,7 +2151,7 @@ function App() { return; } const errorMessage = getErrorMessage(error); - if (pane.connectionId) { + if (supports("nativeWindows") && pane.connectionId) { await invoke("mark_tunnels_disconnected_for_connection", { connectionId: pane.connectionId, }).catch(() => {}); @@ -2172,7 +2217,7 @@ function App() { toast.info(t("tabCtx.reconnecting")); try { - if (pane.connectionId) { + if (supports("nativeWindows") && pane.connectionId) { await invoke("mark_tunnels_reconnecting_for_connection", { connectionId: pane.connectionId, }).catch(() => {}); @@ -2206,7 +2251,7 @@ function App() { return; } const errorMessage = getErrorMessage(error); - if (pane.connectionId) { + if (supports("nativeWindows") && pane.connectionId) { await invoke("mark_tunnels_disconnected_for_connection", { connectionId: pane.connectionId, }).catch(() => {}); @@ -2355,7 +2400,7 @@ function App() { } try { - if (pane.connectionId) { + if (supports("nativeWindows") && pane.connectionId) { await invoke("mark_tunnels_reconnecting_for_connection", { connectionId: pane.connectionId, }).catch(() => {}); @@ -2388,7 +2433,7 @@ function App() { return; } const errorMessage = getErrorMessage(error); - if (pane.connectionId) { + if (supports("nativeWindows") && pane.connectionId) { await invoke("mark_tunnels_disconnected_for_connection", { connectionId: pane.connectionId, }).catch(() => {}); @@ -2601,12 +2646,14 @@ function App() { }, [activePane]); const handleToggleNativeFullscreen = useCallback(() => { - void import("@tauri-apps/api/window").then(async ({ getCurrentWindow }) => { - const currentWindow = getCurrentWindow(); - const fullscreen = await currentWindow.isFullscreen(); - await currentWindow.setFullscreen(!fullscreen); - setNativeFullscreen(!fullscreen); - }); + void import("@/lib/backend/platform/window").then( + async ({ getCurrentWindow }) => { + const currentWindow = getCurrentWindow(); + const fullscreen = await currentWindow.isFullscreen(); + await currentWindow.setFullscreen(!fullscreen); + setNativeFullscreen(!fullscreen); + }, + ); }, []); useGlobalShortcuts( @@ -3222,16 +3269,19 @@ function App() { }); }, []); - const handleExternalMatchConnection = useCallback((connection: SavedConnection) => { - setExternalMatchDialog((current) => { - current?.resolve({ - kind: "saved", - connection, - runtimeModeOverride: current.runtimeModeOverride, + const handleExternalMatchConnection = useCallback( + (connection: SavedConnection) => { + setExternalMatchDialog((current) => { + current?.resolve({ + kind: "saved", + connection, + runtimeModeOverride: current.runtimeModeOverride, + }); + return null; }); - return null; - }); - }, []); + }, + [], + ); const handleExternalMatchTemporary = useCallback((config: TemporaryLinkConfig) => { setExternalMatchDialog((current) => { diff --git a/src/ChildWindowRouter.tsx b/src/ChildWindowRouter.tsx index 3167d708..5ac20325 100644 --- a/src/ChildWindowRouter.tsx +++ b/src/ChildWindowRouter.tsx @@ -1,5 +1,5 @@ -import { emit } from "@tauri-apps/api/event"; -import { getCurrentWindow } from "@tauri-apps/api/window"; +import { emit } from "@/lib/backend/api"; +import { getCurrentWindow } from "@/lib/backend/platform/window"; import { lazy, type ReactNode, Suspense, useEffect } from "react"; import { useTranslation } from "react-i18next"; import { @@ -43,10 +43,16 @@ function ChildWindowLoadingShell() { } function ReadyContent({ children }: { children: ReactNode }) { - return
{children}
; + return ( +
{children}
+ ); } -export default function ChildWindowRouter({ windowType }: { windowType: string }) { +export default function ChildWindowRouter({ + windowType, +}: { + windowType: string; +}) { const { t } = useTranslation(); const Page = PAGES[windowType]; diff --git a/src/components/dialog/app/AboutDialog.tsx b/src/components/dialog/app/AboutDialog.tsx index c63a6d46..52757e59 100644 --- a/src/components/dialog/app/AboutDialog.tsx +++ b/src/components/dialog/app/AboutDialog.tsx @@ -1,5 +1,5 @@ -import { getName, getVersion } from "@tauri-apps/api/app"; -import { openUrl } from "@tauri-apps/plugin-opener"; +import { getName, getVersion } from "@/lib/backend/platform/app"; +import { openUrl } from "@/lib/backend/platform/opener"; import { Check, Copy } from "lucide-react"; import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; @@ -87,7 +87,7 @@ export default function AboutDialog({ open, onClose }: AboutDialogProps) { : conpty.lastUsed ? t("about.conptyLastUsed", { source: conpty.lastUsed === "bundled" ? bundledLabel : systemLabel, - }) + }) : t("about.conptyNotStarted"); const packageManagerDisplay = supportInfo?.packageManager @@ -100,7 +100,11 @@ export default function AboutDialog({ open, onClose }: AboutDialogProps) { } const runtimeLabel = - supportInfo.runtime === "portable" ? t("about.portable") : t("about.installed"); + supportInfo.runtime === "web" + ? "Web" + : supportInfo.runtime === "portable" + ? t("about.portable") + : t("about.installed"); const text = [ "NyaTerm Support Information", `Version: ${appVersion}`, @@ -128,9 +132,11 @@ export default function AboutDialog({ open, onClose }: AboutDialogProps) { const architectureDisplay = supportInfo?.architecture ?? (supportInfoFailed ? t("about.unknown") : t("common.loading")); const runtimeDisplay = supportInfo - ? supportInfo.runtime === "portable" - ? t("about.portable") - : t("about.installed") + ? supportInfo.runtime === "web" + ? "Web" + : supportInfo.runtime === "portable" + ? t("about.portable") + : t("about.installed") : supportInfoFailed ? t("about.unknown") : t("common.loading"); 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/app/LockScreen.tsx b/src/components/dialog/app/LockScreen.tsx index 0dfbf26a..d99105eb 100644 --- a/src/components/dialog/app/LockScreen.tsx +++ b/src/components/dialog/app/LockScreen.tsx @@ -1,4 +1,4 @@ -import { getCurrentWindow } from "@tauri-apps/api/window"; +import { getCurrentWindow } from "@/lib/backend/platform/window"; import { useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { MdLock, MdLockOpen } from "react-icons/md"; @@ -205,7 +205,11 @@ export default function LockScreen({ hasPassword, onUnlock, onRequestClose }: Lo autoComplete="off" disabled={verifying} /> - {error &&

{t("lockScreen.wrongPassword")}

} + {error && ( +

+ {t("lockScreen.wrongPassword")} +

+ )} )} diff --git a/src/components/dialog/app/McpApprovalHost.tsx b/src/components/dialog/app/McpApprovalHost.tsx index e7149249..c8e4204e 100644 --- a/src/components/dialog/app/McpApprovalHost.tsx +++ b/src/components/dialog/app/McpApprovalHost.tsx @@ -1,4 +1,4 @@ -import { listen } from "@tauri-apps/api/event"; +import { listen } from "@/lib/backend/api"; import { useCallback, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { toast } from "sonner"; diff --git a/src/components/dialog/app/UpdateDialog.tsx b/src/components/dialog/app/UpdateDialog.tsx index d1f2a4b8..1c718de4 100644 --- a/src/components/dialog/app/UpdateDialog.tsx +++ b/src/components/dialog/app/UpdateDialog.tsx @@ -1,6 +1,12 @@ -import { getVersion } from "@tauri-apps/api/app"; -import { openUrl } from "@tauri-apps/plugin-opener"; -import { type ReactNode, useCallback, useEffect, useRef, useState } from "react"; +import { getVersion } from "@/lib/backend/platform/app"; +import { openUrl } from "@/lib/backend/platform/opener"; +import { + type ReactNode, + useCallback, + useEffect, + useRef, + useState, +} from "react"; import { useTranslation } from "react-i18next"; import { MdCheckCircle, MdError, MdRestartAlt } from "react-icons/md"; import ReactMarkdown from "react-markdown"; @@ -78,7 +84,9 @@ function MarkdownContent({ content }: { content: string }) { {children} ), - li: ({ children }: MarkdownNodeProps) =>
  • {children}
  • , + li: ({ children }: MarkdownNodeProps) => ( +
  • {children}
  • + ), hr: () =>
    , a: ({ children, href }: MarkdownNodeProps) => ( @@ -332,7 +381,7 @@ export default function ImportDialog({ open, onClose }: ImportDialogProps) { }} > (null); const [loading, setLoading] = useState(true); @@ -106,15 +103,12 @@ export default function PropertiesDialog({ const [symlinkTarget, setSymlinkTarget] = useState(""); const [recursive, setRecursive] = useState(false); const [isSaving, setIsSaving] = useState(false); - const initialOctal = properties - ? parsePermissionsToOctal(properties.permissions) - : "0644"; + const initialOctal = properties ? parsePermissionsToOctal(properties.permissions) : "0644"; const initialOwner = properties?.owner || properties?.uid || ""; const initialGroup = properties?.group || properties?.gid || ""; const initialSymlinkTarget = properties?.symlink_target ?? ""; const canEditAttributes = data.backend === "remote"; - const canEditSymlinkTarget = - data.backend === "remote" && properties?.is_symlink === true; + const canEditSymlinkTarget = data.backend === "remote" && properties?.is_symlink === true; useEffect(() => { let isMounted = true; @@ -173,8 +167,7 @@ export default function PropertiesDialog({ group: nextGroup !== initialGroup ? nextGroup : null, recursive: data.is_dir && recursive, }; - const symlinkTargetChanged = - canEditSymlinkTarget && symlinkTarget !== initialSymlinkTarget; + const symlinkTargetChanged = canEditSymlinkTarget && symlinkTarget !== initialSymlinkTarget; const attributesChanged = !!(update.mode || update.owner || update.group); if (!attributesChanged && !symlinkTargetChanged) { @@ -202,9 +195,7 @@ export default function PropertiesDialog({ } toast.success( t( - symlinkTargetChanged - ? "fileExplorer.symlinkTargetSaved" - : "fileExplorer.propertiesSaved", + symlinkTargetChanged ? "fileExplorer.symlinkTargetSaved" : "fileExplorer.propertiesSaved", ), ); await onSuccess?.(); @@ -247,29 +238,19 @@ export default function PropertiesDialog({ }; return ( - !v && !isSaving && onClose()} - > + !v && !isSaving && onClose()}> {data.is_dir ? ( - + ) : ( )} - + {t("fileExplorer.propertiesOf", { name: data.name })} @@ -307,10 +288,7 @@ export default function PropertiesDialog({ key: "location", label: t("fileExplorer.location"), value: ( - + {getLocation()} ), @@ -326,9 +304,7 @@ export default function PropertiesDialog({ className="h-8 min-w-0 w-full font-mono text-xs" value={symlinkTarget} disabled={isSaving} - onChange={(event) => - setSymlinkTarget(event.target.value) - } + onChange={(event) => setSymlinkTarget(event.target.value)} /> ), }, @@ -342,20 +318,12 @@ export default function PropertiesDialog({ { key: "mtime", label: t("fileExplorer.mtime"), - value: ( - - {formatTime(properties.mtime)} - - ), + value: {formatTime(properties.mtime)}, }, { key: "atime", label: t("fileExplorer.atime"), - value: ( - - {formatTime(properties.atime)} - - ), + value: {formatTime(properties.atime)}, }, { key: "owner", @@ -364,9 +332,7 @@ export default function PropertiesDialog({ {properties.owner || "-"}{" "} {properties.uid && ( - - [{properties.uid}] - + [{properties.uid}] )} ), @@ -378,21 +344,15 @@ export default function PropertiesDialog({ {properties.group || "-"}{" "} {properties.gid && ( - - [{properties.gid}] - + [{properties.gid}] )} ), }, ].map((row) => (
    - - {row.label}: - - - {row.value} - + {row.label}: + {row.value}
    ))} @@ -408,9 +368,7 @@ export default function PropertiesDialog({