外部 CLI 新增 exec --terminal:在已打开的终端里执行命令
只允许登录一次的堡垒机(外部连接)上,exec 重新登录会被拒绝,另开通道又会结束会话。 --terminal 把命令打进这台主机在 ShellRS 里已打开的远程终端:先发 Ctrl-C,探测出 交互 shell(bash、dash、busybox ash)后输入命令行,经 OSC 标记取回输出、退出码和 执行上下文(--json 的 context)。不做忙碌检测,只拒绝全屏程序和同一终端已有一次运行。 外部连接上普通 exec 登录失败时,说明里提示改用 --terminal。 shells.yml 在 Docker 里对 bash 4.2–5.2、dash、busybox ash 跑真实终端测试。 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
3c937a9be2
commit
e09adeefb7
22 files changed
+2432
-45
No files matched your search
@@ -0,0 +1,47 @@
|
||||
# `shellrs exec --terminal` against the shells it types into: bash 4.2 to
|
||||
# 5.2, dash, busybox ash and a bash started as sh, each in a container on a
|
||||
# real terminal (`terminal::run::tests::shells`). The Macs this is
|
||||
# developed on run it only with Docker Desktop up.
|
||||
#
|
||||
# A push runs it when the run's code, the test helpers or the dependencies
|
||||
# change; run it by hand otherwise (Actions › shells › Run workflow).
|
||||
name: shells
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["**"]
|
||||
paths:
|
||||
- "src/terminal/**"
|
||||
- "src/testing.rs"
|
||||
- "Cargo.toml"
|
||||
- "Cargo.lock"
|
||||
- "rust-toolchain.toml"
|
||||
- ".github/workflows/shells.yml"
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
shells:
|
||||
runs-on: ubuntu-22.04
|
||||
timeout-minutes: 90
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Build dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y cmake clang pkg-config libfontconfig-dev \
|
||||
libfreetype-dev libxkbcommon-dev libxkbcommon-x11-dev libwayland-dev \
|
||||
libx11-xcb-dev libxcb1-dev libvulkan-dev libdbus-1-dev libssl-dev
|
||||
# Pulled first, so that no test waits on a download.
|
||||
- name: Images
|
||||
run: |
|
||||
for image in bash:4.2 bash:4.4 bash:5.0 bash:5.1 bash:5.2 \
|
||||
debian:stable-slim alpine:3 rockylinux:9; do
|
||||
docker pull --quiet "$image"
|
||||
done
|
||||
# rust-toolchain.toml picks the toolchain; this installs it with the
|
||||
# components it lists before the cache key is computed.
|
||||
- run: rustup toolchain install
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- run: cargo test --lib terminal::run -- --include-ignored
|
||||
@@ -41,6 +41,7 @@ ShellRS(crate 与二进制都叫 `shellrs`)是 Xshell / WinSCP 式的 SSH
|
||||
- 秘密只写不读:JSON 里的 `password` / `passphrase` / `private_key` 进钥匙串或 `keys/`,任何输出都没有,`show` 只说有没有保存,ShellRS 保存的私钥连路径也不给。
|
||||
- 删除不确认:`hosts delete` 遇到有打开标签的主机报 `host_in_use`,`--force` 才关标签;删凭据时用它的主机改回自己登录。临时连接和外部连接只能 `show`。
|
||||
- `sync` 只从本机到主机,大小和修改时间都没变的跳过;`--delete` 不删 `.filepart` 和 `.shellrs-….backup`,本地读不了的目录不清理,拒绝同步到 `/`。
|
||||
- `exec --terminal`(`exec --json` 里 `"terminal": true`):给只允许登录一次的堡垒机(外部连接上 exec 重新登录会被拒,另开通道又会断会话)。把命令打进这台主机已打开的远程终端(右侧栏跟的那个,否则最新已连接的),所有远程终端都能用,不限外部连接。先发 ^C,**不做忙碌检测**(用户定的:用的人有基础,交给 Agent 的标签由用户负责;别加回光标行、密码提示之类的检查),只拒绝备用屏和同一终端已有一次运行。只支持 Linux 上的 bash、dash、busybox ash。输出经管道(没有颜色和分页器)、stderr 并入;`--json` 多一项 `context`(user、host、cwd、shell、interp)。
|
||||
- **开始页**:没有标签时显示「最近连接」。「快速连接」搜索保存的主机、回车连接;多选做好但关着(常量 `MULTIPLE`)。
|
||||
- **右侧栏**:窗口最右一列竖排的工具切换。
|
||||
- 显示规则:只跟着 SSH 远程终端出现,SFTP、本地终端、设置、开始页都不显示。它是工作区级的一份,开合和所选工具在所有终端间共用。默认收起,宽 320 px,只能往宽里拉。⌘⌥B / Ctrl+Alt+B 切换。
|
||||
@@ -97,6 +98,7 @@ cargo test --lib # 只跑单元测试
|
||||
cargo test --test ui # 只跑 UI 集成测试
|
||||
cargo test --test ui new_host -- --nocapture # 按名称片段跑单个 UI 测试
|
||||
cargo test --test cli_bin # 以子进程运行 shellrs-cli 的端到端测试(Windows CI 也跑)
|
||||
cargo test --lib terminal::run -- --include-ignored # exec --terminal 对 Docker 里的真实 shell(CI:shells.yml)
|
||||
cargo test --lib update:: # 在线升级;装了 minisign 和 jq 时还验证发布脚本签出的清单客户端认
|
||||
cargo clippy --all-targets -- -D warnings # 必须无警告
|
||||
cargo fmt --check
|
||||
@@ -324,6 +326,12 @@ cargo test -- --ignored # 会读写真实钥匙串的测试
|
||||
- 协议只加变体、不升 `PROTOCOL_VERSION`:旧应用对新请求回 serde 的「unknown variant」,客户端改说成 `version_mismatch`。服务端先读版本再解析请求。
|
||||
- `exec --json` 的输入输出都是 JSON,输出只用 ASCII(`ascii_json` 转义其余字符),出错也是 JSON:给 Windows PowerShell 用,绕开引号和代码页。不做 MCP:主流 Agent 都能执行命令,CLI 加 skill 已经够用。
|
||||
- UI 测试不监听真实套接字,`CliIntegration` 默认没有路径。
|
||||
- **`exec --terminal`**(`terminal/run.rs`,纯逻辑,状态都在 `TerminalRun` 的锁里):
|
||||
- 流程:引擎发 ^C → 请求线程等 500 ms(同时到的字节可能被 tty 随 ^C 冲掉)输入探测行 → 解析线程见到标记 P 就输入命令行 → 标记 B(上下文)、输出、标记 E(退出码)。时限在请求线程(`TerminalRun::drive`):P 5 秒(`terminal_busy`),B 15 秒(发 ^C 再报,可能被堡垒机命令过滤或复核拦下);执行中没有时限,每秒一个空 Stdout 帧,写失败就是调用方没了,发 ^C。
|
||||
- 标记是 OSC 6973 带每次随机的 NONCE(单独的参数,回显里的 `\033` 只是文字,伪造不了)。P 带 `readlink /proc/$PPID/exe`(去掉 ` (deleted)`;不用 `comm`,以 sh 名义启动的 bash 的 comm 是 sh)。E 由外层 sh 在管道之后打:`trap : INT` 挺过 ^C,状态经 fd 3 带出,被打断按 130 报,所以 ^C 后也一定有 E。
|
||||
- 命令按 `printf %b` 编码(控制字符、`'`、`\`、`!`、`"`、≥ 0x80 写成 `\0NNN`),打出的行里没有引号和控制字符,命令仍大体可读(审计、命令过滤看得见,所以不用 base64)。从不用 bracketed paste:命令行是在探测还在跑、tty 处于规范模式时预输入的,readline 那时没开 2004,一行最多 4095 字节,所以 bash、dash 上限 4000,busybox 的行编辑 1000。
|
||||
- 运行槽归运行时(`TerminalRuntime::run`):解析线程在 `notices.scan` 旁边喂字节,循环结束时报断开。服务端另有 `runs` 队列,工作区另起 100 ms 的 `serve_cli_runs`(不能排在逐条等改动的 `serve_cli` 后面)。
|
||||
- 真实 shell 的测试在 `run_tests.rs` 的 `shells`(`#[ignore]`,Docker 里 bash 4.2–5.2、dash、busybox、Rocky 的 sh),CI 是 `shells.yml`。
|
||||
- **Windows**:
|
||||
- 发布版是 GUI 子系统,命令改由 `shellrs-cli.exe` 承担;PATH 里放的是副本,加进用户 PATH。
|
||||
- `main` 先用 `link_arguments` 认堡垒机的链接参数,认出来就不走 CLI。
|
||||
|
||||
+10
-4
@@ -124,7 +124,7 @@ ShellRS 同一时间只运行一个:已经开着时再打开一次,会把已
|
||||
|
||||
在 设置 → 外部 CLI 中:
|
||||
|
||||
- **启用外部 CLI**:默认关闭。关闭时 ShellRS 仍在监听,但拒绝所有请求,命令会提示去这里打开。开启后,本机当前用户下的任何程序都能这样使用已保存的主机,也能修改主机和凭据(没有另外的开关)。套接字只对当前用户可读写,连接时还会核对对方的用户身份;Windows 的管道同样只允许当前用户、只接受本机连接,命令在发请求之前还会确认管道属于当前用户(或管理员),防止别人抢注同名管道。
|
||||
- **启用外部 CLI**:默认关闭。关闭时 ShellRS 仍在监听,但拒绝所有请求,命令会提示去这里打开。开启后,本机当前用户下的任何程序都能这样使用已保存的主机,也能修改主机和凭据,还能在已打开的远程终端里输入命令(`exec --terminal`,包括已切到 root 的终端),都没有另外的开关。套接字只对当前用户可读写,连接时还会核对对方的用户身份;Windows 的管道同样只允许当前用户、只接受本机连接,命令在发请求之前还会确认管道属于当前用户(或管理员),防止别人抢注同名管道。
|
||||
- **CLI 二进制**:把 `shellrs` 放进 PATH。
|
||||
- macOS:链接到 `/usr/local/bin/shellrs`,目录不可写时弹系统授权框。
|
||||
- Linux:链接到 `~/.local/bin/shellrs`。
|
||||
@@ -139,6 +139,7 @@ shellrs hosts list [-q <关键词>] [--json] # 列出主机;输出不是
|
||||
shellrs hosts show <ID> [--json] # 一台主机的全部配置
|
||||
shellrs exec <ID> "<命令>" # 或 --stdin 从标准输入读命令
|
||||
shellrs exec --json # 从标准输入读 {"host", "command"},输出 JSON
|
||||
shellrs exec <ID> --terminal "<命令>" # 在 ShellRS 已打开的终端里执行,见下文
|
||||
shellrs upload <ID> <本地路径> <远程路径>
|
||||
shellrs download <ID> <远程路径> <本地路径>
|
||||
shellrs sync <ID> <本地目录> <远程目录> [--delete]
|
||||
@@ -148,6 +149,11 @@ shellrs credentials list | show <ID> | create | update <ID> | delete <ID>
|
||||
|
||||
- `<ID>` 是 16 位的主机 ID,即 `shellrs hosts list` 的 `id`,也就是主机右键「复制 ID」复制的内容。列表里还有标签开着的临时连接和外部连接(`"temporary": true`),见「临时连接」和「从堡垒机打开(外部连接)」。
|
||||
- `exec` 每次临时建立连接,执行完即断开。远程命令没有标准输入,退出码就是远程命令的退出码;被信号终止时是 128 加信号编号。
|
||||
- `exec --terminal`(`exec --json` 里写 `"terminal": true`)不重新登录,而是把命令打进这台主机在 ShellRS 里已打开的远程终端,给只允许登录一次的堡垒机用(见「从堡垒机打开(外部连接)」)。它先发 Ctrl-C,打断终端里正在运行的程序、清掉输入行里没提交的内容,再输入一行探测和一行命令;这两行和命令的输出都显示在终端里。命令在终端当前的用户、目录和机器上执行(`su`、`sudo -i`、嵌套 `ssh` 之后也是),`--json` 的结果多一项 `context`(`user`、`host`、`cwd`、`shell`、`interp`)写明在哪里。命令的输出经管道,程序看不到终端:没有颜色和分页器,stderr 并进 stdout;命令没有标准输入,要密码的程序会停在终端里等。用哪个终端:右侧栏正对着的终端是这台主机的就用它,否则用这台主机最新的、已连接的终端。
|
||||
- 只支持 Linux 上的 bash、dash 和 busybox ash(否则报 `unsupported_shell`)。编码后的命令行,bash 和 dash 最多 4000 字节,busybox 最多 1000 字节(否则报 `too_long`)。
|
||||
- 以下情况报 `terminal_busy`,命令没有执行:全屏程序(vim、less、top、tmux 等)占着终端;上一条 `--terminal` 命令还没结束;5 秒内 shell 没有回应(前台程序不理 Ctrl-C,如 python、mysql);15 秒内 shell 没有开始执行(可能被堡垒机的命令过滤或复核拦下,这时会再发一次 Ctrl-C)。终端没打开或没连接报 `no_terminal`。
|
||||
- 执行过程中没有时限;命令行工具中途被结束时(如 Agent 超时),终端里的命令收到 Ctrl-C。
|
||||
- 那两行行首有空格,bash 设了 `ignorespace` 时不记进历史,否则会记下。停在不带提示的 `read -s`、或 vi 编辑模式的命令模式时认不出,会报「没有回应」。
|
||||
- 上传和下载沿用 SFTP 标签的传输引擎(递归、`.filepart`、断线重连),目标路径按 scp 的规则:目标是已存在的目录就放进去并保留原名,否则目标就是副本自己的路径,其父目录必须存在。已存在的文件直接覆盖;新建的文件保留源文件的可执行权限。`~` 表示登录目录。
|
||||
- `sync` 只从本机到主机:把本地目录里的内容同步进远程目录。远程目录不存在就新建(父目录必须存在),是文件就报错,不能是根目录 `/`。大小和修改时间都没变的文件、指向不变的链接跳过,摘要里计为「未变」,所以再同步一次只传改过的;其余文件直接覆盖,新文件保留可执行权限。`--delete` 先删掉远程目录里本地没有的、或类型不同的项(不跟随链接),但不删传输进行中留下的 `.filepart` 和 `.shellrs-….backup`,本地读不了的目录也不清理;删不掉的项计为失败。
|
||||
- `hosts` 和 `credentials` 管理保存的主机和凭据。`create`、`update` 从标准输入读一个 JSON 对象(`update` 只改给出的字段),字段见 `shellrs hosts create --help` 和 `shellrs credentials create --help`;`show --json` 的输出可以改了直接交回 `update`。校验和对话框一样,报错用对话框的说法:
|
||||
@@ -157,7 +163,7 @@ shellrs credentials list | show <ID> | create | update <ID> | delete <ID>
|
||||
- 临时连接和外部连接能 `show`,不能修改或删除。
|
||||
- `hosts delete` 不确认,连同主机的端口转发规则一起删;主机在 ShellRS 里有打开的标签时拒绝(`host_in_use`),加 `--force` 才关掉标签再删。经它跳转的主机,那一跳变成「已删除的主机」。`credentials delete` 也不确认,用它的主机改为自己登录(同界面)。
|
||||
- 改动由 ShellRS 一条一条执行:10 秒内没轮到就撤回、不做;开始执行后会等到完成。
|
||||
- 失败时在标准错误输出 `shellrs: [错误码] 说明`,退出码 255。错误码有 `not_running`(ShellRS 未运行)、`not_enabled`(未启用外部 CLI)、`host_not_found`、`credential_not_found`、`host_key_unknown`(还没在 ShellRS 里连过这台主机)、`host_key_changed`、`missing_credential`(没有保存密码或口令)、`connect_failed`、`transfer_failed`、`bad_request`、`host_in_use`、`save_failed`(改动已做,但没能全部写进数据库或钥匙串)、`version_mismatch`(命令和正在运行的 ShellRS 版本不同,或 ShellRS 太旧、不认识这个命令)。CLI 不弹任何询问:陌生主机、缺少密码都直接失败,请先在 ShellRS 里连接一次。传输完成但有项目失败时退出码为 1。
|
||||
- 失败时在标准错误输出 `shellrs: [错误码] 说明`,退出码 255。错误码有 `not_running`(ShellRS 未运行)、`not_enabled`(未启用外部 CLI)、`host_not_found`、`credential_not_found`、`host_key_unknown`(还没在 ShellRS 里连过这台主机)、`host_key_changed`、`missing_credential`(没有保存密码或口令)、`connect_failed`、`transfer_failed`、`bad_request`、`host_in_use`、`save_failed`(改动已做,但没能全部写进数据库或钥匙串)、`version_mismatch`(命令和正在运行的 ShellRS 版本不同,或 ShellRS 太旧、不认识这个命令),以及 `exec --terminal` 才有的 `no_terminal`、`terminal_busy`、`unsupported_shell`、`too_long`。CLI 不弹任何询问:陌生主机、缺少密码都直接失败,请先在 ShellRS 里连接一次。传输完成但有项目失败时退出码为 1。
|
||||
- `exec --json` 从标准输入读 `{"host": "<ID>", "command": "<命令>"}`,命令结束后输出 `{"exit_code", "stdout", "stderr"}`,出错时输出 `{"error": {"code", "message"}}`,退出码和不加 `--json` 时一样。输出只含 ASCII,中文等字符都转成 `\uXXXX`;不是 UTF-8 的输出换成 U+FFFD。命令不经过本机 shell 的引号处理,控制台的代码页也不会把输出弄乱,适合 Windows PowerShell 和需要分开读标准输出、标准错误的 Agent。
|
||||
- 支持 macOS、Linux 和 Windows。Windows 的 PowerShell 里执行命令优先用 `exec --json`(`@{ host = '<ID>'; command = '…' } | ConvertTo-Json -Compress | shellrs exec --json`);要边执行边看输出时(长时间构建、大量日志),改用 here-string 经 `--stdin` 传入(`@'…'@ | shellrs exec <ID> --stdin`),并和 `list`、`upload`、`download` 一样,先执行 `$OutputEncoding = [Console]::OutputEncoding = [Text.UTF8Encoding]::new()`,否则中文会变成问号或乱码。命令文本会去掉开头的 BOM、把 CRLF 换成 LF,远程 shell 不会看到多余的回车符。
|
||||
|
||||
@@ -182,7 +188,7 @@ ShellRS /sessionname=标签名 sftp://用户[:密码]@地址[:端口]
|
||||
- Windows:安装目录里的 `shellrs.exe`(默认 `%LOCALAPPDATA%\Programs\ShellRS\shellrs.exe`)。不要用 PATH 里的 `shellrs.exe`:那是外部 CLI 的副本。
|
||||
- macOS:`/Applications/ShellRS.app/Contents/MacOS/shellrs`。不要用 `open -a ShellRS --args …`:ShellRS 已在运行时,系统会把参数丢掉。
|
||||
- Linux:AppImage 本身。
|
||||
- 外部 CLI 的 `shellrs hosts list` 也列出外部连接(JSON 里 `"temporary": true`,表格的分组一栏写「(未保存)」),标签开着时 Agent 可以用它的 ID 执行命令、传输文件,这期间 ID 不变,可以一直用。标签关掉后 ID 失效;堡垒机再拉起一次(即使是同一台资产)是新的外部连接,ID 也是新的。和保存的主机一样,每条命令都用链接里的用户名和密码重新登录一次;堡垒机给的若是只能用一次的令牌,这次登录会被拒绝。
|
||||
- 外部 CLI 的 `shellrs hosts list` 也列出外部连接(JSON 里 `"temporary": true`,表格的分组一栏写「(未保存)」),标签开着时 Agent 可以用它的 ID 执行命令、传输文件,这期间 ID 不变,可以一直用。标签关掉后 ID 失效;堡垒机再拉起一次(即使是同一台资产)是新的外部连接,ID 也是新的。和保存的主机一样,每条命令都用链接里的用户名和密码重新登录一次;堡垒机给的若是只能用一次的令牌,这次登录会被拒绝,这时改用 `shellrs exec --terminal`,在外部连接已打开的终端里执行命令(见「外部 CLI」)。
|
||||
- 命令行参数里的密码在本机的进程列表里看得到,这是堡垒机这样传参本身的问题。
|
||||
|
||||
## 标签页
|
||||
@@ -439,7 +445,7 @@ ShellRS 不发送主机、凭据、会话内容、命令、文件名或路径,
|
||||
|
||||
前三种附带这些设置和数量,取值都是固定的几种:CPU 架构、应用外观、界面语言、浅色和深色主题(内置主题的名字)、界面是否跟随主题、关键字高亮、外部 CLI 和自动升级是否打开、更新渠道、是否改过快捷键,以及保存的主机、凭据、端口转发、命令片段各有多少(只报档位:0、1-5、6-20、21-100、100+)。
|
||||
|
||||
`usage` 只有次数,没有内容,计的是:SSH 终端连上(另按跳板、代理、凭据、无密码、临时连接、外部连接分别计数)、SFTP 连上(另按临时连接、外部连接计数)、本地终端、端口转发开启(本地、远程、动态)、上传和下载批次、编辑器打开本地和远程文件、预览图片和 Markdown、打开右侧栏的七个工具、命令片段和历史命令送进终端、终端查找、通知(程序、响铃、关键字),外部 CLI 的 `exec`、`upload`、`download`、`sync`、`hosts`、`credentials` 命令,以及打开和关闭关键字高亮、外部 CLI。
|
||||
`usage` 只有次数,没有内容,计的是:SSH 终端连上(另按跳板、代理、凭据、无密码、临时连接、外部连接分别计数)、SFTP 连上(另按临时连接、外部连接计数)、本地终端、端口转发开启(本地、远程、动态)、上传和下载批次、编辑器打开本地和远程文件、预览图片和 Markdown、打开右侧栏的七个工具、命令片段和历史命令送进终端、终端查找、通知(程序、响铃、关键字),外部 CLI 的 `exec`、`exec --terminal`、`upload`、`download`、`sync`、`hosts`、`credentials` 命令,以及打开和关闭关键字高亮、外部 CLI。
|
||||
|
||||
- 计数先存在数据目录的 `analytics.json` 里,退出不会丢,之后再补发。离线或在内网里不影响使用:发不出去时等 1 分钟,之后每次加倍、最长 6 小时再试,而且只在使用 ShellRS 时顺带重试,空闲时不发任何请求。
|
||||
- Aptabase 按请求的 IP 地址推断国家和地区,但不保存 IP。它区分设备用的是 IP 和 User-Agent 加上每天更换的随机盐算出的散列,所以看不到同一台设备跨天的行为。
|
||||
|
||||
@@ -345,6 +345,46 @@ cli.change.failures:
|
||||
en: "%{before}; %{next}"
|
||||
|
||||
# Running commands and moving files.
|
||||
cli.exec.try_terminal:
|
||||
zh-CN: "%{message}(堡垒机可能只允许登录一次:改用 shellrs exec --terminal,在 ShellRS 已打开的终端里执行)"
|
||||
en: "%{message} (The bastion host may allow one login only: use shellrs exec --terminal to run the command in the terminal open in ShellRS.)"
|
||||
|
||||
# `exec --terminal`: why the command could not be typed into the terminal,
|
||||
# or did not finish there.
|
||||
cli.terminal.timed_out:
|
||||
zh-CN: ShellRS 没有及时处理这条命令,命令没有执行:请稍后再试
|
||||
en: ShellRS didn't get to the command in time, so it didn't run. Try again later.
|
||||
cli.terminal.no_terminal:
|
||||
zh-CN: 这台主机在 ShellRS 里没有打开的终端:请先在 ShellRS 里打开它的终端
|
||||
en: This host has no terminal open in ShellRS. Open its terminal in ShellRS first.
|
||||
cli.terminal.not_connected:
|
||||
zh-CN: 这台主机在 ShellRS 里的终端没有连接:请先在 ShellRS 里重新连接
|
||||
en: This host's terminal in ShellRS isn't connected. Reconnect it in ShellRS first.
|
||||
cli.terminal.unknown_shell:
|
||||
zh-CN: 认不出终端里的 shell:只支持 Linux 上的 bash、dash 和 busybox ash
|
||||
en: Couldn't tell which shell the terminal runs. Only bash, dash and busybox ash on Linux are supported.
|
||||
cli.terminal.unsupported_shell:
|
||||
zh-CN: 终端里的 shell 是 %{shell}:只支持 bash、dash 和 busybox ash
|
||||
en: The terminal runs %{shell}. Only bash, dash and busybox ash are supported.
|
||||
cli.terminal.too_long:
|
||||
zh-CN: 命令太长:这个终端里,编码后的命令行最多 %{limit} 字节。请拆成几条
|
||||
en: The command is too long. In this terminal the encoded command line takes %{limit} bytes at most; split it up.
|
||||
cli.terminal.full_screen:
|
||||
zh-CN: 终端里正在运行全屏程序(如 vim、less、top、tmux):请先在 ShellRS 里退出它
|
||||
en: A full-screen program (such as vim, less, top or tmux) has the terminal. Quit it in ShellRS first.
|
||||
cli.terminal.another_run:
|
||||
zh-CN: 这个终端里上一条 shellrs exec --terminal 命令还在执行
|
||||
en: The previous shellrs exec --terminal command is still running in this terminal.
|
||||
cli.terminal.no_answer:
|
||||
zh-CN: 终端里的 shell 没有回应:前台程序可能不响应 Ctrl-C(如 python、mysql),或者终端里不是 Linux 上的 bash、dash 或 busybox ash
|
||||
en: The shell in the terminal didn't answer. The program in the foreground may ignore Ctrl-C (such as python or mysql), or the terminal doesn't run bash, dash or busybox ash on Linux.
|
||||
cli.terminal.not_started:
|
||||
zh-CN: shell 没有开始执行这条命令,可能被堡垒机的命令过滤或命令复核拦下了;已发送 Ctrl-C
|
||||
en: The shell didn't start the command; the bastion host's command filter or review may have held it back. Ctrl-C was sent.
|
||||
cli.terminal.disconnected:
|
||||
zh-CN: 命令执行过程中终端断开了,命令可能已执行了一部分
|
||||
en: The terminal disconnected while the command ran; it may have partly run.
|
||||
|
||||
cli.exec.signal:
|
||||
zh-CN: 远程命令被信号 %{signal} 终止
|
||||
en: The remote command was killed by signal %{signal}.
|
||||
|
||||
@@ -82,6 +82,7 @@ pub enum Counter {
|
||||
NoticeKeyword,
|
||||
/// A `shellrs` command served.
|
||||
CliExec,
|
||||
CliExecTerminal,
|
||||
CliUpload,
|
||||
CliDownload,
|
||||
CliSync,
|
||||
@@ -95,7 +96,7 @@ pub enum Counter {
|
||||
}
|
||||
|
||||
impl Counter {
|
||||
pub const ALL: [Counter; 43] = [
|
||||
pub const ALL: [Counter; 44] = [
|
||||
Self::Ssh,
|
||||
Self::SshJump,
|
||||
Self::SshProxy,
|
||||
@@ -130,6 +131,7 @@ impl Counter {
|
||||
Self::NoticeBell,
|
||||
Self::NoticeKeyword,
|
||||
Self::CliExec,
|
||||
Self::CliExecTerminal,
|
||||
Self::CliUpload,
|
||||
Self::CliDownload,
|
||||
Self::CliSync,
|
||||
@@ -177,6 +179,7 @@ impl Counter {
|
||||
Self::NoticeBell => "notice_bell",
|
||||
Self::NoticeKeyword => "notice_keyword",
|
||||
Self::CliExec => "cli_exec",
|
||||
Self::CliExecTerminal => "cli_exec_terminal",
|
||||
Self::CliUpload => "cli_upload",
|
||||
Self::CliDownload => "cli_download",
|
||||
Self::CliSync => "cli_sync",
|
||||
|
||||
+28
-1
@@ -32,6 +32,23 @@ shellrs exec <host-id> --stdin <<'EOF'
|
||||
EOF
|
||||
```
|
||||
|
||||
## Hosts Opened From a Bastion Host
|
||||
|
||||
A host listed with `"temporary": true` may have been opened by a bastion host's link that allows one login. Then `exec`, `upload` and `download`, which log in again, fail with `missing_credential` or `connect_failed`, and the message says so. Run the command in the host's terminal open in ShellRS instead:
|
||||
|
||||
```bash
|
||||
shellrs exec <host-id> --terminal "<command>"
|
||||
shellrs exec <host-id> --terminal --stdin <<'EOF'
|
||||
<command>
|
||||
EOF
|
||||
```
|
||||
|
||||
- It types into the user's own terminal tab, in sight of the user. It sends Ctrl-C first, which interrupts whatever runs there: use it only on a tab the user handed to you, not on one they are working in.
|
||||
- The command runs where that terminal is now: its user, folder and machine, after any `su`, `sudo -i` or nested `ssh`. With `--json`, the result's `context` says where: `{"user", "host", "cwd", "shell", "interp"}`.
|
||||
- stderr comes as stdout. One command at a time per terminal. Only bash, dash and busybox ash.
|
||||
- Programs that ask for a password wait in the user's tab: use `sudo -n` and other non-interactive flags. Give your own tool a timeout; when the command is given up, it gets Ctrl-C.
|
||||
- Files cannot be transferred this way; write a small text file with a heredoc in the command.
|
||||
|
||||
## File Transfer
|
||||
|
||||
```bash
|
||||
@@ -94,6 +111,12 @@ $OutputEncoding = [Console]::OutputEncoding = [Text.UTF8Encoding]::new()
|
||||
'@ | shellrs exec <host-id> --stdin
|
||||
```
|
||||
|
||||
For `--terminal`, add `terminal = $true`:
|
||||
|
||||
```powershell
|
||||
@{ host = '<host-id>'; command = 'df -h'; terminal = $true } | ConvertTo-Json -Compress | shellrs exec --json | ConvertFrom-Json
|
||||
```
|
||||
|
||||
The `@'` must end its line and the `'@` must start one. Set the same encodings before the other commands, or non-ASCII text (such as the Chinese names of saved hosts) turns into `?` or mojibake.
|
||||
|
||||
To create or update a host or credential from Windows PowerShell 5.1, pipe the JSON in the same way, writing non-ASCII characters in it as `\uXXXX` escapes (`"group": "\u751f\u4ea7"` for 生产):
|
||||
@@ -125,6 +148,10 @@ Errors are printed to stderr with a code in brackets, such as `[not_enabled]`. E
|
||||
- `host_in_use`: the host's tabs are open in ShellRS. Ask the user whether to close them (`--force`).
|
||||
- `save_failed`: the change was made, but not all of it could be written to disk or the keychain. Tell the user the message.
|
||||
- `version_mismatch`: the `shellrs` command and the running ShellRS are different versions. Ask the user to restart or update ShellRS.
|
||||
- `host_key_unknown`, `missing_credential`: ShellRS has not connected to this host yet, or has no saved password. Ask the user to connect to the host once in ShellRS and save the password.
|
||||
- `host_key_unknown`, `missing_credential`: ShellRS has not connected to this host yet, or has no saved password. Ask the user to connect to the host once in ShellRS and save the password. For a host opened from a bastion host, use `--terminal` (see above).
|
||||
- `host_key_changed`: the server's host key changed. Tell the user; do not try to work around it.
|
||||
- `no_terminal` (`--terminal`): the host has no connected terminal in ShellRS. Ask the user to open or reconnect it.
|
||||
- `terminal_busy` (`--terminal`): a full-screen program has the terminal, another `--terminal` command still runs there, the shell did not answer, or it did not start the command (a bastion host's command filter may hold it back). Tell the user the message.
|
||||
- `unsupported_shell` (`--terminal`): the terminal runs a shell other than bash, dash or busybox ash. Tell the user.
|
||||
- `too_long` (`--terminal`): split the command into shorter ones.
|
||||
- `connect_failed` saying there is no permission to connect to ShellRS: the agent's sandbox blocks local inter-process communication. Ask the user to allow it, or run the command outside the sandbox.
|
||||
+21
-9
@@ -145,15 +145,27 @@ impl CliBackend for SshCliBackend {
|
||||
);
|
||||
Ok(signal_exit_code(&signal))
|
||||
}
|
||||
Err(error) => Err(CliError::new(
|
||||
match error.kind {
|
||||
ExecErrorKind::HostKeyUnknown => ErrorCode::HostKeyUnknown,
|
||||
ExecErrorKind::HostKeyChanged => ErrorCode::HostKeyChanged,
|
||||
ExecErrorKind::MissingCredential => ErrorCode::MissingCredential,
|
||||
ExecErrorKind::Connect | ExecErrorKind::Aborted => ErrorCode::ConnectFailed,
|
||||
},
|
||||
error.message,
|
||||
)),
|
||||
Err(error) => {
|
||||
// A bastion host's link logs in once; its terminal is open.
|
||||
let message = if target.login().shell_only
|
||||
&& matches!(
|
||||
error.kind,
|
||||
ExecErrorKind::MissingCredential | ExecErrorKind::Connect
|
||||
) {
|
||||
t!("cli.exec.try_terminal", message = error.message).to_string()
|
||||
} else {
|
||||
error.message
|
||||
};
|
||||
Err(CliError::new(
|
||||
match error.kind {
|
||||
ExecErrorKind::HostKeyUnknown => ErrorCode::HostKeyUnknown,
|
||||
ExecErrorKind::HostKeyChanged => ErrorCode::HostKeyChanged,
|
||||
ExecErrorKind::MissingCredential => ErrorCode::MissingCredential,
|
||||
ExecErrorKind::Connect | ExecErrorKind::Aborted => ErrorCode::ConnectFailed,
|
||||
},
|
||||
message,
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+9
-1
@@ -15,7 +15,8 @@ use super::link::OpenLink;
|
||||
use super::protocol::{
|
||||
AuthChoice, CredentialDeleted, CredentialDetails, CredentialKindChoice, Envelope, ErrorCode,
|
||||
FrameKind, HostDeleted, HostDetails, HostInfo, PROTOCOL_VERSION, ProxyChoice, Reply, Request,
|
||||
RouteDetails, TransferCounters, TransferSummary, parse_json, read_frame, write_json,
|
||||
RouteDetails, TerminalContext, TransferCounters, TransferSummary, parse_json, read_frame,
|
||||
write_json,
|
||||
};
|
||||
use crate::host::HostOs;
|
||||
use crate::i18n::{t, tn};
|
||||
@@ -60,6 +61,9 @@ struct ExecResult<'a> {
|
||||
exit_code: i32,
|
||||
stdout: std::borrow::Cow<'a, str>,
|
||||
stderr: std::borrow::Cow<'a, str>,
|
||||
/// Where an `exec --terminal` command ran.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
context: Option<TerminalContext>,
|
||||
}
|
||||
|
||||
/// `value` as JSON in ASCII alone: every other character escaped, which
|
||||
@@ -233,6 +237,7 @@ where
|
||||
let mut progress = ProgressLine::default();
|
||||
// What `exec --json` holds until the command ends.
|
||||
let (mut stdout, mut stderr) = (Vec::new(), Vec::new());
|
||||
let mut context = None;
|
||||
loop {
|
||||
let Some((kind, payload)) = read_frame(&mut reader)? else {
|
||||
return Err(io::Error::from(io::ErrorKind::UnexpectedEof).into());
|
||||
@@ -286,11 +291,14 @@ where
|
||||
print_credential_deleted(&deleted, console)?;
|
||||
return Ok(0);
|
||||
}
|
||||
// Only `--json` prints where the command ran.
|
||||
Reply::Context(found) => context = Some(found),
|
||||
Reply::Exit { code } if console.exec_json => {
|
||||
let result = ExecResult {
|
||||
exit_code: code,
|
||||
stdout: String::from_utf8_lossy(&stdout),
|
||||
stderr: String::from_utf8_lossy(&stderr),
|
||||
context: context.take(),
|
||||
};
|
||||
writeln!(console.stdout, "{}", ascii_json(&result))?;
|
||||
return Ok(code);
|
||||
|
||||
+39
-16
@@ -40,13 +40,14 @@ pub use link::{OpenLink, link_arguments};
|
||||
pub use manage::CliChange;
|
||||
pub(crate) use manage::{
|
||||
CredentialPlan, GroupPlan, SecretChange, credential_details, credential_secrets,
|
||||
find_credential, find_saved_host, host_details, host_info, host_secrets, plan_credential,
|
||||
plan_host, with_saved_credential_secrets, with_saved_passwords,
|
||||
find_credential, find_host, find_saved_host, host_details, host_info, host_secrets,
|
||||
plan_credential, plan_host, with_saved_credential_secrets, with_saved_passwords,
|
||||
};
|
||||
pub use protocol::{
|
||||
AuthChoice, CliError, CredentialDeleted, CredentialDetails, CredentialFields,
|
||||
CredentialKindChoice, ErrorCode, HostDeleted, HostDetails, HostFields, HostInfo, ProxyChoice,
|
||||
Reply, Request, RouteDetails, RouteFields, Secret, TransferCounters, TransferSummary,
|
||||
Reply, Request, RouteDetails, RouteFields, Secret, TerminalContext, TransferCounters,
|
||||
TransferSummary,
|
||||
};
|
||||
pub use server::{ChangeReply, CliBackend, CliServer, CliTarget, CliUse};
|
||||
|
||||
@@ -135,9 +136,17 @@ enum Command {
|
||||
/// {"exit_code", "stdout", "stderr"} once the command ends, errors as
|
||||
/// {"error": {"code", "message"}}. The output is ASCII, everything
|
||||
/// else escaped: no shell quoting, and no console code page can
|
||||
/// garble it.
|
||||
/// garble it. {"terminal": true} is the same as --terminal.
|
||||
#[arg(long, conflicts_with_all = ["id", "command", "stdin"])]
|
||||
json: bool,
|
||||
/// Type the command into the host's terminal open in ShellRS
|
||||
/// instead of logging in again, for a bastion host that allows one
|
||||
/// login. It sends Ctrl-C first, which interrupts whatever runs
|
||||
/// there. Its stderr comes as stdout; with --json, the result also
|
||||
/// has "context": the user, host, folder and shell it ran in. Bash,
|
||||
/// dash and busybox ash only.
|
||||
#[arg(long)]
|
||||
terminal: bool,
|
||||
},
|
||||
/// Copy a local file or folder to a saved host.
|
||||
///
|
||||
@@ -326,22 +335,34 @@ fn request(command: Command, console: &mut Console) -> Result<Request, i32> {
|
||||
command,
|
||||
stdin,
|
||||
json,
|
||||
terminal,
|
||||
} => {
|
||||
// From here on, errors are JSON too.
|
||||
console.exec_json = json;
|
||||
let (host, command) = if json {
|
||||
let request = if json {
|
||||
exec_request(&read_stdin(console)?)
|
||||
.map_err(|message| console.error(ErrorCode::BadRequest, &message))?
|
||||
} else if stdin {
|
||||
(id.unwrap_or_default(), read_stdin(console)?)
|
||||
} else {
|
||||
(id.unwrap_or_default(), command.unwrap_or_default())
|
||||
ExecJson {
|
||||
host: id.unwrap_or_default(),
|
||||
command: if stdin {
|
||||
read_stdin(console)?
|
||||
} else {
|
||||
command.unwrap_or_default()
|
||||
},
|
||||
terminal: false,
|
||||
}
|
||||
};
|
||||
let command = normalize_command(&command);
|
||||
let command = normalize_command(&request.command);
|
||||
if command.trim().is_empty() {
|
||||
return Err(console.error(ErrorCode::BadRequest, &t!("cli.exec.empty")));
|
||||
}
|
||||
Request::Exec { host, command }
|
||||
let host = request.host;
|
||||
if terminal || request.terminal {
|
||||
Request::ExecInTerminal { host, command }
|
||||
} else {
|
||||
Request::Exec { host, command }
|
||||
}
|
||||
}
|
||||
Command::Upload {
|
||||
id,
|
||||
@@ -514,24 +535,26 @@ fn read_stdin(console: &mut Console) -> Result<String, i32> {
|
||||
}
|
||||
|
||||
/// What `exec --json` reads.
|
||||
#[derive(serde::Deserialize)]
|
||||
#[derive(Debug, PartialEq, serde::Deserialize)]
|
||||
struct ExecJson {
|
||||
host: String,
|
||||
command: String,
|
||||
/// `--terminal`.
|
||||
#[serde(default)]
|
||||
terminal: bool,
|
||||
}
|
||||
|
||||
/// The host and command of an `exec --json` request.
|
||||
fn exec_request(text: &str) -> Result<(String, String), String> {
|
||||
/// What an `exec --json` request asks for.
|
||||
fn exec_request(text: &str) -> Result<ExecJson, String> {
|
||||
let text = text.strip_prefix('\u{feff}').unwrap_or(text);
|
||||
let request: ExecJson = serde_json::from_str(text).map_err(|error| {
|
||||
serde_json::from_str(text).map_err(|error| {
|
||||
t!(
|
||||
"cli.exec.bad_json",
|
||||
form = r#"{"host": ..., "command": ...}"#,
|
||||
error = error
|
||||
)
|
||||
.to_string()
|
||||
})?;
|
||||
Ok((request.host, request.command))
|
||||
})
|
||||
}
|
||||
|
||||
/// A command as the remote shell should see it. PowerShell ends every line
|
||||
|
||||
+43
-1
@@ -127,6 +127,14 @@ pub enum Request {
|
||||
host: String,
|
||||
command: String,
|
||||
},
|
||||
/// `exec --terminal`: type `command` into the host's open terminal
|
||||
/// instead of logging in again, for a bastion host that allows one
|
||||
/// login. Its output comes as stdout, then [`Reply::Context`], then
|
||||
/// [`Reply::Exit`].
|
||||
ExecInTerminal {
|
||||
host: String,
|
||||
command: String,
|
||||
},
|
||||
/// `source` is absolute: the app's working directory is not the
|
||||
/// caller's.
|
||||
Upload {
|
||||
@@ -207,6 +215,8 @@ pub enum Reply {
|
||||
Exit {
|
||||
code: i32,
|
||||
},
|
||||
/// Where an `exec --terminal` command runs, once it started.
|
||||
Context(TerminalContext),
|
||||
/// A host as it is now: shown, created or changed.
|
||||
Host(HostDetails),
|
||||
HostDeleted(HostDeleted),
|
||||
@@ -250,6 +260,19 @@ impl HostInfo {
|
||||
}
|
||||
}
|
||||
|
||||
/// Where an `exec --terminal` command ran: the terminal may have gone to
|
||||
/// another user, folder or machine since it was opened.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct TerminalContext {
|
||||
pub user: String,
|
||||
pub host: String,
|
||||
pub cwd: String,
|
||||
/// The interactive shell the command was typed into.
|
||||
pub shell: String,
|
||||
/// What ran the command: `bash`, or `sh` where there is none.
|
||||
pub interp: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct TransferCounters {
|
||||
pub files: u64,
|
||||
@@ -296,6 +319,14 @@ pub enum ErrorCode {
|
||||
/// A change was made, but could not all be written to disk or the
|
||||
/// keychain.
|
||||
SaveFailed,
|
||||
/// `exec --terminal`: the host has no connected terminal.
|
||||
NoTerminal,
|
||||
/// `exec --terminal`: the terminal could not take the command.
|
||||
TerminalBusy,
|
||||
/// `exec --terminal`: the shell is not bash, dash or busybox ash.
|
||||
UnsupportedShell,
|
||||
/// `exec --terminal`: the command line is longer than the shell takes.
|
||||
TooLong,
|
||||
}
|
||||
|
||||
impl ErrorCode {
|
||||
@@ -314,6 +345,10 @@ impl ErrorCode {
|
||||
ErrorCode::CredentialNotFound => "credential_not_found",
|
||||
ErrorCode::HostInUse => "host_in_use",
|
||||
ErrorCode::SaveFailed => "save_failed",
|
||||
ErrorCode::NoTerminal => "no_terminal",
|
||||
ErrorCode::TerminalBusy => "terminal_busy",
|
||||
ErrorCode::UnsupportedShell => "unsupported_shell",
|
||||
ErrorCode::TooLong => "too_long",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -641,7 +676,14 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn error_codes_are_spelled_as_documented() {
|
||||
for code in [ErrorCode::NotEnabled, ErrorCode::HostKeyUnknown] {
|
||||
for code in [
|
||||
ErrorCode::NotEnabled,
|
||||
ErrorCode::HostKeyUnknown,
|
||||
ErrorCode::NoTerminal,
|
||||
ErrorCode::TerminalBusy,
|
||||
ErrorCode::UnsupportedShell,
|
||||
ErrorCode::TooLong,
|
||||
] {
|
||||
assert_eq!(
|
||||
serde_json::to_string(&code).unwrap(),
|
||||
format!("\"{}\"", code.as_str())
|
||||
|
||||
+156
-4
@@ -26,13 +26,16 @@ use super::manage::{
|
||||
};
|
||||
use super::protocol::{
|
||||
CliError, CredentialDetails, Envelope, ErrorCode, FrameKind, HostDetails, HostInfo,
|
||||
PROTOCOL_VERSION, Reply, Request, TransferCounters, TransferSummary, VersionOnly, parse_json,
|
||||
read_frame, write_frame, write_json,
|
||||
PROTOCOL_VERSION, Reply, Request, TerminalContext, TransferCounters, TransferSummary,
|
||||
VersionOnly, parse_json, read_frame, write_frame, write_json,
|
||||
};
|
||||
use crate::host::{Host, HostLogin, HostStore, matches_query};
|
||||
use crate::i18n::t;
|
||||
use crate::secrets::SecretRef;
|
||||
use crate::ssh::ExecStream;
|
||||
use crate::terminal::{
|
||||
BusyReason, RunEvent, RunFailure, RunHandle, RunProgress, RunTiming, TerminalRun,
|
||||
};
|
||||
|
||||
/// Does what a CLI request asks. Every method blocks: each request has a
|
||||
/// thread of its own.
|
||||
@@ -159,6 +162,26 @@ const QUEUE_TIMEOUT: Duration = if cfg!(test) {
|
||||
Duration::from_secs(10)
|
||||
};
|
||||
|
||||
/// How an `exec --terminal` run is paced; quick in the tests.
|
||||
const RUN_TIMING: RunTiming = if cfg!(test) {
|
||||
RunTiming {
|
||||
settle: Duration::ZERO,
|
||||
probe: Duration::from_millis(300),
|
||||
start: Duration::from_millis(300),
|
||||
tick: Duration::from_millis(20),
|
||||
}
|
||||
} else {
|
||||
RunTiming::STANDARD
|
||||
};
|
||||
|
||||
/// An `exec --terminal` run waiting for the app to find the host's
|
||||
/// terminal.
|
||||
struct PendingRun {
|
||||
id: u64,
|
||||
host: String,
|
||||
run: RunHandle,
|
||||
}
|
||||
|
||||
/// A change waiting for the app, with where its outcome goes.
|
||||
struct PendingChange {
|
||||
id: u64,
|
||||
@@ -189,6 +212,9 @@ struct Shared {
|
||||
/// Changes to hosts and credentials, oldest first, until the app takes
|
||||
/// them up: only the UI thread can change the store.
|
||||
changes: Mutex<VecDeque<PendingChange>>,
|
||||
/// `exec --terminal` runs, oldest first, until the app takes them up:
|
||||
/// only the UI thread can reach a terminal.
|
||||
runs: Mutex<VecDeque<PendingRun>>,
|
||||
next_change: AtomicU64,
|
||||
/// What kinds of command were served, for 匿名使用统计, until the app
|
||||
/// takes them.
|
||||
@@ -200,6 +226,7 @@ struct Shared {
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum CliUse {
|
||||
Exec,
|
||||
ExecTerminal,
|
||||
Upload,
|
||||
Download,
|
||||
Sync,
|
||||
@@ -211,6 +238,7 @@ impl CliUse {
|
||||
fn of(request: &Request) -> Option<Self> {
|
||||
match request {
|
||||
Request::Exec { .. } => Some(Self::Exec),
|
||||
Request::ExecInTerminal { .. } => Some(Self::ExecTerminal),
|
||||
Request::Upload { .. } => Some(Self::Upload),
|
||||
Request::Download { .. } => Some(Self::Download),
|
||||
Request::Sync { .. } => Some(Self::Sync),
|
||||
@@ -249,6 +277,7 @@ impl CliServer {
|
||||
backend,
|
||||
activation: Mutex::new(None),
|
||||
changes: Mutex::new(VecDeque::new()),
|
||||
runs: Mutex::new(VecDeque::new()),
|
||||
next_change: AtomicU64::new(0),
|
||||
usage: Mutex::new(Vec::new()),
|
||||
});
|
||||
@@ -297,6 +326,17 @@ impl CliServer {
|
||||
.map(|pending| (pending.change, pending.reply))
|
||||
}
|
||||
|
||||
/// The oldest `exec --terminal` run waiting, with the host it is for,
|
||||
/// for the app to hand to that host's terminal, or say why it cannot.
|
||||
pub fn take_run(&self) -> Option<(String, RunHandle)> {
|
||||
self.shared
|
||||
.runs
|
||||
.lock()
|
||||
.unwrap_or_else(|error| error.into_inner())
|
||||
.pop_front()
|
||||
.map(|pending| (pending.host, pending.run))
|
||||
}
|
||||
|
||||
/// Whether ShellRS was opened again since this was last asked, and the
|
||||
/// links it was opened with, oldest first: `Some` and empty for a plain
|
||||
/// re-open. The request threads cannot reach the window, so the app asks
|
||||
@@ -323,14 +363,23 @@ impl CliServer {
|
||||
}
|
||||
|
||||
impl Drop for CliServer {
|
||||
/// The app is going: whatever waits for it to take up a change hears
|
||||
/// that it never will.
|
||||
/// The app is going: whatever waits for it to take up a change or a
|
||||
/// run hears that it never will.
|
||||
fn drop(&mut self) {
|
||||
self.shared
|
||||
.changes
|
||||
.lock()
|
||||
.unwrap_or_else(|error| error.into_inner())
|
||||
.clear();
|
||||
for pending in self
|
||||
.shared
|
||||
.runs
|
||||
.lock()
|
||||
.unwrap_or_else(|error| error.into_inner())
|
||||
.drain(..)
|
||||
{
|
||||
pending.run.fail(RunFailure::NotConnected);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -640,6 +689,10 @@ fn respond(
|
||||
})?;
|
||||
Ok(Reply::Exit { code })
|
||||
}
|
||||
Request::ExecInTerminal { host, command } => {
|
||||
let target = find(shared, &host)?;
|
||||
run_in_terminal(shared, writer, &target.info.id, command)
|
||||
}
|
||||
Request::Upload {
|
||||
host,
|
||||
source,
|
||||
@@ -790,6 +843,105 @@ fn change(shared: &Shared, change: CliChange) -> Result<Reply, CliError> {
|
||||
outcome.recv().unwrap_or_else(|_| Err(gone()))
|
||||
}
|
||||
|
||||
/// Hand a run to the app, which finds the host's terminal, and see it
|
||||
/// through: the command's output as stdout, its context, its exit code.
|
||||
/// Taken back, so certainly not typed, when the app does not take it up
|
||||
/// in time. While the command prints nothing, an empty frame now and then
|
||||
/// finds out whether the caller is still there; once it is not, the
|
||||
/// command gets a ^C.
|
||||
fn run_in_terminal(
|
||||
shared: &Shared,
|
||||
writer: &mut impl io::Write,
|
||||
host: &str,
|
||||
command: String,
|
||||
) -> Result<Reply, CliError> {
|
||||
let (run, events) = TerminalRun::new(command);
|
||||
let id = shared.next_change.fetch_add(1, Ordering::Relaxed);
|
||||
shared
|
||||
.runs
|
||||
.lock()
|
||||
.unwrap_or_else(|error| error.into_inner())
|
||||
.push_back(PendingRun {
|
||||
id,
|
||||
host: host.to_owned(),
|
||||
run: run.handle(),
|
||||
});
|
||||
let first = match events.recv_timeout(QUEUE_TIMEOUT) {
|
||||
Ok(event) => event,
|
||||
Err(_) => {
|
||||
let mut runs = shared
|
||||
.runs
|
||||
.lock()
|
||||
.unwrap_or_else(|error| error.into_inner());
|
||||
if let Some(index) = runs.iter().position(|pending| pending.id == id) {
|
||||
runs.remove(index);
|
||||
return Err(CliError::new(
|
||||
ErrorCode::ConnectFailed,
|
||||
t!("cli.terminal.timed_out"),
|
||||
));
|
||||
}
|
||||
drop(runs);
|
||||
// Taken up just now: its answer is on the way.
|
||||
events
|
||||
.recv()
|
||||
.map_err(|_| run_error(RunFailure::NotConnected, host))?
|
||||
}
|
||||
};
|
||||
if let RunEvent::Failed(failure) = first {
|
||||
return Err(run_error(failure, host));
|
||||
}
|
||||
let code = run
|
||||
.drive(&events, RUN_TIMING, &mut |progress| match progress {
|
||||
RunProgress::Context(context) => write_json(
|
||||
writer,
|
||||
&Reply::Context(TerminalContext {
|
||||
user: context.user,
|
||||
host: context.host,
|
||||
cwd: context.cwd,
|
||||
shell: context.shell,
|
||||
interp: context.interp,
|
||||
}),
|
||||
),
|
||||
RunProgress::Output(bytes) => write_frame(writer, FrameKind::Stdout, &bytes),
|
||||
RunProgress::Idle => write_frame(writer, FrameKind::Stdout, &[]),
|
||||
})
|
||||
.map_err(|failure| run_error(failure, host))?;
|
||||
Ok(Reply::Exit { code })
|
||||
}
|
||||
|
||||
/// Why a run did not run, in the CLI's words.
|
||||
fn run_error(failure: RunFailure, host: &str) -> CliError {
|
||||
let busy = |message| CliError::new(ErrorCode::TerminalBusy, message);
|
||||
match failure {
|
||||
RunFailure::HostNotFound => host_not_found(host),
|
||||
RunFailure::NoTerminal => {
|
||||
CliError::new(ErrorCode::NoTerminal, t!("cli.terminal.no_terminal"))
|
||||
}
|
||||
RunFailure::NotConnected => {
|
||||
CliError::new(ErrorCode::NoTerminal, t!("cli.terminal.not_connected"))
|
||||
}
|
||||
RunFailure::UnsupportedShell(shell) if shell.is_empty() => CliError::new(
|
||||
ErrorCode::UnsupportedShell,
|
||||
t!("cli.terminal.unknown_shell"),
|
||||
),
|
||||
RunFailure::UnsupportedShell(shell) => CliError::new(
|
||||
ErrorCode::UnsupportedShell,
|
||||
t!("cli.terminal.unsupported_shell", shell = shell),
|
||||
),
|
||||
RunFailure::TooLong { limit } => CliError::new(
|
||||
ErrorCode::TooLong,
|
||||
t!("cli.terminal.too_long", limit = limit),
|
||||
),
|
||||
RunFailure::Busy(BusyReason::FullScreen) => busy(t!("cli.terminal.full_screen")),
|
||||
RunFailure::Busy(BusyReason::AnotherRun) => busy(t!("cli.terminal.another_run")),
|
||||
RunFailure::Busy(BusyReason::NoAnswer) => busy(t!("cli.terminal.no_answer")),
|
||||
RunFailure::Busy(BusyReason::NotStarted) => busy(t!("cli.terminal.not_started")),
|
||||
RunFailure::Disconnected => {
|
||||
CliError::new(ErrorCode::ConnectFailed, t!("cli.terminal.disconnected"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn find(shared: &Shared, id: &str) -> Result<CliTarget, CliError> {
|
||||
shared
|
||||
.targets
|
||||
|
||||
+188
-3
@@ -19,7 +19,7 @@ use super::protocol::{
|
||||
};
|
||||
use super::server::{CliBackend, CliServer, CliTarget, CliUse};
|
||||
use super::{Cli, Command, CredentialsCommand, HostsCommand};
|
||||
use super::{ConsoleText, exec_request, normalize_command};
|
||||
use super::{ConsoleText, ExecJson, exec_request, normalize_command};
|
||||
use crate::app::cli_endpoint;
|
||||
use crate::host::{
|
||||
AuthKind, CredentialDraft, CredentialKind, GroupDraft, Host, HostDraft, HostId, HostStore,
|
||||
@@ -27,6 +27,7 @@ use crate::host::{
|
||||
};
|
||||
use crate::secrets::SecretRef;
|
||||
use crate::ssh::ExecStream;
|
||||
use crate::terminal::{RunFailure, RunHandle, TerminalTransportCommand};
|
||||
|
||||
/// Records what it was asked and answers from a script.
|
||||
#[derive(Default)]
|
||||
@@ -632,6 +633,19 @@ fn exec_takes_its_command_as_one_argument_or_from_stdin() {
|
||||
assert!(parse(&["shellrs", "exec", "--json", "--stdin"]).is_err());
|
||||
// Split words are a mistake, not a longer command.
|
||||
assert!(parse(&["shellrs", "exec", "ID", "ls", "-la"]).is_err());
|
||||
// Typed into the open terminal, in any of the forms.
|
||||
assert!(matches!(
|
||||
parse(&["shellrs", "exec", "ID", "--terminal", "ls"]),
|
||||
Ok(Command::Exec { terminal: true, command: Some(command), .. }) if command == "ls"
|
||||
));
|
||||
assert!(matches!(
|
||||
parse(&["shellrs", "exec", "--json", "--terminal"]),
|
||||
Ok(Command::Exec {
|
||||
terminal: true,
|
||||
json: true,
|
||||
..
|
||||
})
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -680,14 +694,23 @@ fn a_windows_console_gets_text_even_from_bytes_that_are_not_utf8() {
|
||||
|
||||
#[test]
|
||||
fn exec_json_reads_the_host_and_the_command() {
|
||||
let asked = |host: &str, command: &str, terminal| ExecJson {
|
||||
host: host.into(),
|
||||
command: command.into(),
|
||||
terminal,
|
||||
};
|
||||
assert_eq!(
|
||||
exec_request("\u{feff}{\"host\": \"ID\", \"command\": \"echo \\\"hi\\\" | wc -c\"}"),
|
||||
Ok(("ID".to_string(), "echo \"hi\" | wc -c".to_string()))
|
||||
Ok(asked("ID", "echo \"hi\" | wc -c", false))
|
||||
);
|
||||
// Escaped as an agent may write it in PowerShell 5.1, which pipes ASCII.
|
||||
assert_eq!(
|
||||
exec_request(r#"{"host":"ID","command":"grep \u751f\u4ea7 a.log"}"#),
|
||||
Ok(("ID".to_string(), "grep 生产 a.log".to_string()))
|
||||
Ok(asked("ID", "grep 生产 a.log", false))
|
||||
);
|
||||
assert_eq!(
|
||||
exec_request(r#"{"host":"ID","command":"ls","terminal":true}"#),
|
||||
Ok(asked("ID", "ls", true))
|
||||
);
|
||||
assert!(exec_request(r#"{"command":"ls"}"#).is_err());
|
||||
assert!(exec_request("ls -la").is_err());
|
||||
@@ -1110,3 +1133,165 @@ fn an_app_that_does_not_know_the_command_is_said_to_be_older() {
|
||||
.starts_with("shellrs: [version_mismatch] 正在运行的 ShellRS 不认识这个命令"),
|
||||
);
|
||||
}
|
||||
|
||||
/// The app's end of an `exec --terminal` run: wait for it to come in.
|
||||
fn take_run(server: &CliServer) -> (String, RunHandle) {
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
|
||||
loop {
|
||||
if let Some(taken) = server.take_run() {
|
||||
return taken;
|
||||
}
|
||||
assert!(std::time::Instant::now() < deadline, "no run came in");
|
||||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||
}
|
||||
}
|
||||
|
||||
/// A terminal taking `run` up, with a shell whose program is `shell`: it
|
||||
/// answers the probe, then the command line with `output` and `code`,
|
||||
/// slowly enough for the server to check on the caller meanwhile.
|
||||
/// Everything typed, once nothing more is.
|
||||
fn answer_run(
|
||||
run: RunHandle,
|
||||
shell: &'static str,
|
||||
output: &'static str,
|
||||
code: i32,
|
||||
) -> std::thread::JoinHandle<String> {
|
||||
let (input, typed) = std::sync::mpsc::channel();
|
||||
assert!(run.accept(input));
|
||||
std::thread::spawn(move || {
|
||||
let mut all = String::new();
|
||||
while let Ok(TerminalTransportCommand::Write(bytes)) =
|
||||
typed.recv_timeout(std::time::Duration::from_millis(500))
|
||||
{
|
||||
let line = String::from_utf8(bytes).unwrap();
|
||||
all.push_str(&line);
|
||||
// ` sh -c '<script>' sh NONCE …`
|
||||
let Some(at) = line.find("' sh ") else {
|
||||
continue;
|
||||
};
|
||||
let nonce = &line[at + 5..at + 17];
|
||||
if line.contains("6973;P;") {
|
||||
run.feed(format!("\x1b]6973;P;{nonce};{shell}\x07").as_bytes());
|
||||
} else if line.contains("6973;B;") {
|
||||
let context: String = "deploy\nasset\n/home/deploy\nbash"
|
||||
.bytes()
|
||||
.map(|byte| format!("{byte:02x}"))
|
||||
.collect();
|
||||
run.feed(format!("\x1b]6973;B;{nonce};{context}\x07").as_bytes());
|
||||
std::thread::sleep(std::time::Duration::from_millis(100));
|
||||
run.feed(format!("{output}\x1b]6973;E;{nonce};{code}\x07").as_bytes());
|
||||
}
|
||||
}
|
||||
all
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_command_typed_into_the_terminal_passes_on_its_output_and_where_it_ran() {
|
||||
let fixture = fixture();
|
||||
let exec = |json| {
|
||||
let socket = fixture.socket.clone();
|
||||
let request = Request::ExecInTerminal {
|
||||
host: id(&fixture.web),
|
||||
command: "ls /srv".into(),
|
||||
};
|
||||
std::thread::spawn(move || {
|
||||
if json {
|
||||
let (code, result, _) = run_exec_json(&socket, request);
|
||||
(code, result.to_string())
|
||||
} else {
|
||||
let (code, stdout, _) = run(&socket, request, false);
|
||||
(code, stdout)
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
let command = exec(false);
|
||||
let (host, run) = take_run(&fixture.server);
|
||||
assert_eq!(host, id(&fixture.web));
|
||||
let terminal = answer_run(run, "/usr/bin/bash", "a\r\nb\r\n", 2);
|
||||
assert_eq!(command.join().unwrap(), (2, "a\nb\n".to_string()));
|
||||
assert!(terminal.join().unwrap().contains(" 'ls /srv'\r"));
|
||||
|
||||
let command = exec(true);
|
||||
let (_, run) = take_run(&fixture.server);
|
||||
answer_run(run, "/bin/busybox", "a\r\n", 0);
|
||||
let (code, result) = command.join().unwrap();
|
||||
assert_eq!(code, 0);
|
||||
assert_eq!(
|
||||
serde_json::from_str::<serde_json::Value>(&result).unwrap(),
|
||||
serde_json::json!({
|
||||
"exit_code": 0,
|
||||
"stdout": "a\n",
|
||||
"stderr": "",
|
||||
"context": {
|
||||
"user": "deploy",
|
||||
"host": "asset",
|
||||
"cwd": "/home/deploy",
|
||||
"shell": "busybox",
|
||||
"interp": "bash",
|
||||
},
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
fixture.server.take_usage(),
|
||||
[CliUse::ExecTerminal, CliUse::ExecTerminal]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_run_that_cannot_be_typed_says_why() {
|
||||
let fixture = fixture();
|
||||
let exec = || {
|
||||
let socket = fixture.socket.clone();
|
||||
let host = id(&fixture.web);
|
||||
std::thread::spawn(move || {
|
||||
let request = Request::ExecInTerminal {
|
||||
host,
|
||||
command: "true".into(),
|
||||
};
|
||||
let (code, _, stderr) = run(&socket, request, false);
|
||||
(code, String::from_utf8(stderr).unwrap())
|
||||
})
|
||||
};
|
||||
let refused = |command: std::thread::JoinHandle<(i32, String)>, code: &str| {
|
||||
let (exit, stderr) = command.join().unwrap();
|
||||
assert_eq!(exit, 255);
|
||||
assert!(
|
||||
stderr.starts_with(&format!("shellrs: [{code}] ")),
|
||||
"{stderr}"
|
||||
);
|
||||
stderr
|
||||
};
|
||||
|
||||
// No terminal for it.
|
||||
let command = exec();
|
||||
take_run(&fixture.server).1.fail(RunFailure::NoTerminal);
|
||||
refused(command, "no_terminal");
|
||||
|
||||
// A shell it cannot type into: nothing but the probe was typed.
|
||||
let command = exec();
|
||||
let terminal = answer_run(take_run(&fixture.server).1, "/usr/bin/zsh", "", 0);
|
||||
assert!(refused(command, "unsupported_shell").contains("zsh"));
|
||||
assert!(!terminal.join().unwrap().contains("6973;B;"));
|
||||
|
||||
// Nothing answers the probe.
|
||||
let command = exec();
|
||||
let (input, _typed) = std::sync::mpsc::channel();
|
||||
assert!(take_run(&fixture.server).1.accept(input));
|
||||
refused(command, "terminal_busy");
|
||||
|
||||
// Nobody takes it up: taken back, never typed.
|
||||
let command = exec();
|
||||
refused(command, "connect_failed");
|
||||
assert!(fixture.server.take_run().is_none());
|
||||
|
||||
// An unknown host never gets to the app.
|
||||
let socket = fixture.socket.clone();
|
||||
let request = Request::ExecInTerminal {
|
||||
host: "nope".into(),
|
||||
command: "true".into(),
|
||||
};
|
||||
assert_eq!(run(&socket, request, false).0, 255);
|
||||
assert!(fixture.server.take_run().is_none());
|
||||
}
|
||||
@@ -30,6 +30,7 @@ use super::highlight::{
|
||||
};
|
||||
use super::links::{LinkMarker, url_search, visible_links};
|
||||
use super::notices::{NoticeScanner, ProgramNotice, TerminalNotice};
|
||||
use super::run::{BusyReason, RunFailure, RunHandle};
|
||||
use super::search::{MatchMarker, SearchDirection, SearchMark, SearchPosition, TerminalSearch};
|
||||
use super::{
|
||||
ExecRequest, ExecResult, SharedTerminalTransportFactory, TerminalColors, TerminalLifecycle,
|
||||
@@ -493,6 +494,32 @@ impl TerminalEngine {
|
||||
self.runtime.exec(command)
|
||||
}
|
||||
|
||||
/// Take up an `exec --terminal` run (see [`super::TerminalRun`]): ^C to
|
||||
/// whatever has the terminal, the rest as the output comes. Refused
|
||||
/// while a full-screen program has the screen, which ^C does not end
|
||||
/// and the command line would be keys to, and while another run has
|
||||
/// the terminal, which the ^C would end.
|
||||
pub fn run_command(&self, run: RunHandle) -> Result<(), RunFailure> {
|
||||
if !self.lifecycle.accepts_input() {
|
||||
return Err(RunFailure::NotConnected);
|
||||
}
|
||||
if self.mode().contains(TermMode::ALT_SCREEN) {
|
||||
return Err(RunFailure::Busy(BusyReason::FullScreen));
|
||||
}
|
||||
{
|
||||
let mut slot = lock_run(&self.runtime.run);
|
||||
if slot.as_ref().is_some_and(RunHandle::is_active) {
|
||||
return Err(RunFailure::Busy(BusyReason::AnotherRun));
|
||||
}
|
||||
if !run.accept(self.runtime.commands.clone()) {
|
||||
return Ok(());
|
||||
}
|
||||
*slot = Some(run);
|
||||
}
|
||||
self.send_user_input(b"\x03".to_vec());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn status(&self) -> TerminalStatus {
|
||||
TerminalStatus::new(
|
||||
self.lifecycle.clone(),
|
||||
@@ -636,6 +663,9 @@ struct TerminalRuntime {
|
||||
commands: mpsc::Sender<TerminalTransportCommand>,
|
||||
size: Arc<Mutex<TerminalSize>>,
|
||||
wakeup_pending: Arc<AtomicBool>,
|
||||
/// The `exec --terminal` run this connection carries, which the parser
|
||||
/// thread reads the output for. Given up with the connection.
|
||||
run: Arc<Mutex<Option<RunHandle>>>,
|
||||
}
|
||||
|
||||
impl TerminalRuntime {
|
||||
@@ -662,6 +692,8 @@ impl TerminalRuntime {
|
||||
let term = Arc::new(FairMutex::new(AlacrittyTerm::new(config, &size, proxy)));
|
||||
|
||||
let parser_term = term.clone();
|
||||
let run: Arc<Mutex<Option<RunHandle>>> = Arc::default();
|
||||
let parser_run = run.clone();
|
||||
thread::Builder::new()
|
||||
.name("shellrs-terminal-parser".into())
|
||||
.spawn(move || {
|
||||
@@ -711,6 +743,7 @@ impl TerminalRuntime {
|
||||
for notice in notices.scan(&bytes) {
|
||||
parser_proxy.send(TerminalUiEventKind::Notice(notice));
|
||||
}
|
||||
feed_run(&parser_run, &bytes);
|
||||
parser_proxy.wakeup();
|
||||
}
|
||||
TerminalTransportEvent::HostOsDetected(os) => {
|
||||
@@ -753,6 +786,9 @@ impl TerminalRuntime {
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(run) = lock_run(&parser_run).take() {
|
||||
run.disconnected();
|
||||
}
|
||||
})
|
||||
.expect("terminal parser thread");
|
||||
|
||||
@@ -772,6 +808,7 @@ impl TerminalRuntime {
|
||||
commands,
|
||||
size: shared_size,
|
||||
wakeup_pending,
|
||||
run,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -971,6 +1008,21 @@ fn receive<T>(events: &async_channel::Receiver<T>, deadline: Option<Instant>) ->
|
||||
})
|
||||
}
|
||||
|
||||
/// Hand output to the run the connection carries, and let it go once it
|
||||
/// is over.
|
||||
fn feed_run(run: &Mutex<Option<RunHandle>>, bytes: &[u8]) {
|
||||
let mut slot = lock_run(run);
|
||||
if let Some(handle) = slot.as_ref()
|
||||
&& !handle.feed(bytes)
|
||||
{
|
||||
*slot = None;
|
||||
}
|
||||
}
|
||||
|
||||
fn lock_run(run: &Mutex<Option<RunHandle>>) -> std::sync::MutexGuard<'_, Option<RunHandle>> {
|
||||
run.lock().unwrap_or_else(|error| error.into_inner())
|
||||
}
|
||||
|
||||
/// Draw what an open synchronized update has held back, if its time is up.
|
||||
/// Says whether it was.
|
||||
fn end_overdue_sync(processor: &mut Processor, term: &mut AlacrittyTerm) -> bool {
|
||||
|
||||
@@ -10,6 +10,7 @@ mod local_terminal_panel;
|
||||
mod model;
|
||||
mod mouse;
|
||||
mod notices;
|
||||
mod run;
|
||||
mod search;
|
||||
mod terminal_panel;
|
||||
mod terminal_view;
|
||||
@@ -31,6 +32,10 @@ pub use model::{
|
||||
LocalTerminalId, RemoteTerminalId, TerminalLifecycle, TerminalSize, TerminalStatus,
|
||||
};
|
||||
pub use notices::TerminalNotice;
|
||||
pub use run::{
|
||||
BusyReason, RunContext, RunEvent, RunFailure, RunHandle, RunProgress, RunShell, RunTiming,
|
||||
TerminalRun,
|
||||
};
|
||||
pub use search::{SearchDirection, SearchMark, SearchPosition};
|
||||
pub use terminal_panel::{TerminalPanel, TerminalPanelEvent};
|
||||
pub(crate) use terminal_view::terminal_key_bindings;
|
||||
|
||||
@@ -0,0 +1,694 @@
|
||||
//! 在已开的终端里执行: what `shellrs exec --terminal` types into an open
|
||||
//! remote terminal, and how the result is read back out of its output.
|
||||
//!
|
||||
//! A bastion host that opened ShellRS with a link serves one login, and
|
||||
//! ends the session when another channel opens on it (see
|
||||
//! `HostLogin::shell_only`). An agent's command can then only reach the
|
||||
//! host the way the user's own do: typed into the shell. A run goes:
|
||||
//!
|
||||
//! 1. ^C, which ends what runs in the foreground and empties the input
|
||||
//! line, in bash, dash and busybox ash alike. Nothing checks first what
|
||||
//! the terminal is doing: whoever hands a tab to an agent knows.
|
||||
//! 2. A moment later, a probe, whose answer (marker P) says the shell
|
||||
//! reads lines again and which shell it is.
|
||||
//! 3. Once P comes, the command line. It prints marker B with where it
|
||||
//! runs, then the command's output through a pipe, then marker E with
|
||||
//! the exit code.
|
||||
//!
|
||||
//! The markers are an OSC sequence alacritty ignores, with the run's
|
||||
//! nonce an argument of its own. The echo of a typed line has `\033` as
|
||||
//! four characters, never an escape, so only the run's own `printf` makes
|
||||
//! them.
|
||||
|
||||
use std::io;
|
||||
use std::sync::{Arc, Mutex, MutexGuard, mpsc};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use rand::{Rng as _, distr::Alphanumeric};
|
||||
|
||||
use super::TerminalTransportCommand;
|
||||
|
||||
/// What every marker starts with: an OSC number no terminal uses.
|
||||
const MARKER: &[u8] = b"\x1b]6973;";
|
||||
/// The longest marker waited for: room for a context whose directory is
|
||||
/// as long as paths get, in hex.
|
||||
const MARKER_LIMIT: usize = 16 * 1024;
|
||||
const NONCE_LENGTH: usize = 12;
|
||||
|
||||
/// Prints P with the nonce and the program the interactive shell runs:
|
||||
/// `sh -c`'s parent is that shell. Not `/proc/<pid>/comm`, which says `sh`
|
||||
/// for a bash started as sh.
|
||||
const PROBE_SCRIPT: &str =
|
||||
r#"printf "\033]6973;P;%s;%s\007" "$1" "$(readlink /proc/$PPID/exe 2>/dev/null)""#;
|
||||
|
||||
/// Runs the command (`$2`, as `printf %b` reads it) with bash where there
|
||||
/// is one and sh where not, its output through a pipe so that programs
|
||||
/// see no terminal: no colors, no pager. B comes first with the user, the
|
||||
/// host, the directory and the interpreter, in hex so that no path can
|
||||
/// break the marker; E comes last, printed by this shell after the pipe.
|
||||
/// It survives a ^C (`trap : INT`) while the command, in a subshell whose
|
||||
/// trap is reset, does not; the status comes out on fd 3, and none means
|
||||
/// the command was interrupted. No `'`, `!` or `\\` in it: it is typed in
|
||||
/// single quotes.
|
||||
const COMMAND_SCRIPT: &str = concat!(
|
||||
r#"trap : INT; i=$(command -v bash || echo sh); "#,
|
||||
r#"c=$(printf "%s\n%s\n%s\n%s" "$(id -un)" "$(uname -n)" "$PWD" "${i##*/}" "#,
|
||||
r#"| od -An -tx1 2>/dev/null | tr -d " \n"); "#,
|
||||
r#"printf "\033]6973;B;%s;%s\007" "$1" "$c"; exec 4>&1; "#,
|
||||
r#"s=$( { { "$i" -c "$(printf %b "$2")" </dev/null 2>&1 3>&- 4>&-; printf %d $? >&3; } "#,
|
||||
r#"| cat >&4; } 3>&1 ); "#,
|
||||
r#"printf "\033]6973;E;%s;%s\007" "$1" "${s:-130}""#,
|
||||
);
|
||||
|
||||
/// The interactive shells a command can be typed into.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum RunShell {
|
||||
Bash,
|
||||
Dash,
|
||||
Busybox,
|
||||
}
|
||||
|
||||
impl RunShell {
|
||||
/// The shell whose program is `exe`, as `/proc/<pid>/exe` reads.
|
||||
pub fn of(exe: &str) -> Option<Self> {
|
||||
match program_name(exe) {
|
||||
"bash" => Some(Self::Bash),
|
||||
"dash" => Some(Self::Dash),
|
||||
"busybox" => Some(Self::Busybox),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
Self::Bash => "bash",
|
||||
Self::Dash => "dash",
|
||||
Self::Busybox => "busybox",
|
||||
}
|
||||
}
|
||||
|
||||
/// The longest command line it takes, Enter included. The line is
|
||||
/// typed ahead while the probe still runs, when the terminal holds a
|
||||
/// line of 4095 bytes at most; busybox's own line editor holds 1024.
|
||||
pub fn line_limit(self) -> usize {
|
||||
match self {
|
||||
Self::Busybox => 1000,
|
||||
Self::Bash | Self::Dash => 4000,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The file name of `exe`, without the mark Linux adds once the program
|
||||
/// was upgraded under a running shell.
|
||||
fn program_name(exe: &str) -> &str {
|
||||
let exe = exe.trim();
|
||||
let exe = exe.strip_suffix(" (deleted)").unwrap_or(exe);
|
||||
exe.rsplit('/').next().unwrap_or(exe)
|
||||
}
|
||||
|
||||
/// Where the command ran.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct RunContext {
|
||||
pub user: String,
|
||||
pub host: String,
|
||||
pub cwd: String,
|
||||
/// The interactive shell it was typed into.
|
||||
pub shell: String,
|
||||
/// What ran it: `bash`, or `sh` where there is no bash.
|
||||
pub interp: String,
|
||||
}
|
||||
|
||||
impl RunContext {
|
||||
/// The context from marker B's hex: user, host, directory and
|
||||
/// interpreter, a line each. A directory may hold newlines itself, so
|
||||
/// it is what is left between the first two and the last.
|
||||
fn read(hex: &[u8], shell: &str) -> Self {
|
||||
let bytes: Vec<u8> = hex
|
||||
.as_chunks::<2>()
|
||||
.0
|
||||
.iter()
|
||||
.filter_map(|pair| u8::from_str_radix(std::str::from_utf8(pair).ok()?, 16).ok())
|
||||
.collect();
|
||||
let text = String::from_utf8_lossy(&bytes);
|
||||
let mut context = Self {
|
||||
shell: shell.to_owned(),
|
||||
..Self::default()
|
||||
};
|
||||
let mut parts = text.splitn(3, '\n');
|
||||
context.user = parts.next().unwrap_or_default().to_owned();
|
||||
context.host = parts.next().unwrap_or_default().to_owned();
|
||||
if let Some((cwd, interp)) = parts.next().unwrap_or_default().rsplit_once('\n') {
|
||||
context.cwd = cwd.to_owned();
|
||||
context.interp = interp.to_owned();
|
||||
}
|
||||
context
|
||||
}
|
||||
}
|
||||
|
||||
/// What a run tells the request it serves, in order.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub enum RunEvent {
|
||||
/// A terminal took the run up and sent its ^C.
|
||||
Accepted,
|
||||
/// The shell answered the probe and the command line was typed.
|
||||
Typed,
|
||||
/// The command started.
|
||||
Begun(RunContext),
|
||||
Output(Vec<u8>),
|
||||
/// The command ended with this exit code.
|
||||
Exited(i32),
|
||||
Failed(RunFailure),
|
||||
}
|
||||
|
||||
/// Why a run did not run, or did not finish.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum RunFailure {
|
||||
HostNotFound,
|
||||
/// The host has no terminal open.
|
||||
NoTerminal,
|
||||
/// Its terminals are not connected.
|
||||
NotConnected,
|
||||
/// The program of the interactive shell; empty when it could not be
|
||||
/// read (no `/proc`).
|
||||
UnsupportedShell(String),
|
||||
TooLong {
|
||||
limit: usize,
|
||||
},
|
||||
Busy(BusyReason),
|
||||
/// The terminal went away after the command was typed: it may have
|
||||
/// run, in part.
|
||||
Disconnected,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum BusyReason {
|
||||
/// A full-screen program has the screen, which ^C does not end.
|
||||
FullScreen,
|
||||
/// Another run has the terminal.
|
||||
AnotherRun,
|
||||
/// Nothing answered the probe.
|
||||
NoAnswer,
|
||||
/// The shell did not start the command.
|
||||
NotStarted,
|
||||
}
|
||||
|
||||
/// How long each step of a run may take.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct RunTiming {
|
||||
/// After the ^C, for the shell to show its prompt again: bytes coming
|
||||
/// with the ^C may go with it.
|
||||
pub settle: Duration,
|
||||
pub probe: Duration,
|
||||
/// From typing the command to its marker B. A bastion host's command
|
||||
/// filter or review may hold it back.
|
||||
pub start: Duration,
|
||||
/// How often the caller hears of a run that prints nothing.
|
||||
pub tick: Duration,
|
||||
}
|
||||
|
||||
impl RunTiming {
|
||||
pub const STANDARD: Self = Self {
|
||||
settle: Duration::from_millis(500),
|
||||
probe: Duration::from_secs(5),
|
||||
start: Duration::from_secs(15),
|
||||
tick: Duration::from_secs(1),
|
||||
};
|
||||
}
|
||||
|
||||
/// What a run hands its caller while it runs.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub enum RunProgress {
|
||||
Context(RunContext),
|
||||
Output(Vec<u8>),
|
||||
/// Nothing happened for a tick.
|
||||
Idle,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum Phase {
|
||||
/// Waiting for a terminal to take it up.
|
||||
Queued,
|
||||
/// ^C sent.
|
||||
Installed,
|
||||
Probing,
|
||||
Typed,
|
||||
Begun,
|
||||
Done,
|
||||
Abandoned,
|
||||
}
|
||||
|
||||
impl Phase {
|
||||
fn is_active(self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
Phase::Installed | Phase::Probing | Phase::Typed | Phase::Begun
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
struct RunState {
|
||||
nonce: String,
|
||||
command: String,
|
||||
phase: Phase,
|
||||
shell: Option<RunShell>,
|
||||
scanner: RunScanner,
|
||||
/// The terminal's input, once one took the run up.
|
||||
input: Option<mpsc::Sender<TerminalTransportCommand>>,
|
||||
events: mpsc::Sender<RunEvent>,
|
||||
}
|
||||
|
||||
impl RunState {
|
||||
fn type_bytes(&self, bytes: impl Into<Vec<u8>>) {
|
||||
if let Some(input) = &self.input {
|
||||
let _ = input.send(TerminalTransportCommand::Write(bytes.into()));
|
||||
}
|
||||
}
|
||||
|
||||
fn tell(&self, event: RunEvent) {
|
||||
let _ = self.events.send(event);
|
||||
}
|
||||
|
||||
fn fail(&mut self, failure: RunFailure) {
|
||||
self.phase = Phase::Abandoned;
|
||||
self.tell(RunEvent::Failed(failure));
|
||||
}
|
||||
|
||||
/// The shell answered the probe: type the command, if it is one the
|
||||
/// line can be typed into and the line fits.
|
||||
fn probed(&mut self, exe: &str) {
|
||||
let Some(shell) = RunShell::of(exe) else {
|
||||
self.fail(RunFailure::UnsupportedShell(program_name(exe).to_owned()));
|
||||
return;
|
||||
};
|
||||
let mut line = command_line(&self.nonce, &self.command);
|
||||
line.push('\r');
|
||||
if line.len() > shell.line_limit() {
|
||||
self.fail(RunFailure::TooLong {
|
||||
limit: shell.line_limit(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
self.type_bytes(line);
|
||||
self.shell = Some(shell);
|
||||
self.phase = Phase::Typed;
|
||||
self.tell(RunEvent::Typed);
|
||||
}
|
||||
}
|
||||
|
||||
/// One `exec --terminal` run, as the request it serves holds it. Dropping
|
||||
/// it gives the run up, with a ^C when its command was typed.
|
||||
pub struct TerminalRun {
|
||||
state: Arc<Mutex<RunState>>,
|
||||
}
|
||||
|
||||
/// The same run, as the terminal it runs in holds it.
|
||||
#[derive(Clone)]
|
||||
pub struct RunHandle {
|
||||
state: Arc<Mutex<RunState>>,
|
||||
}
|
||||
|
||||
fn lock(state: &Mutex<RunState>) -> MutexGuard<'_, RunState> {
|
||||
state.lock().unwrap_or_else(|error| error.into_inner())
|
||||
}
|
||||
|
||||
impl TerminalRun {
|
||||
pub fn new(command: String) -> (Self, mpsc::Receiver<RunEvent>) {
|
||||
let nonce: String = rand::rng()
|
||||
.sample_iter(Alphanumeric)
|
||||
.take(NONCE_LENGTH)
|
||||
.map(char::from)
|
||||
.collect();
|
||||
let (events, receiver) = mpsc::channel();
|
||||
let state = RunState {
|
||||
scanner: RunScanner::new(&nonce),
|
||||
nonce,
|
||||
command,
|
||||
phase: Phase::Queued,
|
||||
shell: None,
|
||||
input: None,
|
||||
events,
|
||||
};
|
||||
(
|
||||
Self {
|
||||
state: Arc::new(Mutex::new(state)),
|
||||
},
|
||||
receiver,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn handle(&self) -> RunHandle {
|
||||
RunHandle {
|
||||
state: self.state.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Type the probe, once the terminal has sent its ^C.
|
||||
pub fn type_probe(&self) {
|
||||
let mut state = lock(&self.state);
|
||||
if state.phase == Phase::Installed {
|
||||
let mut line = probe_line(&state.nonce);
|
||||
line.push('\r');
|
||||
state.type_bytes(line);
|
||||
state.phase = Phase::Probing;
|
||||
}
|
||||
}
|
||||
|
||||
/// Give the run up for `reason`, unless it got past that meanwhile:
|
||||
/// whether it was given up. A command typed and not started gets a ^C,
|
||||
/// which also withdraws one a bastion host holds for review.
|
||||
pub fn give_up(&self, reason: BusyReason) -> bool {
|
||||
let mut state = lock(&self.state);
|
||||
let given_up = match reason {
|
||||
BusyReason::NotStarted => state.phase == Phase::Typed,
|
||||
_ => matches!(
|
||||
state.phase,
|
||||
Phase::Queued | Phase::Installed | Phase::Probing
|
||||
),
|
||||
};
|
||||
if given_up {
|
||||
if state.phase == Phase::Typed {
|
||||
state.type_bytes(b"\x03".to_vec());
|
||||
}
|
||||
state.phase = Phase::Abandoned;
|
||||
}
|
||||
given_up
|
||||
}
|
||||
|
||||
/// Give the run up whatever it does: ^C to a command typed and not
|
||||
/// finished.
|
||||
pub fn cancel(&self) {
|
||||
let mut state = lock(&self.state);
|
||||
match state.phase {
|
||||
Phase::Done | Phase::Abandoned => return,
|
||||
Phase::Typed | Phase::Begun => state.type_bytes(b"\x03".to_vec()),
|
||||
Phase::Queued | Phase::Installed | Phase::Probing => {}
|
||||
}
|
||||
state.phase = Phase::Abandoned;
|
||||
}
|
||||
|
||||
/// See the run through once a terminal took it up (after
|
||||
/// [`RunEvent::Accepted`]): probe, wait for the command, and hand what
|
||||
/// it prints to `progress`, with [`RunProgress::Idle`] every quiet
|
||||
/// tick. The exit code, or why there is none. When `progress` fails,
|
||||
/// its reader is gone: the run is cancelled, and given up as
|
||||
/// [`RunFailure::Disconnected`], which nobody hears.
|
||||
pub fn drive(
|
||||
&self,
|
||||
events: &mpsc::Receiver<RunEvent>,
|
||||
timing: RunTiming,
|
||||
progress: &mut dyn FnMut(RunProgress) -> io::Result<()>,
|
||||
) -> Result<i32, RunFailure> {
|
||||
std::thread::sleep(timing.settle);
|
||||
self.type_probe();
|
||||
let mut deadline = Some((Instant::now() + timing.probe, BusyReason::NoAnswer));
|
||||
loop {
|
||||
let next = match events.recv_timeout(timing.tick) {
|
||||
Ok(RunEvent::Accepted) => continue,
|
||||
Ok(RunEvent::Typed) => {
|
||||
deadline = Some((Instant::now() + timing.start, BusyReason::NotStarted));
|
||||
continue;
|
||||
}
|
||||
Ok(RunEvent::Begun(context)) => {
|
||||
deadline = None;
|
||||
RunProgress::Context(context)
|
||||
}
|
||||
Ok(RunEvent::Output(bytes)) => RunProgress::Output(bytes),
|
||||
Ok(RunEvent::Exited(code)) => return Ok(code),
|
||||
Ok(RunEvent::Failed(failure)) => return Err(failure),
|
||||
Err(mpsc::RecvTimeoutError::Timeout) => {
|
||||
if let Some((at, reason)) = deadline
|
||||
&& Instant::now() >= at
|
||||
{
|
||||
if self.give_up(reason) {
|
||||
return Err(RunFailure::Busy(reason));
|
||||
}
|
||||
// It got further just now: its event is on the way.
|
||||
deadline = None;
|
||||
}
|
||||
RunProgress::Idle
|
||||
}
|
||||
// The run itself holds the sender.
|
||||
Err(mpsc::RecvTimeoutError::Disconnected) => return Err(RunFailure::Disconnected),
|
||||
};
|
||||
if progress(next).is_err() {
|
||||
self.cancel();
|
||||
return Err(RunFailure::Disconnected);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TerminalRun {
|
||||
fn drop(&mut self) {
|
||||
self.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
impl RunHandle {
|
||||
/// Take the run up in a terminal whose input is `input`; the ^C is the
|
||||
/// terminal's to send. False when the request gave it up already.
|
||||
pub fn accept(&self, input: mpsc::Sender<TerminalTransportCommand>) -> bool {
|
||||
let mut state = lock(&self.state);
|
||||
if state.phase != Phase::Queued {
|
||||
return false;
|
||||
}
|
||||
state.input = Some(input);
|
||||
state.phase = Phase::Installed;
|
||||
state.tell(RunEvent::Accepted);
|
||||
true
|
||||
}
|
||||
|
||||
/// Say why no terminal can take the run up.
|
||||
pub fn fail(&self, failure: RunFailure) {
|
||||
let mut state = lock(&self.state);
|
||||
if state.phase == Phase::Queued {
|
||||
state.fail(failure);
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the run still has its terminal: one given up does not,
|
||||
/// whatever its command still does.
|
||||
pub(super) fn is_active(&self) -> bool {
|
||||
lock(&self.state).phase.is_active()
|
||||
}
|
||||
|
||||
/// Read the terminal's output for the run's markers and output;
|
||||
/// whether the run goes on.
|
||||
pub fn feed(&self, bytes: &[u8]) -> bool {
|
||||
let mut state = lock(&self.state);
|
||||
if !state.phase.is_active() {
|
||||
return false;
|
||||
}
|
||||
for scanned in state.scanner.scan(bytes) {
|
||||
match (scanned, state.phase) {
|
||||
(Scanned::Probe(exe), Phase::Probing) => state.probed(&exe),
|
||||
(Scanned::Begin(hex), Phase::Typed) => {
|
||||
let shell = state.shell.map(RunShell::name).unwrap_or_default();
|
||||
state.phase = Phase::Begun;
|
||||
state.tell(RunEvent::Begun(RunContext::read(&hex, shell)));
|
||||
}
|
||||
(Scanned::Output(bytes), Phase::Begun) => state.tell(RunEvent::Output(bytes)),
|
||||
(Scanned::End(code), Phase::Begun) => {
|
||||
state.phase = Phase::Done;
|
||||
state.tell(RunEvent::Exited(code));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
state.phase.is_active()
|
||||
}
|
||||
|
||||
/// The terminal's connection is gone.
|
||||
pub(super) fn disconnected(&self) {
|
||||
let mut state = lock(&self.state);
|
||||
match state.phase {
|
||||
Phase::Installed | Phase::Probing => state.fail(RunFailure::NotConnected),
|
||||
Phase::Typed | Phase::Begun => state.fail(RunFailure::Disconnected),
|
||||
Phase::Queued | Phase::Done | Phase::Abandoned => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The probe line, with a leading space so that bash with `ignorespace`
|
||||
/// keeps it out of the history.
|
||||
fn probe_line(nonce: &str) -> String {
|
||||
format!(" sh -c '{PROBE_SCRIPT}' sh {nonce}")
|
||||
}
|
||||
|
||||
/// The command line: `command` as [`encode_command`] writes it.
|
||||
fn command_line(nonce: &str, command: &str) -> String {
|
||||
format!(
|
||||
" sh -c '{COMMAND_SCRIPT}' sh {nonce} '{}'",
|
||||
encode_command(command.as_bytes())
|
||||
)
|
||||
}
|
||||
|
||||
/// `command` as `printf %b` reads it back, with nothing in it a shell's
|
||||
/// line editor or single quotes would take for their own: control
|
||||
/// characters, quotes, `\`, `!` (csh-style history) and every byte above
|
||||
/// ASCII (Meta keys to readline) as `\0NNN`. The rest reads as it is, for
|
||||
/// the user and a bastion host's command audit alike.
|
||||
pub(super) fn encode_command(command: &[u8]) -> String {
|
||||
let mut encoded = String::with_capacity(command.len());
|
||||
for &byte in command {
|
||||
if !(0x20..0x7f).contains(&byte) || matches!(byte, b'\'' | b'\\' | b'!' | b'"') {
|
||||
encoded.push_str(&format!("\\0{byte:03o}"));
|
||||
} else {
|
||||
encoded.push(char::from(byte));
|
||||
}
|
||||
}
|
||||
encoded
|
||||
}
|
||||
|
||||
/// What the scanner found in the output, in order.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
enum Scanned {
|
||||
/// Marker P, with the interactive shell's program.
|
||||
Probe(String),
|
||||
/// Marker B, with the context in hex.
|
||||
Begin(Vec<u8>),
|
||||
/// What the command printed, between B and E, with CRLF as LF.
|
||||
Output(Vec<u8>),
|
||||
End(i32),
|
||||
}
|
||||
|
||||
/// Finds a run's markers in terminal output, however it is split into
|
||||
/// chunks, and what the command printed between B and E.
|
||||
struct RunScanner {
|
||||
nonce: Vec<u8>,
|
||||
/// What may still turn out to be (part of) a marker.
|
||||
pending: Vec<u8>,
|
||||
capturing: bool,
|
||||
/// A CR the next chunk may make CRLF.
|
||||
carriage_return: bool,
|
||||
}
|
||||
|
||||
impl RunScanner {
|
||||
fn new(nonce: &str) -> Self {
|
||||
Self {
|
||||
nonce: nonce.as_bytes().to_vec(),
|
||||
pending: Vec::new(),
|
||||
capturing: false,
|
||||
carriage_return: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn scan(&mut self, bytes: &[u8]) -> Vec<Scanned> {
|
||||
let mut text = std::mem::take(&mut self.pending);
|
||||
text.extend_from_slice(bytes);
|
||||
let mut found = Vec::new();
|
||||
let mut output = Vec::new();
|
||||
let mut rest = text.as_slice();
|
||||
loop {
|
||||
let Some(at) = find(rest, MARKER) else {
|
||||
let kept = partial_marker(rest);
|
||||
self.capture(&rest[..rest.len() - kept], &mut output);
|
||||
self.pending = rest[rest.len() - kept..].to_vec();
|
||||
break;
|
||||
};
|
||||
self.capture(&rest[..at], &mut output);
|
||||
let body = &rest[at + MARKER.len()..];
|
||||
match terminator(body) {
|
||||
Some((end, length)) => {
|
||||
if let Some(marker) = self.marker(&body[..end], &mut output) {
|
||||
if !output.is_empty() {
|
||||
found.push(Scanned::Output(std::mem::take(&mut output)));
|
||||
}
|
||||
found.push(marker);
|
||||
}
|
||||
rest = &body[end + length..];
|
||||
}
|
||||
// Too long to be one: not a marker after all.
|
||||
None if body.len() > MARKER_LIMIT => {
|
||||
self.capture(&rest[at..at + MARKER.len()], &mut output);
|
||||
rest = body;
|
||||
}
|
||||
None => {
|
||||
self.pending = rest[at..].to_vec();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if !output.is_empty() {
|
||||
found.push(Scanned::Output(output));
|
||||
}
|
||||
found
|
||||
}
|
||||
|
||||
/// Keep `bytes` when they are the command's, with CRLF as LF.
|
||||
fn capture(&mut self, bytes: &[u8], output: &mut Vec<u8>) {
|
||||
if !self.capturing {
|
||||
return;
|
||||
}
|
||||
for &byte in bytes {
|
||||
if std::mem::take(&mut self.carriage_return) && byte != b'\n' {
|
||||
output.push(b'\r');
|
||||
}
|
||||
if byte == b'\r' {
|
||||
self.carriage_return = true;
|
||||
} else {
|
||||
output.push(byte);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The marker `body` (after `ESC ] 6973 ;`) is, if it is this run's.
|
||||
fn marker(&mut self, body: &[u8], output: &mut Vec<u8>) -> Option<Scanned> {
|
||||
let mut fields = body.splitn(3, |byte| *byte == b';');
|
||||
let (kind, nonce, payload) = (fields.next()?, fields.next()?, fields.next()?);
|
||||
if nonce != self.nonce.as_slice() {
|
||||
return None;
|
||||
}
|
||||
match kind {
|
||||
b"P" => Some(Scanned::Probe(
|
||||
String::from_utf8_lossy(payload).into_owned(),
|
||||
)),
|
||||
b"B" => {
|
||||
self.capturing = true;
|
||||
Some(Scanned::Begin(payload.to_vec()))
|
||||
}
|
||||
b"E" => {
|
||||
if std::mem::take(&mut self.carriage_return) {
|
||||
output.push(b'\r');
|
||||
}
|
||||
self.capturing = false;
|
||||
// Never anything but digits; the run ends either way.
|
||||
let code = std::str::from_utf8(payload)
|
||||
.ok()
|
||||
.and_then(|code| code.trim().parse().ok())
|
||||
.unwrap_or(255);
|
||||
Some(Scanned::End(code))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn find(haystack: &[u8], needle: &[u8]) -> Option<usize> {
|
||||
haystack
|
||||
.windows(needle.len())
|
||||
.position(|window| window == needle)
|
||||
}
|
||||
|
||||
/// How many bytes at the end of `bytes` begin a marker.
|
||||
fn partial_marker(bytes: &[u8]) -> usize {
|
||||
(1..MARKER.len())
|
||||
.rev()
|
||||
.find(|&length| length <= bytes.len() && bytes.ends_with(&MARKER[..length]))
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Where an OSC body ends, and how long its terminator is: BEL or ST.
|
||||
fn terminator(body: &[u8]) -> Option<(usize, usize)> {
|
||||
body.iter()
|
||||
.enumerate()
|
||||
.find_map(|(index, byte)| match byte {
|
||||
0x07 => Some((index, 1)),
|
||||
0x1b if body.get(index + 1) == Some(&b'\\') => Some((index, 2)),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "run_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,708 @@
|
||||
use std::sync::mpsc;
|
||||
use std::time::Duration;
|
||||
|
||||
use super::*;
|
||||
|
||||
/// Timing for runs whose terminal is a thread of the test.
|
||||
const QUICK: RunTiming = RunTiming {
|
||||
settle: Duration::ZERO,
|
||||
probe: Duration::from_millis(200),
|
||||
start: Duration::from_millis(200),
|
||||
tick: Duration::from_millis(10),
|
||||
};
|
||||
|
||||
fn nonce(run: &TerminalRun) -> String {
|
||||
lock(&run.state).nonce.clone()
|
||||
}
|
||||
|
||||
fn marker(kind: &str, nonce: &str, payload: &str) -> String {
|
||||
format!("\x1b]6973;{kind};{nonce};{payload}\x07")
|
||||
}
|
||||
|
||||
fn hex(text: &str) -> String {
|
||||
text.bytes().map(|byte| format!("{byte:02x}")).collect()
|
||||
}
|
||||
|
||||
/// What was typed into the terminal so far, as text.
|
||||
fn typed(input: &mpsc::Receiver<TerminalTransportCommand>) -> String {
|
||||
input
|
||||
.try_iter()
|
||||
.map(|command| match command {
|
||||
TerminalTransportCommand::Write(bytes) => String::from_utf8(bytes).unwrap(),
|
||||
_ => String::new(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A run a terminal took up, with the terminal's input.
|
||||
fn accepted(
|
||||
command: &str,
|
||||
) -> (
|
||||
TerminalRun,
|
||||
mpsc::Receiver<RunEvent>,
|
||||
mpsc::Receiver<TerminalTransportCommand>,
|
||||
) {
|
||||
let (run, events) = TerminalRun::new(command.to_owned());
|
||||
let (input, typed) = mpsc::channel();
|
||||
assert!(run.handle().accept(input));
|
||||
assert_eq!(events.try_recv(), Ok(RunEvent::Accepted));
|
||||
(run, events, typed)
|
||||
}
|
||||
|
||||
/// What the scanner found, with the output of successive chunks joined.
|
||||
fn joined(found: Vec<Scanned>) -> Vec<Scanned> {
|
||||
let mut joined: Vec<Scanned> = Vec::new();
|
||||
for scanned in found {
|
||||
match (joined.last_mut(), scanned) {
|
||||
(Some(Scanned::Output(before)), Scanned::Output(more)) => before.extend(more),
|
||||
(_, scanned) => joined.push(scanned),
|
||||
}
|
||||
}
|
||||
joined
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn markers_are_found_however_the_output_is_split() {
|
||||
let mut scanner = RunScanner::new("abc");
|
||||
let begin = marker("B", "abc", "6869");
|
||||
let mut found = scanner.scan(format!("$ echo\r\n{begin}out\r\nline2\r").as_bytes());
|
||||
found.extend(scanner.scan(b"\n\x1b]69"));
|
||||
found.extend(scanner.scan(b"73;E;abc;3\x07$ "));
|
||||
assert_eq!(
|
||||
joined(found),
|
||||
vec![
|
||||
Scanned::Begin(b"6869".to_vec()),
|
||||
Scanned::Output(b"out\nline2\n".to_vec()),
|
||||
Scanned::End(3),
|
||||
]
|
||||
);
|
||||
// After E, the prompt is not the command's.
|
||||
assert!(scanner.scan(b"more\r\n").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_this_runs_markers_count_and_either_terminator_ends_them() {
|
||||
let mut scanner = RunScanner::new("abc");
|
||||
let found = scanner.scan(
|
||||
format!(
|
||||
"{}{}\x1b]6973;P;abc;/usr/bin/bash\x1b\\",
|
||||
marker("P", "other", "/bin/zsh"),
|
||||
marker("E", "abc", "oops"),
|
||||
)
|
||||
.as_bytes(),
|
||||
);
|
||||
assert_eq!(
|
||||
found,
|
||||
vec![Scanned::End(255), Scanned::Probe("/usr/bin/bash".into())]
|
||||
);
|
||||
// The echo of a typed line has the escape as text.
|
||||
assert!(
|
||||
scanner
|
||||
.scan(br#" sh -c 'printf "\033]6973;P;%s" "$1"' sh abc"#)
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_lone_carriage_return_stays_and_crlf_becomes_lf() {
|
||||
let mut scanner = RunScanner::new("n");
|
||||
let found = scanner.scan(
|
||||
format!(
|
||||
"{}50%\r100%\r\n{}",
|
||||
marker("B", "n", ""),
|
||||
marker("E", "n", "0")
|
||||
)
|
||||
.as_bytes(),
|
||||
);
|
||||
assert_eq!(found[1], Scanned::Output(b"50%\r100%\n".to_vec()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shells_are_told_by_their_program() {
|
||||
assert_eq!(RunShell::of("/usr/bin/bash"), Some(RunShell::Bash));
|
||||
// Upgraded under the running shell.
|
||||
assert_eq!(
|
||||
RunShell::of("/usr/bin/bash (deleted)"),
|
||||
Some(RunShell::Bash)
|
||||
);
|
||||
assert_eq!(RunShell::of("/usr/bin/dash"), Some(RunShell::Dash));
|
||||
assert_eq!(RunShell::of("/bin/busybox\n"), Some(RunShell::Busybox));
|
||||
assert_eq!(RunShell::of("/usr/bin/zsh"), None);
|
||||
assert_eq!(RunShell::of(""), None);
|
||||
assert_eq!(program_name("/tmp/mysh"), "mysh");
|
||||
assert_eq!(RunShell::Busybox.line_limit(), 1000);
|
||||
assert_eq!(RunShell::Dash.line_limit(), 4000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_context_keeps_a_directory_with_newlines_whole() {
|
||||
let context = RunContext::read(hex("root\nweb-01\n/srv/a\nb\nbash").as_bytes(), "dash");
|
||||
assert_eq!(
|
||||
context,
|
||||
RunContext {
|
||||
user: "root".into(),
|
||||
host: "web-01".into(),
|
||||
cwd: "/srv/a\nb".into(),
|
||||
shell: "dash".into(),
|
||||
interp: "bash".into(),
|
||||
}
|
||||
);
|
||||
// `od` missing: nothing known, the run goes on.
|
||||
assert_eq!(RunContext::read(b"", "bash").user, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_run_probes_then_types_its_command_and_reads_its_output() {
|
||||
let (run, events, input) = accepted("uname -a");
|
||||
let handle = run.handle();
|
||||
let nonce = nonce(&run);
|
||||
run.type_probe();
|
||||
let probe = typed(&input);
|
||||
assert!(probe.starts_with(" sh -c '") && probe.ends_with(&format!(" sh {nonce}\r")));
|
||||
|
||||
assert!(handle.feed(marker("P", &nonce, "/usr/bin/bash").as_bytes()));
|
||||
assert_eq!(events.try_recv(), Ok(RunEvent::Typed));
|
||||
let command = typed(&input);
|
||||
assert!(
|
||||
command.ends_with(&format!(" sh {nonce} 'uname -a'\r")),
|
||||
"{command}"
|
||||
);
|
||||
|
||||
let context = hex("root\nweb-01\n/root\nbash");
|
||||
let output = format!(
|
||||
"{}Linux\r\n{}$ ",
|
||||
marker("B", &nonce, &context),
|
||||
marker("E", &nonce, "0")
|
||||
);
|
||||
assert!(!handle.feed(output.as_bytes()));
|
||||
let RunEvent::Begun(context) = events.try_recv().unwrap() else {
|
||||
panic!("not begun");
|
||||
};
|
||||
assert_eq!(
|
||||
(context.user.as_str(), context.shell.as_str()),
|
||||
("root", "bash")
|
||||
);
|
||||
assert_eq!(events.try_recv(), Ok(RunEvent::Output(b"Linux\n".to_vec())));
|
||||
assert_eq!(events.try_recv(), Ok(RunEvent::Exited(0)));
|
||||
// Finished: dropping it sends nothing.
|
||||
drop(run);
|
||||
assert_eq!(typed(&input), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_answer_after_the_run_was_given_up_types_nothing() {
|
||||
let (run, events, input) = accepted("rm -rf build");
|
||||
let handle = run.handle();
|
||||
run.type_probe();
|
||||
typed(&input);
|
||||
assert!(run.give_up(BusyReason::NoAnswer));
|
||||
assert!(!handle.feed(marker("P", &nonce(&run), "/usr/bin/bash").as_bytes()));
|
||||
assert_eq!(typed(&input), "");
|
||||
assert!(events.try_recv().is_err());
|
||||
assert!(!handle.is_active());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_a_typed_command_gets_a_ctrl_c() {
|
||||
let (run, _events, input) = accepted("true");
|
||||
run.type_probe();
|
||||
typed(&input);
|
||||
// Probing: the probe is harmless where it waits.
|
||||
assert!(!run.give_up(BusyReason::NotStarted));
|
||||
run.cancel();
|
||||
assert_eq!(typed(&input), "");
|
||||
|
||||
let (run, _events, input) = accepted("sleep 9");
|
||||
run.type_probe();
|
||||
run.handle()
|
||||
.feed(marker("P", &nonce(&run), "/bin/busybox").as_bytes());
|
||||
typed(&input);
|
||||
drop(run);
|
||||
assert_eq!(typed(&input), "\x03");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_shell_other_than_the_three_or_a_line_too_long_types_nothing() {
|
||||
let (run, events, input) = accepted("true");
|
||||
run.type_probe();
|
||||
typed(&input);
|
||||
run.handle()
|
||||
.feed(marker("P", &nonce(&run), "/usr/bin/zsh").as_bytes());
|
||||
assert_eq!(
|
||||
events.try_recv(),
|
||||
Ok(RunEvent::Failed(RunFailure::UnsupportedShell("zsh".into())))
|
||||
);
|
||||
assert_eq!(typed(&input), "");
|
||||
|
||||
let (run, events, input) = accepted(&"x".repeat(1000));
|
||||
run.type_probe();
|
||||
typed(&input);
|
||||
run.handle()
|
||||
.feed(marker("P", &nonce(&run), "/bin/busybox").as_bytes());
|
||||
assert_eq!(
|
||||
events.try_recv(),
|
||||
Ok(RunEvent::Failed(RunFailure::TooLong { limit: 1000 }))
|
||||
);
|
||||
assert_eq!(typed(&input), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_terminal_gone_says_whether_the_command_may_have_run() {
|
||||
let (run, events, _input) = accepted("true");
|
||||
run.type_probe();
|
||||
run.handle().disconnected();
|
||||
assert_eq!(
|
||||
events.try_recv(),
|
||||
Ok(RunEvent::Failed(RunFailure::NotConnected))
|
||||
);
|
||||
|
||||
let (run, events, _input) = accepted("true");
|
||||
run.type_probe();
|
||||
run.handle()
|
||||
.feed(marker("P", &nonce(&run), "/usr/bin/bash").as_bytes());
|
||||
events.try_recv().unwrap();
|
||||
run.handle().disconnected();
|
||||
assert_eq!(
|
||||
events.try_recv(),
|
||||
Ok(RunEvent::Failed(RunFailure::Disconnected))
|
||||
);
|
||||
}
|
||||
|
||||
/// How a fake shell answers the command line.
|
||||
#[derive(Clone, Copy)]
|
||||
enum Answer {
|
||||
/// B, this output and E with 7.
|
||||
Runs(&'static str),
|
||||
/// B alone: still running.
|
||||
Starts,
|
||||
/// Nothing: held back.
|
||||
Nothing,
|
||||
}
|
||||
|
||||
/// Answers a run's lines the way a shell would, on a thread: P at once,
|
||||
/// then the command line as `answer` says. Stops once nothing is typed
|
||||
/// for a while (the run's handle keeps the input open); everything typed.
|
||||
fn fake_shell(
|
||||
run: &TerminalRun,
|
||||
input: mpsc::Receiver<TerminalTransportCommand>,
|
||||
answer: Answer,
|
||||
) -> std::thread::JoinHandle<String> {
|
||||
let (handle, nonce) = (run.handle(), nonce(run));
|
||||
std::thread::spawn(move || {
|
||||
let mut all = String::new();
|
||||
while let Ok(TerminalTransportCommand::Write(bytes)) =
|
||||
input.recv_timeout(Duration::from_millis(500))
|
||||
{
|
||||
let line = String::from_utf8(bytes).unwrap();
|
||||
all.push_str(&line);
|
||||
let reply = if line.contains("6973;P;") {
|
||||
marker("P", &nonce, "/usr/bin/bash")
|
||||
} else if line.contains("6973;B;") {
|
||||
match answer {
|
||||
Answer::Runs(output) => format!(
|
||||
"{}{output}{}",
|
||||
marker("B", &nonce, ""),
|
||||
marker("E", &nonce, "7")
|
||||
),
|
||||
Answer::Starts => marker("B", &nonce, ""),
|
||||
Answer::Nothing => continue,
|
||||
}
|
||||
} else {
|
||||
continue;
|
||||
};
|
||||
handle.feed(reply.as_bytes());
|
||||
}
|
||||
all
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn driving_a_run_passes_its_output_on_and_ends_with_its_code() {
|
||||
let (run, events, input) = accepted("make");
|
||||
let shell = fake_shell(&run, input, Answer::Runs("built\r\n"));
|
||||
let mut progress = Vec::new();
|
||||
let code = run.drive(&events, QUICK, &mut |step| {
|
||||
if step != RunProgress::Idle {
|
||||
progress.push(step);
|
||||
}
|
||||
Ok(())
|
||||
});
|
||||
assert_eq!(code, Ok(7));
|
||||
assert!(matches!(progress[0], RunProgress::Context(_)));
|
||||
assert_eq!(progress[1], RunProgress::Output(b"built\n".to_vec()));
|
||||
drop(run);
|
||||
assert!(!shell.join().unwrap().contains('\x03'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_run_nobody_answers_or_that_never_starts_is_busy() {
|
||||
let (run, events, _input) = accepted("true");
|
||||
assert_eq!(
|
||||
run.drive(&events, QUICK, &mut |_| Ok(())),
|
||||
Err(RunFailure::Busy(BusyReason::NoAnswer))
|
||||
);
|
||||
|
||||
// Held back by a command filter: withdrawn with ^C.
|
||||
let (run, events, input) = accepted("reboot");
|
||||
let shell = fake_shell(&run, input, Answer::Nothing);
|
||||
assert_eq!(
|
||||
run.drive(&events, QUICK, &mut |_| Ok(())),
|
||||
Err(RunFailure::Busy(BusyReason::NotStarted))
|
||||
);
|
||||
drop(run);
|
||||
assert!(shell.join().unwrap().ends_with('\x03'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_caller_gone_cancels_the_command() {
|
||||
let (run, events, input) = accepted("sleep 100");
|
||||
let shell = fake_shell(&run, input, Answer::Starts);
|
||||
let mut begun = false;
|
||||
let result = run.drive(&events, QUICK, &mut |step| match step {
|
||||
RunProgress::Context(_) => {
|
||||
begun = true;
|
||||
Ok(())
|
||||
}
|
||||
RunProgress::Idle if begun => Err(io::Error::from(io::ErrorKind::BrokenPipe)),
|
||||
_ => Ok(()),
|
||||
});
|
||||
assert_eq!(result, Err(RunFailure::Disconnected));
|
||||
drop(run);
|
||||
assert!(shell.join().unwrap().ends_with('\x03'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_encoded_command_has_nothing_a_line_editor_or_quotes_would_take() {
|
||||
let encoded = encode_command("echo 'it''s' \"$HOME\" a\\b !x\t中\n".as_bytes());
|
||||
assert!(encoded.bytes().all(|byte| (0x20..0x7f).contains(&byte)));
|
||||
assert!(!encoded.contains(['\'', '"', '!']));
|
||||
assert!(!encoded.contains("\\\\"));
|
||||
assert!(encoded.starts_with("echo \\0047it"), "{encoded}");
|
||||
assert!(encoded.contains("$HOME"));
|
||||
}
|
||||
|
||||
/// The scripts run for real by this machine's `sh`.
|
||||
#[cfg(unix)]
|
||||
mod sh {
|
||||
use super::*;
|
||||
use crate::testing::{sh, sh_accepts};
|
||||
|
||||
#[test]
|
||||
fn the_fixed_scripts_are_plain_sh() {
|
||||
assert!(sh_accepts(PROBE_SCRIPT));
|
||||
assert!(sh_accepts(COMMAND_SCRIPT));
|
||||
for script in [PROBE_SCRIPT, COMMAND_SCRIPT] {
|
||||
assert!(!script.contains(['\'', '!']) && !script.contains("\\\\"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn printf_b_reads_every_byte_back() {
|
||||
let bytes: Vec<u8> = (0..=255).collect();
|
||||
let output = sh(
|
||||
&format!("printf %b '{}' | od -An -tx1", encode_command(&bytes)),
|
||||
None,
|
||||
);
|
||||
let dumped: String = String::from_utf8(output.stdout)
|
||||
.unwrap()
|
||||
.split_whitespace()
|
||||
.collect();
|
||||
let expected: String = bytes.iter().map(|byte| format!("{byte:02x}")).collect();
|
||||
assert_eq!(dumped, expected);
|
||||
}
|
||||
|
||||
/// The command line as the interactive shell would run it, its output
|
||||
/// read back by the scanner.
|
||||
fn run_line(command: &str) -> (Vec<Scanned>, String) {
|
||||
let nonce = "Nonce0123456";
|
||||
let line = command_line(nonce, command);
|
||||
let output = sh(line.trim_start(), None);
|
||||
let mut scanner = RunScanner::new(nonce);
|
||||
(scanner.scan(&output.stdout), line)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_command_line_runs_any_command_and_reports_its_status() {
|
||||
let (found, _) =
|
||||
run_line("printf '%s\\n' \"it's\" 'a\\b' '$HOME' '!x' 中文; echo err >&2; exit 3");
|
||||
let [
|
||||
Scanned::Begin(context),
|
||||
Scanned::Output(output),
|
||||
Scanned::End(code),
|
||||
] = found.as_slice()
|
||||
else {
|
||||
panic!("{found:?}");
|
||||
};
|
||||
assert_eq!(
|
||||
String::from_utf8_lossy(output),
|
||||
"it's\na\\b\n$HOME\n!x\n中文\nerr\n"
|
||||
);
|
||||
assert_eq!(*code, 3);
|
||||
let context = RunContext::read(context, "bash");
|
||||
assert!(!context.user.is_empty() && context.cwd.starts_with('/'));
|
||||
assert!(["bash", "sh"].contains(&context.interp.as_str()));
|
||||
|
||||
// Several lines, and a command killed by a signal.
|
||||
let (found, _) = run_line("cd /\npwd\nkill -TERM $$");
|
||||
assert_eq!(found[1], Scanned::Output(b"/\n".to_vec()));
|
||||
assert_eq!(found[2], Scanned::End(143));
|
||||
let (found, _) = run_line("kill -INT $$");
|
||||
assert_eq!(found.last(), Some(&Scanned::End(130)));
|
||||
}
|
||||
}
|
||||
|
||||
/// The run against real shells in containers, on a real terminal: what
|
||||
/// the tests above cannot show. Needs Docker and the images; run with
|
||||
/// `cargo test --lib terminal::run -- --ignored` (CI: `shells.yml`).
|
||||
#[cfg(unix)]
|
||||
mod shells {
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::time::Instant;
|
||||
|
||||
use super::*;
|
||||
use crate::testing::{Pty, pty};
|
||||
|
||||
/// A shell in a container, waited for until it shows its prompt.
|
||||
fn start(image: &str, shell: &[&str]) -> Pty {
|
||||
let mut argv = vec!["docker", "run", "--rm", "-i", "-t", image];
|
||||
argv.extend_from_slice(shell);
|
||||
let pty = pty(&argv);
|
||||
pty.output
|
||||
.recv_timeout(Duration::from_secs(60))
|
||||
.expect("the shell starts");
|
||||
settle(&pty);
|
||||
pty
|
||||
}
|
||||
|
||||
/// Wait until the terminal has been quiet a while.
|
||||
fn settle(pty: &Pty) {
|
||||
while pty.output.recv_timeout(Duration::from_millis(500)).is_ok() {}
|
||||
}
|
||||
|
||||
struct Outcome {
|
||||
result: Result<i32, RunFailure>,
|
||||
output: String,
|
||||
context: Option<RunContext>,
|
||||
}
|
||||
|
||||
/// Run `command` the way the app does: ^C, then the run. `interrupt`
|
||||
/// presses ^C that long after the command started, as the user would.
|
||||
fn run(pty: Pty, command: &str, interrupt: Option<Duration>) -> (Pty, Outcome) {
|
||||
let (run, events) = TerminalRun::new(command.to_owned());
|
||||
let handle = run.handle();
|
||||
let (input, typed) = mpsc::channel();
|
||||
assert!(handle.accept(input.clone()));
|
||||
input
|
||||
.send(TerminalTransportCommand::Write(b"\x03".to_vec()))
|
||||
.unwrap();
|
||||
let done = Arc::new(AtomicBool::new(false));
|
||||
let pump = {
|
||||
let done = done.clone();
|
||||
let handle = handle.clone();
|
||||
std::thread::spawn(move || {
|
||||
let mut pty = pty;
|
||||
let mut begun_at = None;
|
||||
while !done.load(Ordering::Acquire) {
|
||||
while let Ok(TerminalTransportCommand::Write(bytes)) = typed.try_recv() {
|
||||
pty.write(&bytes);
|
||||
}
|
||||
if let Ok(bytes) = pty.output.recv_timeout(Duration::from_millis(10)) {
|
||||
let was_begun = lock(&handle.state).phase == Phase::Begun;
|
||||
handle.feed(&bytes);
|
||||
if !was_begun && lock(&handle.state).phase == Phase::Begun {
|
||||
begun_at = Some(Instant::now());
|
||||
}
|
||||
}
|
||||
if let (Some(after), Some(at)) = (interrupt, begun_at)
|
||||
&& at.elapsed() >= after
|
||||
{
|
||||
pty.write(b"\x03");
|
||||
begun_at = None;
|
||||
}
|
||||
}
|
||||
pty
|
||||
})
|
||||
};
|
||||
let (mut output, mut context) = (Vec::new(), None);
|
||||
let timing = RunTiming {
|
||||
probe: Duration::from_secs(10),
|
||||
tick: Duration::from_millis(100),
|
||||
..RunTiming::STANDARD
|
||||
};
|
||||
let result = run.drive(&events, timing, &mut |step| {
|
||||
match step {
|
||||
RunProgress::Context(found) => context = Some(found),
|
||||
RunProgress::Output(bytes) => output.extend(bytes),
|
||||
RunProgress::Idle => {}
|
||||
}
|
||||
Ok(())
|
||||
});
|
||||
drop(run);
|
||||
done.store(true, Ordering::Release);
|
||||
let pty = pump.join().unwrap();
|
||||
settle(&pty);
|
||||
let output = String::from_utf8(output).unwrap();
|
||||
(
|
||||
pty,
|
||||
Outcome {
|
||||
result,
|
||||
output,
|
||||
context,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn dump(text: &str) -> String {
|
||||
format!("od -An -tx1 <<'SHELLRS_END'\n{text}\nSHELLRS_END")
|
||||
}
|
||||
|
||||
/// Every ASCII character but NUL (which no shell string holds),
|
||||
/// characters whose UTF-8 has every kind of lead and continuation
|
||||
/// byte, and random ones, through a heredoc into `od`: in as many runs
|
||||
/// as the shell's line takes.
|
||||
fn round_trip(mut pty: Pty, limit: usize) -> Pty {
|
||||
let mut text: Vec<char> = (1..=127_u8).map(char::from).collect();
|
||||
text.extend("\u{80}\u{7ff}\u{800}中文\u{ffff}\u{10000}😀\u{10ffff}".chars());
|
||||
let mut random = rand::rng();
|
||||
text.extend(
|
||||
std::iter::repeat_with(|| random.random::<char>())
|
||||
.filter(|character| *character != '\0')
|
||||
.take(100),
|
||||
);
|
||||
let fits = |piece: &str| command_line("Nonce0123456", &dump(piece)).len() < limit;
|
||||
let mut rest = text.as_slice();
|
||||
while !rest.is_empty() {
|
||||
let length = (1..=rest.len())
|
||||
.take_while(|&length| fits(&rest[..length].iter().collect::<String>()))
|
||||
.last()
|
||||
.expect("one character fits");
|
||||
let piece: String = rest[..length].iter().collect();
|
||||
rest = &rest[length..];
|
||||
let (next, outcome) = run(pty, &dump(&piece), None);
|
||||
pty = next;
|
||||
assert_eq!(outcome.result, Ok(0), "{piece:?}: {}", outcome.output);
|
||||
let dumped: String = outcome.output.split_whitespace().collect();
|
||||
assert_eq!(dumped, hex(&format!("{piece}\n")), "{piece:?}");
|
||||
}
|
||||
pty
|
||||
}
|
||||
|
||||
/// The longest command that fits `limit`, and one byte more.
|
||||
fn at_limit(pty: Pty, limit: usize) -> Pty {
|
||||
let fill = |length: usize| format!("echo {}", "x".repeat(length));
|
||||
let base = command_line("Nonce0123456", &fill(0)).len() + 1;
|
||||
let (pty, outcome) = run(pty, &fill(limit - base), None);
|
||||
assert_eq!(outcome.result, Ok(0));
|
||||
assert_eq!(outcome.output.trim_end().len(), limit - base);
|
||||
let (pty, outcome) = run(pty, &fill(limit - base + 1), None);
|
||||
assert_eq!(outcome.result, Err(RunFailure::TooLong { limit }));
|
||||
pty
|
||||
}
|
||||
|
||||
/// Whatever has the terminal, ^C hands it back.
|
||||
fn takes_over(mut pty: Pty) -> Pty {
|
||||
for busy in [
|
||||
"sleep 100\r",
|
||||
"cat\r",
|
||||
"read -p 'Password: ' secret\r",
|
||||
"echo partial",
|
||||
] {
|
||||
pty.write(busy.as_bytes());
|
||||
settle(&pty);
|
||||
let (next, outcome) = run(pty, "echo ok", None);
|
||||
pty = next;
|
||||
assert_eq!(outcome.result, Ok(0), "{busy:?}");
|
||||
assert_eq!(outcome.output, "ok\n", "{busy:?}");
|
||||
}
|
||||
pty
|
||||
}
|
||||
|
||||
fn interrupted(pty: Pty) -> Pty {
|
||||
let (pty, outcome) = run(pty, "sleep 30", Some(Duration::from_secs(2)));
|
||||
assert_eq!(outcome.result, Ok(130));
|
||||
pty
|
||||
}
|
||||
|
||||
fn check(image: &str, shell: &[&str], expected: &str, interp: &str) {
|
||||
let (pty, outcome) = run(start(image, shell), "id -un", None);
|
||||
assert_eq!(outcome.result, Ok(0), "{image}: {}", outcome.output);
|
||||
let context = outcome.context.expect("context");
|
||||
assert_eq!(context.shell, expected, "{image}");
|
||||
assert_eq!(context.interp, interp, "{image}");
|
||||
assert_eq!((context.user.as_str(), context.cwd.as_str()), ("root", "/"));
|
||||
assert_eq!(outcome.output, "root\n");
|
||||
let limit = RunShell::of(expected).unwrap().line_limit();
|
||||
let pty = round_trip(pty, limit);
|
||||
let pty = at_limit(pty, limit);
|
||||
let pty = takes_over(pty);
|
||||
interrupted(pty);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs Docker"]
|
||||
fn bash_4_2() {
|
||||
check("bash:4.2", &["bash"], "bash", "bash");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs Docker"]
|
||||
fn bash_4_4() {
|
||||
check("bash:4.4", &["bash"], "bash", "bash");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs Docker"]
|
||||
fn bash_5_0() {
|
||||
check("bash:5.0", &["bash"], "bash", "bash");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs Docker"]
|
||||
fn bash_5_1() {
|
||||
check("bash:5.1", &["bash"], "bash", "bash");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs Docker"]
|
||||
fn bash_5_2() {
|
||||
check("bash:5.2", &["bash"], "bash", "bash");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs Docker"]
|
||||
fn bash_started_as_sh() {
|
||||
check("rockylinux:9", &["sh"], "bash", "bash");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs Docker"]
|
||||
fn dash() {
|
||||
check("debian:stable-slim", &["sh"], "dash", "bash");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs Docker"]
|
||||
fn busybox_ash() {
|
||||
check("alpine:3", &["sh"], "busybox", "sh");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs Docker"]
|
||||
fn another_shell_is_refused() {
|
||||
// A shell program by another name: bash, which runs whatever its
|
||||
// file is called.
|
||||
let shell = start(
|
||||
"bash:5.2",
|
||||
&[
|
||||
"sh",
|
||||
"-c",
|
||||
"cp /usr/local/bin/bash /tmp/mysh && exec /tmp/mysh",
|
||||
],
|
||||
);
|
||||
let (_, outcome) = run(shell, "true", None);
|
||||
assert_eq!(
|
||||
outcome.result,
|
||||
Err(RunFailure::UnsupportedShell("mysh".into()))
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -402,6 +402,11 @@ impl TerminalView {
|
||||
self.engine.read(cx).exec(command)
|
||||
}
|
||||
|
||||
/// Take up an `exec --terminal` run; see [`TerminalEngine::run_command`].
|
||||
pub fn run_command(&self, run: super::RunHandle, cx: &App) -> Result<(), super::RunFailure> {
|
||||
self.engine.read(cx).run_command(run)
|
||||
}
|
||||
|
||||
pub fn screen_text(&self, cx: &App) -> String {
|
||||
self.engine.read(cx).snapshot().visible_text()
|
||||
}
|
||||
|
||||
@@ -34,6 +34,70 @@ pub fn sh(script: &str, path: Option<&str>) -> std::process::Output {
|
||||
command.output().expect("sh runs")
|
||||
}
|
||||
|
||||
/// A program on a terminal of its own: what it prints comes in on `output`.
|
||||
/// Unix only, like `sh`.
|
||||
#[cfg(unix)]
|
||||
pub struct Pty {
|
||||
pub output: std::sync::mpsc::Receiver<Vec<u8>>,
|
||||
writer: Box<dyn std::io::Write + Send>,
|
||||
child: Box<dyn portable_pty::Child + Send + Sync>,
|
||||
_master: Box<dyn portable_pty::MasterPty + Send>,
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
impl Pty {
|
||||
pub fn write(&mut self, bytes: &[u8]) {
|
||||
self.writer.write_all(bytes).expect("pty takes input");
|
||||
self.writer.flush().expect("pty takes input");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
impl Drop for Pty {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.child.kill();
|
||||
}
|
||||
}
|
||||
|
||||
/// Start `argv` on a new terminal of 120 × 40.
|
||||
#[cfg(unix)]
|
||||
pub fn pty(argv: &[&str]) -> Pty {
|
||||
use portable_pty::{CommandBuilder, PtySize, native_pty_system};
|
||||
|
||||
let pair = native_pty_system()
|
||||
.openpty(PtySize {
|
||||
rows: 40,
|
||||
cols: 120,
|
||||
pixel_width: 0,
|
||||
pixel_height: 0,
|
||||
})
|
||||
.expect("pty opens");
|
||||
let mut command = CommandBuilder::from_argv(argv.iter().map(Into::into).collect());
|
||||
command.env("TERM", "xterm-256color");
|
||||
let child = {
|
||||
let _forks = no_forks();
|
||||
pair.slave.spawn_command(command).expect("program starts")
|
||||
};
|
||||
drop(pair.slave);
|
||||
let mut reader = pair.master.try_clone_reader().expect("pty reads");
|
||||
let writer = pair.master.take_writer().expect("pty writes");
|
||||
let (sender, output) = std::sync::mpsc::channel();
|
||||
std::thread::spawn(move || {
|
||||
let mut buffer = [0_u8; 8192];
|
||||
while let Ok(count) = std::io::Read::read(&mut reader, &mut buffer) {
|
||||
if count == 0 || sender.send(buffer[..count].to_vec()).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
Pty {
|
||||
output,
|
||||
writer,
|
||||
child,
|
||||
_master: pair.master,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this machine's `sh` reads `script` without a syntax error.
|
||||
pub fn sh_accepts(script: &str) -> bool {
|
||||
let mut command = Command::new("sh");
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
//! The workspace's share of the external CLI: coming forward when ShellRS
|
||||
//! is opened again, and the changes to hosts and credentials a `shellrs`
|
||||
//! command asks for. The request threads cannot reach the store; the
|
||||
//! changes wait in the server until they are made here, one at a time.
|
||||
//! is opened again, the changes to hosts and credentials a `shellrs`
|
||||
//! command asks for, and the `exec --terminal` runs. The request threads
|
||||
//! cannot reach the store or the terminals; the changes and the runs wait
|
||||
//! in the server until they are taken up here.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
@@ -14,12 +15,13 @@ use crate::cli::{
|
||||
};
|
||||
use crate::cli::{
|
||||
CredentialPlan, GroupPlan, SecretChange, credential_details, credential_secrets,
|
||||
find_credential, find_saved_host, host_details, host_info, host_secrets, plan_credential,
|
||||
plan_host, with_saved_credential_secrets, with_saved_passwords,
|
||||
find_credential, find_host, find_saved_host, host_details, host_info, host_secrets,
|
||||
plan_credential, plan_host, with_saved_credential_secrets, with_saved_passwords,
|
||||
};
|
||||
use crate::host::{CredentialId, GroupDraft, GroupId, HostId, HostStore};
|
||||
use crate::i18n::t;
|
||||
use crate::secrets::{SecretRef, SharedSecretStore};
|
||||
use crate::terminal::{RunFailure, RunHandle, TerminalLifecycle, TerminalPanel};
|
||||
|
||||
use super::workspace_view::Workspace;
|
||||
|
||||
@@ -48,6 +50,7 @@ impl Workspace {
|
||||
for usage in server.take_usage() {
|
||||
this.count(match usage {
|
||||
CliUse::Exec => Counter::CliExec,
|
||||
CliUse::ExecTerminal => Counter::CliExecTerminal,
|
||||
CliUse::Upload => Counter::CliUpload,
|
||||
CliUse::Download => Counter::CliDownload,
|
||||
CliUse::Sync => Counter::CliSync,
|
||||
@@ -73,6 +76,62 @@ impl Workspace {
|
||||
.detach();
|
||||
}
|
||||
|
||||
/// Hand each `exec --terminal` run the CLI server has heard to its
|
||||
/// host's terminal. Asked on a timer of its own: the poller above waits
|
||||
/// for each change to be made, and a run must not wait behind a
|
||||
/// keychain write.
|
||||
pub(super) fn serve_cli_runs(&self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
loop {
|
||||
cx.background_executor().timer(CLI_POLL).await;
|
||||
let served = this.update(cx, |this, cx| {
|
||||
let Some(server) = this.cli_server.as_ref() else {
|
||||
return;
|
||||
};
|
||||
let runs: Vec<_> = std::iter::from_fn(|| server.take_run()).collect();
|
||||
for (host, run) in runs {
|
||||
if let Err(failure) = this.start_cli_run(&host, run.clone(), cx) {
|
||||
run.fail(failure);
|
||||
}
|
||||
}
|
||||
});
|
||||
if served.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
/// Type an `exec --terminal` run into the terminal of the host the CLI
|
||||
/// calls `host`: the one the right sidebar follows when it is this
|
||||
/// host's, else the host's newest connected one.
|
||||
pub fn start_cli_run(&self, host: &str, run: RunHandle, cx: &App) -> Result<(), RunFailure> {
|
||||
let host = find_host(self.store.read(cx), host)
|
||||
.map_err(|_| RunFailure::HostNotFound)?
|
||||
.id;
|
||||
let connected = |panel: &&Entity<TerminalPanel>| {
|
||||
matches!(panel.read(cx).lifecycle(cx), TerminalLifecycle::Running)
|
||||
};
|
||||
let panel = self
|
||||
.tool_terminal(cx)
|
||||
.filter(|terminal| terminal.host == host)
|
||||
.and_then(|terminal| self.terminals.get(&terminal.id))
|
||||
.filter(connected)
|
||||
.or_else(|| {
|
||||
self.terminals
|
||||
.values()
|
||||
.filter(|panel| panel.read(cx).host_id() == host)
|
||||
.filter(connected)
|
||||
.max_by_key(|panel| panel.read(cx).id().0)
|
||||
});
|
||||
match panel {
|
||||
Some(panel) => panel.read(cx).terminal().read(cx).run_command(run, cx),
|
||||
None if self.terminal(host, cx).is_some() => Err(RunFailure::NotConnected),
|
||||
None => Err(RunFailure::NoTerminal),
|
||||
}
|
||||
}
|
||||
|
||||
/// Make a change a `shellrs` command asks for, checked as the host and
|
||||
/// credential forms check theirs. Its outcome is the reply the command
|
||||
/// prints; on success, the host or credential as it is now.
|
||||
|
||||
@@ -265,6 +265,7 @@ impl Workspace {
|
||||
Ok(server) => {
|
||||
this.cli_server = Some(server);
|
||||
this.serve_cli(window, cx);
|
||||
this.serve_cli_runs(window, cx);
|
||||
}
|
||||
Err(error) => notify_once_open(
|
||||
Notification::error(error.to_string()).title(t!("workspace.cli.start_failed")),
|
||||
|
||||
+206
@@ -4,6 +4,9 @@
|
||||
//! each change over.
|
||||
|
||||
use shellrs::cli::{CliChange, CliError, CredentialFields, ErrorCode, HostFields, Reply, Secret};
|
||||
use shellrs::terminal::{
|
||||
BusyReason, RunEvent, RunFailure, RunProgress, RunTiming, TerminalRun, TerminalView,
|
||||
};
|
||||
|
||||
use crate::support::*;
|
||||
|
||||
@@ -329,3 +332,206 @@ fn deleting_a_credential_lets_its_hosts_log_in_on_their_own(cx: &mut TestAppCont
|
||||
assert_eq!(host.user.as_ref(), "deploy");
|
||||
});
|
||||
}
|
||||
|
||||
/// How an `exec --terminal` run is paced against a fake shell that
|
||||
/// answers at once.
|
||||
const QUICK: RunTiming = RunTiming {
|
||||
settle: Duration::ZERO,
|
||||
probe: Duration::from_secs(2),
|
||||
start: Duration::from_secs(2),
|
||||
tick: Duration::from_millis(10),
|
||||
};
|
||||
|
||||
/// The seed with web-01's terminal running on a transport that behaves as
|
||||
/// `behavior`.
|
||||
async fn open_terminal(
|
||||
cx: &mut TestAppContext,
|
||||
behavior: FakeBehavior,
|
||||
) -> (
|
||||
WindowHandle<Root>,
|
||||
Entity<Workspace>,
|
||||
Arc<FakeTerminalFactory>,
|
||||
) {
|
||||
let factory = Arc::new(FakeTerminalFactory {
|
||||
behavior,
|
||||
..FakeTerminalFactory::default()
|
||||
});
|
||||
let (handle, workspace) =
|
||||
open_workspace_with_remote_factory(cx, HostStore::seed(), factory.clone());
|
||||
let terminal = RemoteTerminalId(INITIAL_WEB_TERMINAL);
|
||||
cx.wait_for(handle.into(), Duration::from_secs(5), |_, cx| {
|
||||
remote_lifecycle(&workspace, terminal, cx).accepts_input()
|
||||
})
|
||||
.await;
|
||||
(handle, workspace, factory)
|
||||
}
|
||||
|
||||
fn start_run(
|
||||
workspace: &Entity<Workspace>,
|
||||
host: &str,
|
||||
run: &TerminalRun,
|
||||
cx: &mut TestAppContext,
|
||||
) -> Result<(), RunFailure> {
|
||||
cx.update(|cx| workspace.read(cx).start_cli_run(host, run.handle(), cx))
|
||||
}
|
||||
|
||||
fn web_terminal(workspace: &Entity<Workspace>, cx: &mut TestAppContext) -> Entity<TerminalView> {
|
||||
cx.update(|cx| {
|
||||
workspace
|
||||
.read(cx)
|
||||
.remote_terminal(RemoteTerminalId(INITIAL_WEB_TERMINAL))
|
||||
.unwrap()
|
||||
.read(cx)
|
||||
.terminal()
|
||||
.clone()
|
||||
})
|
||||
}
|
||||
|
||||
#[gpui_kit::test]
|
||||
async fn a_cli_run_is_typed_into_the_hosts_terminal_after_a_ctrl_c(cx: &mut TestAppContext) {
|
||||
let (_, workspace, factory) = open_terminal(
|
||||
cx,
|
||||
FakeBehavior::Shell {
|
||||
exe: "/usr/bin/bash",
|
||||
ends: true,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
let web = host_id(&workspace, 1, cx);
|
||||
let before = factory.written_text().len();
|
||||
let (run, events) = TerminalRun::new("uname -a".into());
|
||||
assert_eq!(start_run(&workspace, &web, &run, cx), Ok(()));
|
||||
assert_eq!(events.recv().unwrap(), RunEvent::Accepted);
|
||||
|
||||
let (mut output, mut context) = (Vec::new(), None);
|
||||
let code = run.drive(&events, QUICK, &mut |step| {
|
||||
match step {
|
||||
RunProgress::Context(found) => context = Some(found),
|
||||
RunProgress::Output(bytes) => output.extend(bytes),
|
||||
RunProgress::Idle => {}
|
||||
}
|
||||
Ok(())
|
||||
});
|
||||
assert_eq!(code, Ok(0));
|
||||
assert_eq!(output, b"ran\n");
|
||||
let context = context.expect("begun");
|
||||
assert_eq!(
|
||||
(
|
||||
context.user.as_str(),
|
||||
context.cwd.as_str(),
|
||||
context.shell.as_str()
|
||||
),
|
||||
("root", "/root", "bash")
|
||||
);
|
||||
// ^C, the probe, the command line; never Ctrl-E, a byte to dash.
|
||||
let typed = factory.written_text()[before..].to_string();
|
||||
assert!(typed.starts_with("\x03 sh -c '"), "{typed:?}");
|
||||
assert!(typed.ends_with(" 'uname -a'\r"), "{typed:?}");
|
||||
assert_eq!(typed.matches(" sh -c '").count(), 2);
|
||||
assert!(!typed.contains('\x05'));
|
||||
}
|
||||
|
||||
#[gpui_kit::test]
|
||||
async fn a_cli_run_a_terminal_cannot_take_says_why(cx: &mut TestAppContext) {
|
||||
let (_, workspace, factory) = open_terminal(
|
||||
cx,
|
||||
FakeBehavior::Shell {
|
||||
exe: "/usr/bin/zsh",
|
||||
ends: true,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
let (other, _) = TerminalRun::new("true".into());
|
||||
assert_eq!(
|
||||
start_run(&workspace, "nope", &other, cx),
|
||||
Err(RunFailure::HostNotFound)
|
||||
);
|
||||
let db = host_id(&workspace, 3, cx);
|
||||
assert_eq!(
|
||||
start_run(&workspace, &db, &other, cx),
|
||||
Err(RunFailure::NoTerminal)
|
||||
);
|
||||
|
||||
// Another run has the terminal: no ^C to end it.
|
||||
let web = host_id(&workspace, 1, cx);
|
||||
let (first, first_events) = TerminalRun::new("sleep 9".into());
|
||||
assert_eq!(start_run(&workspace, &web, &first, cx), Ok(()));
|
||||
let before = factory.written_text().len();
|
||||
let (second, _) = TerminalRun::new("true".into());
|
||||
assert_eq!(
|
||||
start_run(&workspace, &web, &second, cx),
|
||||
Err(RunFailure::Busy(BusyReason::AnotherRun))
|
||||
);
|
||||
assert_eq!(factory.written_text().len(), before);
|
||||
|
||||
// A shell it cannot type into: only the probe was typed.
|
||||
assert_eq!(first_events.recv().unwrap(), RunEvent::Accepted);
|
||||
assert_eq!(
|
||||
first.drive(&first_events, QUICK, &mut |_| Ok(())),
|
||||
Err(RunFailure::UnsupportedShell("zsh".into()))
|
||||
);
|
||||
assert_eq!(
|
||||
factory.written_text()[before..].matches(" sh -c '").count(),
|
||||
1
|
||||
);
|
||||
|
||||
// The terminal disconnected.
|
||||
web_terminal(&workspace, cx).update(cx, |view, cx| view.stop("closed", cx));
|
||||
cx.run_until_parked();
|
||||
let (third, _) = TerminalRun::new("true".into());
|
||||
assert_eq!(
|
||||
start_run(&workspace, &web, &third, cx),
|
||||
Err(RunFailure::NotConnected)
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui_kit::test]
|
||||
async fn a_cli_run_leaves_a_full_screen_program_alone(cx: &mut TestAppContext) {
|
||||
let (handle, workspace, factory) = open_terminal(cx, FakeBehavior::Prints("\x1b[?1049h")).await;
|
||||
let terminal = web_terminal(&workspace, cx);
|
||||
// The alternate screen is up: the prompt line is off it.
|
||||
cx.wait_for(handle.into(), Duration::from_secs(5), |_, cx| {
|
||||
!terminal.read(cx).screen_text(cx).contains("alpha.txt")
|
||||
})
|
||||
.await;
|
||||
let before = factory.written_text().len();
|
||||
let (run, _) = TerminalRun::new("true".into());
|
||||
let web = host_id(&workspace, 1, cx);
|
||||
assert_eq!(
|
||||
start_run(&workspace, &web, &run, cx),
|
||||
Err(RunFailure::Busy(BusyReason::FullScreen))
|
||||
);
|
||||
assert_eq!(factory.written_text().len(), before);
|
||||
}
|
||||
|
||||
#[gpui_kit::test]
|
||||
async fn a_cli_run_whose_terminal_goes_away_may_have_run(cx: &mut TestAppContext) {
|
||||
let (_, workspace, factory) = open_terminal(
|
||||
cx,
|
||||
FakeBehavior::Shell {
|
||||
exe: "/usr/bin/bash",
|
||||
ends: false,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
let web = host_id(&workspace, 1, cx);
|
||||
let (run, events) = TerminalRun::new("sleep 100".into());
|
||||
assert_eq!(start_run(&workspace, &web, &run, cx), Ok(()));
|
||||
assert_eq!(events.recv().unwrap(), RunEvent::Accepted);
|
||||
let (begun, begun_here) = mpsc::channel();
|
||||
let driver = std::thread::spawn(move || {
|
||||
run.drive(&events, QUICK, &mut |step| {
|
||||
if matches!(step, RunProgress::Context(_)) {
|
||||
let _ = begun.send(());
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
});
|
||||
begun_here
|
||||
.recv_timeout(Duration::from_secs(5))
|
||||
.expect("the command started");
|
||||
web_terminal(&workspace, cx).update(cx, |view, cx| view.stop("closed", cx));
|
||||
cx.run_until_parked();
|
||||
assert_eq!(driver.join().unwrap(), Err(RunFailure::Disconnected));
|
||||
assert!(factory.written_text().contains(" 'sleep 100'\r"));
|
||||
}
|
||||
@@ -12,6 +12,13 @@ pub enum FakeBehavior {
|
||||
ReportsLatency(Latency),
|
||||
/// Prints this after its prompt line, as a program would.
|
||||
Prints(&'static str),
|
||||
/// Answers what `exec --terminal` types the way a shell whose program
|
||||
/// is `exe` would: the probe, then the command line, which prints
|
||||
/// `ran` and ends with 0 when `ends`, and runs on otherwise.
|
||||
Shell {
|
||||
exe: &'static str,
|
||||
ends: bool,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
@@ -165,7 +172,11 @@ impl TerminalTransport for FakeTerminalTransport {
|
||||
.lock()
|
||||
.unwrap_or_else(|error| error.into_inner())
|
||||
.push(bytes.clone());
|
||||
let answer = shell_answer(self.behavior, &bytes);
|
||||
events.send_blocking(TerminalTransportEvent::Output(bytes))?;
|
||||
if let Some(answer) = answer {
|
||||
events.send_blocking(TerminalTransportEvent::Output(answer))?;
|
||||
}
|
||||
}
|
||||
TerminalTransportCommand::Resize(size) => {
|
||||
self.resizes
|
||||
@@ -199,6 +210,35 @@ impl TerminalTransport for FakeTerminalTransport {
|
||||
}
|
||||
}
|
||||
|
||||
/// What a [`FakeBehavior::Shell`] prints for a line `exec --terminal`
|
||||
/// typed (` sh -c '<script>' sh NONCE …`): the probe's marker, or the
|
||||
/// command's, run in /root on web-01 as root.
|
||||
fn shell_answer(behavior: FakeBehavior, typed: &[u8]) -> Option<Vec<u8>> {
|
||||
let FakeBehavior::Shell { exe, ends } = behavior else {
|
||||
return None;
|
||||
};
|
||||
let line = String::from_utf8_lossy(typed);
|
||||
let at = line.find("' sh ")?;
|
||||
let nonce = line.get(at + 5..at + 17)?;
|
||||
let answer = if line.contains("6973;P;") {
|
||||
format!("\x1b]6973;P;{nonce};{exe}\x07")
|
||||
} else if line.contains("6973;B;") {
|
||||
let context: String = "root\nweb-01\n/root\nbash"
|
||||
.bytes()
|
||||
.map(|byte| format!("{byte:02x}"))
|
||||
.collect();
|
||||
let begin = format!("\x1b]6973;B;{nonce};{context}\x07");
|
||||
if ends {
|
||||
format!("{begin}ran\r\n\x1b]6973;E;{nonce};0\x07")
|
||||
} else {
|
||||
begin
|
||||
}
|
||||
} else {
|
||||
return None;
|
||||
};
|
||||
Some(answer.into_bytes())
|
||||
}
|
||||
|
||||
pub fn open_workspace_with_remote_factory(
|
||||
cx: &mut TestAppContext,
|
||||
store: HostStore,
|
||||
|
||||
Reference in new issue
Block a user