Compare commits
9
Commits
v0.1.5
...
v0.1.7-beta.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5be3f56b0c | ||
|
|
eadbe6481a | ||
|
|
ecf9d92e47 | ||
|
|
63d1e4281c | ||
|
|
b834477bf4 | ||
|
|
65ea32acfb | ||
|
|
2df67bc639 | ||
|
|
e09adeefb7 | ||
|
|
3c937a9be2 |
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
|
||||
@@ -2,6 +2,25 @@
|
||||
|
||||
每个版本一节,标题写成 `## [版本号]`,可以在后面加日期。发布流程(`.github/workflows/release.yml`)把与 tag 同版本的那一节原样作为更新说明:显示在 GitHub Release 和官网的更新日志里(ShellRS 的更新对话框用「查看更新内容」打开官网的更新日志),用 Markdown 书写,面向使用者。
|
||||
|
||||
## [0.1.7-beta.1]
|
||||
|
||||
### 修复
|
||||
|
||||
- 修复经部分堡垒机上传文件失败、编辑器保存时 SFTP 连接断开。
|
||||
|
||||
## [0.1.6]
|
||||
|
||||
### 新增
|
||||
|
||||
- 外部 CLI 新增 `shellrs exec --terminal`:在 ShellRS 已打开的终端里执行命令,适用于只允许登录一次的堡垒机。
|
||||
- 终端右侧显示滚动条,可拖动翻看历史输出。
|
||||
|
||||
## [0.1.6-beta.1]
|
||||
|
||||
### 新增
|
||||
|
||||
- 外部 CLI 新增 `shellrs exec --terminal`:在 ShellRS 已打开的终端里执行命令,适用于只允许登录一次的堡垒机。
|
||||
|
||||
## [0.1.5]
|
||||
|
||||
### 新增
|
||||
|
||||
@@ -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
|
||||
@@ -278,6 +280,7 @@ cargo test -- --ignored # 会读写真实钥匙串的测试
|
||||
- **每个标签独立连接、独立传输引擎。** 引擎一次只跑一批,其余在 `TransferQueue` 里排队,引擎的 `Idle` 推动队列前进;进度只归队首;停止的批次挡在队首,等用户继续或移出。
|
||||
- **SFTP 独立于终端。** 关闭终端不停传输;断开或删除主机会停止传输,并保留续传数据。
|
||||
- **下载严格按偏移顺序写 `.filepart`**,因为续传只信它的长度。
|
||||
- **只发 WinSCP 也发的请求**:用户的堡垒机对 `fsetstat` 回 Failure,收到 `fsync@openssh.com`(即使宣称支持)就断开整个会话。所以上传关闭文件后才按路径 `setstat` 设时间和权限;不用任何扩展:不发 fsync,覆盖也不用 `posix-rename@openssh.com`,而是先把旧文件改名成备份、发布后再删(不是原子的)。`protocol_tests` 的 `bastion_server` 对 `fsetstat` 和所有扩展请求都断开会话,守着这条。
|
||||
- **文件操作从不跟随符号链接**:删除只删链接本身,改权限跳过链接,传输原样创建链接。
|
||||
- **空闲时也要发现断网**:通道流包一层 `WatchedStream`。等待时只持有 `Weak`,不能攥着 `Arc<SftpClient>`,否则「断开」关不掉连接。
|
||||
- **选择归面板,不归表格。** `DataTable` 设成 `row_selectable(false)`,`FilePane` 持有按名称保存的 `Selection`。「名称」单元格才是项目,拖文件只从图标和文件名开始,其余地方按下拖动是框选。
|
||||
@@ -324,6 +327,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。
|
||||
|
||||
Generated
+1
-1
@@ -7738,7 +7738,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "shellrs"
|
||||
version = "0.1.5"
|
||||
version = "0.1.7-beta.1"
|
||||
dependencies = [
|
||||
"alacritty_terminal",
|
||||
"anyhow",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "shellrs"
|
||||
version = "0.1.5"
|
||||
version = "0.1.7-beta.1"
|
||||
edition = "2024"
|
||||
publish = false
|
||||
description = "SSH client and host manager: terminal, SFTP, port forwarding"
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
<a href="#license"><img src="https://img.shields.io/badge/license-GPL--3.0-blue" alt="License: GPL-3.0"></a>
|
||||
<img src="https://img.shields.io/badge/platform-macOS%20%7C%20Windows%20%7C%20Linux-lightgrey" alt="Platform: macOS | Windows | Linux">
|
||||
<img src="https://img.shields.io/badge/rust-1.98-orange" alt="Rust 1.98">
|
||||
<a href="https://linux.do"><img src="https://img.shields.io/badge/LINUX-DO-FFB003.svg?logo=data:image/svg%2bxml;base64,DQo8c3ZnIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgd2lkdGg9IjEwMCIgaGVpZ2h0PSIxMDAiPjxwYXRoIGQ9Ik00Ni44Mi0uMDU1aDYuMjVxMjMuOTY5IDIuMDYyIDM4IDIxLjQyNmM1LjI1OCA3LjY3NiA4LjIxNSAxNi4xNTYgOC44NzUgMjUuNDV2Ni4yNXEtMi4wNjQgMjMuOTY4LTIxLjQzIDM4LTExLjUxMiA3Ljg4NS0yNS40NDUgOC44NzRoLTYuMjVxLTIzLjk3LTIuMDY0LTM4LjAwNC0yMS40M1EuOTcxIDY3LjA1Ni0uMDU0IDUzLjE4di02LjQ3M0MxLjM2MiAzMC43ODEgOC41MDMgMTguMTQ4IDIxLjM3IDguODE3IDI5LjA0NyAzLjU2MiAzNy41MjcuNjA0IDQ2LjgyMS0uMDU2IiBzdHlsZT0ic3Ryb2tlOm5vbmU7ZmlsbC1ydWxlOmV2ZW5vZGQ7ZmlsbDojZWNlY2VjO2ZpbGwtb3BhY2l0eToxIi8+PHBhdGggZD0iTTQ3LjI2NiAyLjk1N3EyMi41My0uNjUgMzcuNzc3IDE1LjczOGE0OS43IDQ5LjcgMCAwIDEgNi44NjcgMTAuMTU3cS00MS45NjQuMjIyLTgzLjkzIDAgOS43NS0xOC42MTYgMzAuMDI0LTI0LjM4N2E2MSA2MSAwIDAgMSA5LjI2Mi0xLjUwOCIgc3R5bGU9InN0cm9rZTpub25lO2ZpbGwtcnVsZTpldmVub2RkO2ZpbGw6IzE5MTkxOTtmaWxsLW9wYWNpdHk6MSIvPjxwYXRoIGQ9Ik03Ljk4IDcwLjkyNmMyNy45NzctLjAzNSA1NS45NTQgMCA4My45My4xMTNRODMuNDI2IDg3LjQ3MyA2Ni4xMyA5NC4wODZxLTE4LjgxIDYuNTQ0LTM2LjgzMi0xLjg5OC0xNC4yMDMtNy4wOS0yMS4zMTctMjEuMjYyIiBzdHlsZT0ic3Ryb2tlOm5vbmU7ZmlsbC1ydWxlOmV2ZW5vZGQ7ZmlsbDojZjlhZjAwO2ZpbGwtb3BhY2l0eToxIi8+PC9zdmc+" alt="LINUX DO"></a>
|
||||
</p>
|
||||
|
||||
<p align="center"><b>English</b> | <a href="README.zh-CN.md">简体中文</a></p>
|
||||
@@ -28,9 +29,6 @@ ShellRS brings Xshell-style host management and tabbed terminals, a WinSCP-style
|
||||
|
||||
Hosts, groups, credentials and forwarding rules live in a local SQLite database. Passwords and key passphrases go only into the system keychain (macOS Keychain, Windows Credential Manager, Linux Secret Service); the database never holds a secret. ShellRS also ships a `shellrs` command that lets AI agents such as Claude Code or Codex run commands and transfer files on your saved hosts without ever seeing a password.
|
||||
|
||||
> [!NOTE]
|
||||
> The interface is in English and Simplified Chinese. It follows the system language unless you choose one in Settings › Appearance › Language, and switches at once.
|
||||
|
||||
## Features
|
||||
|
||||
**Hosts**
|
||||
|
||||
+1
-3
@@ -13,6 +13,7 @@
|
||||
<a href="#许可证"><img src="https://img.shields.io/badge/license-GPL--3.0-blue" alt="License: GPL-3.0"></a>
|
||||
<img src="https://img.shields.io/badge/platform-macOS%20%7C%20Windows%20%7C%20Linux-lightgrey" alt="Platform: macOS | Windows | Linux">
|
||||
<img src="https://img.shields.io/badge/rust-1.98-orange" alt="Rust 1.98">
|
||||
<a href="https://linux.do"><img src="https://img.shields.io/badge/LINUX-DO-FFB003.svg?logo=data:image/svg%2bxml;base64,DQo8c3ZnIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgd2lkdGg9IjEwMCIgaGVpZ2h0PSIxMDAiPjxwYXRoIGQ9Ik00Ni44Mi0uMDU1aDYuMjVxMjMuOTY5IDIuMDYyIDM4IDIxLjQyNmM1LjI1OCA3LjY3NiA4LjIxNSAxNi4xNTYgOC44NzUgMjUuNDV2Ni4yNXEtMi4wNjQgMjMuOTY4LTIxLjQzIDM4LTExLjUxMiA3Ljg4NS0yNS40NDUgOC44NzRoLTYuMjVxLTIzLjk3LTIuMDY0LTM4LjAwNC0yMS40M1EuOTcxIDY3LjA1Ni0uMDU0IDUzLjE4di02LjQ3M0MxLjM2MiAzMC43ODEgOC41MDMgMTguMTQ4IDIxLjM3IDguODE3IDI5LjA0NyAzLjU2MiAzNy41MjcuNjA0IDQ2LjgyMS0uMDU2IiBzdHlsZT0ic3Ryb2tlOm5vbmU7ZmlsbC1ydWxlOmV2ZW5vZGQ7ZmlsbDojZWNlY2VjO2ZpbGwtb3BhY2l0eToxIi8+PHBhdGggZD0iTTQ3LjI2NiAyLjk1N3EyMi41My0uNjUgMzcuNzc3IDE1LjczOGE0OS43IDQ5LjcgMCAwIDEgNi44NjcgMTAuMTU3cS00MS45NjQuMjIyLTgzLjkzIDAgOS43NS0xOC42MTYgMzAuMDI0LTI0LjM4N2E2MSA2MSAwIDAgMSA5LjI2Mi0xLjUwOCIgc3R5bGU9InN0cm9rZTpub25lO2ZpbGwtcnVsZTpldmVub2RkO2ZpbGw6IzE5MTkxOTtmaWxsLW9wYWNpdHk6MSIvPjxwYXRoIGQ9Ik03Ljk4IDcwLjkyNmMyNy45NzctLjAzNSA1NS45NTQgMCA4My45My4xMTNRODMuNDI2IDg3LjQ3MyA2Ni4xMyA5NC4wODZxLTE4LjgxIDYuNTQ0LTM2LjgzMi0xLjg5OC0xNC4yMDMtNy4wOS0yMS4zMTctMjEuMjYyIiBzdHlsZT0ic3Ryb2tlOm5vbmU7ZmlsbC1ydWxlOmV2ZW5vZGQ7ZmlsbDojZjlhZjAwO2ZpbGwtb3BhY2l0eToxIi8+PC9zdmc+" alt="LINUX DO"></a>
|
||||
</p>
|
||||
|
||||
<p align="center"><a href="README.md">English</a> | <b>简体中文</b></p>
|
||||
@@ -28,9 +29,6 @@ ShellRS 把 Xshell 式的主机管理和多标签终端、WinSCP 式的双栏 SF
|
||||
|
||||
主机、分组、凭据和转发规则保存在本地的 SQLite 数据库里。密码和私钥口令只存进系统钥匙串(macOS 钥匙串、Windows 凭据管理器、Linux Secret Service),数据库里没有任何秘密。ShellRS 还带一个 `shellrs` 命令,让 Claude Code、Codex 等 AI Agent 用你保存的主机执行命令、传输文件,不用把密码交给它们。
|
||||
|
||||
> [!NOTE]
|
||||
> 界面有简体中文和英文,默认跟随系统语言,可在「设置 › 外观 › 界面语言」里改,改了立即生效。
|
||||
|
||||
## 功能
|
||||
|
||||
**主机管理**
|
||||
|
||||
+2
-1
@@ -193,6 +193,7 @@ Remote terminal tabs and SFTP tabs use the host’s system badge as their icon (
|
||||
|
||||
The terminal’s context menu has “Copy”, “Paste”, “Find…” and “Clear screen”; a remote terminal’s also has “Open SFTP”, “Reconnect” and “Disconnect”. “Disconnect” disconnects only this tab: the tab stays and can reconnect at any time, and other tabs and SFTP for the same host aren’t affected.
|
||||
|
||||
- **Scrollback**: each terminal keeps the last 10,000 lines. Scroll up with the wheel or trackpad and a scrollbar appears on the right (as in the app’s other lists, following the system’s “Show scroll bars” setting); drag it, or click the track to jump there. Full-screen programs such as vim, less and top have no scrollback and show no scrollbar.
|
||||
- **Find** (⌘F, or Ctrl+Shift+F on Windows and Linux): opens the find bar at the top right of the terminal and searches the screen and the scrollback for the literal text. An all-lowercase query ignores case; one with capital letters matches case. All matches are highlighted, the current one darker, and the find bar shows “current/total”. Enter or ⌘G (F3) goes to the next match (newer output), Shift+Enter or ⌘⇧G (Shift+F3) to the previous one (older output), wrapping around at the ends. Esc closes it. If text is selected when it opens, that text becomes the query.
|
||||
- **Clear screen** (⌘K, or Ctrl+Shift+K on Windows and Linux; when the terminal doesn’t have focus, ⌘K still focuses the sidebar’s search box): clears the screen and the scrollback, leaving only the prompt line the cursor is on, including anything typed but not yet entered. Clearing happens only locally; nothing is sent to the server. It isn’t available while a full-screen program such as vim or top is running.
|
||||
- **Links**: `http://` and `https://` addresses in the output, and links that programs mark explicitly (such as `ls --hyperlink` and gcc’s error messages), are shown in blue and underlined. Hold ⌘ (Ctrl on Windows and Linux) and click to open one in the system browser; a plain click still selects text. Rest the pointer on a link for a moment to see its real address: for a link a program marks, the text shown can differ from the address. A trailing period, comma or unmatched closing parenthesis isn’t part of the address, and a long address wrapped over two lines is still one link. Other schemes (such as `file://`) aren’t treated as links.
|
||||
@@ -384,7 +385,7 @@ Works like WinSCP’s queue panel: it appears below the two panes when there are
|
||||
|
||||
## Resuming and replacing
|
||||
|
||||
Files are first written to `<destination file name>.filepart`. Write requests go through a bounded pipeline, and the real file is published only after every write has been acknowledged and the file has closed successfully; when the server supports it, `fsync@openssh.com` is called first. Servers that support `posix-rename@openssh.com` replace the file atomically; on other servers, the old file is first moved to `.shellrs-<UUID>.backup` and then the new file is published, a compatibility path that isn’t guaranteed to be atomic. If the reply to a publish is lost, ShellRS recovers using the managed temporary path, the stage, the type and the size; when it can’t tell safely, it keeps the recovery files and reports it.
|
||||
Files are first written to `<destination file name>.filepart`. Write requests go through a bounded pipeline, and once every write has been acknowledged and the file has closed successfully, its modification time (and, over an existing file, that file’s permissions) is set by path before the real file is published. Over an existing file, the old file is first moved to `.shellrs-<UUID>.backup`, then the new file is published and the backup deleted; this replacement isn’t atomic, as the destination is briefly missing between the two renames. Like WinSCP, transfers use no SFTP extensions (`fsync@openssh.com`, `posix-rename@openssh.com` and so on) and set no attributes on an open file: some bastion hosts refuse these requests, or even end the whole session over them. If the reply to a publish is lost, ShellRS recovers using the managed temporary path, the stage, the type and the size; when it can’t tell safely, it keeps the recovery files and reports it.
|
||||
|
||||
Resume records are kept in `upload-resume/` in the app’s data folder, as versioned JSON written atomically; they never contain passwords, passphrases or private keys, and they don’t change the database schema. A record ties together the endpoint, host fingerprint, source, destination and safe-replace stage. Resuming works as in WinSCP: ShellRS reads the current size of the remote `.filepart`, skips that much of the local file and carries on; it’s up to you to make sure the source is still the same version. Uploads never read the remote file back to verify it.
|
||||
|
||||
|
||||
+12
-5
@@ -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」)。
|
||||
- 命令行参数里的密码在本机的进程列表里看得到,这是堡垒机这样传参本身的问题。
|
||||
|
||||
## 标签页
|
||||
@@ -193,6 +199,7 @@ ShellRS /sessionname=标签名 sftp://用户[:密码]@地址[:端口]
|
||||
|
||||
右键终端内容区可复制、粘贴、查找、清屏;远程终端另有打开 SFTP、重新连接和断开连接。断开连接只断开这一个标签,标签保留,可随时重新连接,同一台主机的其他标签和 SFTP 不受影响。
|
||||
|
||||
- **回滚历史**:每个终端保留最近 10000 行。用滚轮或触控板往上翻看,这时右侧出现滚动条(和应用里其他列表一样,按系统的「显示滚动条」设置),可以拖动,点轨道直接跳到那个位置。vim、less、top 这类全屏程序没有回滚历史,不显示滚动条。
|
||||
- **查找**(⌘F,其他平台 Ctrl+Shift+F):在终端右上角打开查找条,按字面量搜索屏幕和回滚历史。查询全为小写时不区分大小写,含大写字母时区分。所有匹配高亮显示,当前项颜色更深,查找条显示「当前项/总数」。回车或 ⌘G(F3)跳到下一项(更新的输出),Shift+回车或 ⌘⇧G(Shift+F3)跳到上一项(更早的输出),到头后循环。Esc 关闭。打开时如有选中文字,会作为查询词。
|
||||
- **清屏**(⌘K,其他平台 Ctrl+Shift+K;焦点不在终端时 ⌘K 仍是聚焦侧栏的搜索框):清掉屏幕和回滚历史,只留下光标所在的提示符行,包括已输入但未回车的内容。清屏只在本地进行,不向服务器发送任何内容。vim、top 等全屏程序运行时不可用。
|
||||
- **链接**:输出里的 `http://`、`https://` 地址,以及程序明确标出的链接(如 `ls --hyperlink`、gcc 的报错),显示为带下划线的蓝色。按住 ⌘(其他平台 Ctrl)单击,用系统浏览器打开;不按时单击照旧是选择文字。鼠标在链接上停一会儿会显示它真正的地址:程序标出的链接,显示的文字可以和地址不同。地址末尾的句号、逗号和不成对的右括号不算在内;被折成两行的长地址仍是一个链接。其他协议(如 `file://`)不当作链接。
|
||||
@@ -384,7 +391,7 @@ bash 在 shell 退出时才把这次执行的命令写进历史文件,所以
|
||||
|
||||
## 断点续传和替换
|
||||
|
||||
文件先写入 `<目标文件名>.filepart`。写入请求采用有界流水线,收到每个写入确认并关闭成功后才发布正式文件;服务器支持时先调用 `fsync@openssh.com`。支持 `posix-rename@openssh.com` 的服务器使用原子替换;其他服务器先将旧文件移到 `.shellrs-<UUID>.backup`,再发布新文件,此兼容路径不保证原子性。发布响应丢失时根据受管理临时路径、阶段、类型和大小恢复,无法安全判断则保留恢复文件并报告。
|
||||
文件先写入 `<目标文件名>.filepart`。写入请求采用有界流水线,收到每个写入确认并关闭成功后,按路径设置修改时间(覆盖时还有原文件的权限),再发布正式文件。覆盖已有文件时,先将旧文件移到 `.shellrs-<UUID>.backup`,再发布新文件,最后删除备份;这样替换不是原子的,两次改名之间目标短暂不存在。和 WinSCP 一样,传输不用任何 SFTP 扩展(`fsync@openssh.com`、`posix-rename@openssh.com` 等),也不对打开的文件设置属性:有的堡垒机拒绝这些请求,甚至因此断开整个会话。发布响应丢失时根据受管理临时路径、阶段、类型和大小恢复,无法安全判断则保留恢复文件并报告。
|
||||
|
||||
续传记录保存在应用数据目录的 `upload-resume/`,采用版本化 JSON 和原子写入,不写入密码、口令或私钥,不改变数据库结构。记录用于绑定端点、主机指纹、来源、目标和安全替换阶段。续传方式与 WinSCP 相同:读取远端 `.filepart` 的当前大小,在本地文件中跳过相同长度并继续传输;使用者需确保来源仍是同一版本。上传过程不会为了校验而回读远端文件内容。
|
||||
|
||||
@@ -439,7 +446,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}.
|
||||
|
||||
@@ -305,9 +305,6 @@ sftp.client.no_path:
|
||||
sftp.client.no_link_target:
|
||||
zh-CN: 无法读取链接目标
|
||||
en: Couldn’t read the link target
|
||||
sftp.client.bad_rename_reply:
|
||||
zh-CN: 服务器返回了无效的重命名响应
|
||||
en: The server sent an invalid reply to the rename
|
||||
sftp.client.ambiguous_name:
|
||||
zh-CN: 远端名称包含无法可靠表示的字符,已停止操作
|
||||
en: A remote name has characters that can’t be shown reliably, so the operation stopped
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
+17
-48
@@ -6,7 +6,7 @@ use anyhow::{Result, anyhow, bail};
|
||||
use futures::StreamExt as _;
|
||||
use russh_sftp::{
|
||||
client::{RawSftpSession, error::Error},
|
||||
protocol::{FileAttributes, OpenFlags, Packet, StatusCode},
|
||||
protocol::{FileAttributes, OpenFlags, StatusCode},
|
||||
};
|
||||
use std::{
|
||||
pin::Pin,
|
||||
@@ -34,25 +34,29 @@ pub(crate) trait RemoteFs {
|
||||
async fn open_read(&self, path: &RemotePath) -> Result<String>;
|
||||
/// Up to `len` bytes at `offset`; may return fewer. `None` at end of file.
|
||||
async fn read(&self, handle: &str, offset: u64, len: u32) -> Result<Option<Vec<u8>>>;
|
||||
async fn sync(&self, handle: &str) -> Result<()>;
|
||||
async fn write(&self, handle: &str, offset: u64, bytes: Vec<u8>) -> Result<()>;
|
||||
async fn close(&self, handle: &str) -> Result<()>;
|
||||
/// Set the modification time and permission bits of a closed file by its
|
||||
/// path (`setstat`), as WinSCP does: a bastion host has been seen to
|
||||
/// refuse `fsetstat` on a file still open.
|
||||
async fn attributes(
|
||||
&self,
|
||||
handle: &str,
|
||||
path: &RemotePath,
|
||||
modified: Option<u32>,
|
||||
permissions: Option<u32>,
|
||||
) -> Result<()>;
|
||||
async fn mkdir(&self, path: &RemotePath) -> Result<()>;
|
||||
async fn symlink(&self, target: &str, path: &RemotePath) -> Result<()>;
|
||||
async fn readlink(&self, path: &RemotePath) -> Result<String>;
|
||||
async fn rename(&self, from: &RemotePath, to: &RemotePath, replace: bool) -> Result<()>;
|
||||
/// A plain `rename`, which fails when `to` is there: replacing a file
|
||||
/// moves the old one aside first, as WinSCP never sends
|
||||
/// `posix-rename@openssh.com` either.
|
||||
async fn rename(&self, from: &RemotePath, to: &RemotePath) -> Result<()>;
|
||||
async fn remove(&self, path: &RemotePath) -> Result<()>;
|
||||
/// Remove an empty directory.
|
||||
async fn rmdir(&self, path: &RemotePath) -> Result<()>;
|
||||
/// Set the permission bits of a path (`setstat`, which follows links).
|
||||
async fn set_permissions(&self, path: &RemotePath, permissions: u32) -> Result<()>;
|
||||
fn atomic_replace(&self) -> bool;
|
||||
}
|
||||
|
||||
pub(crate) struct SftpClient {
|
||||
@@ -61,8 +65,6 @@ pub(crate) struct SftpClient {
|
||||
/// Turns true once the server side of the SFTP stream has ended.
|
||||
closed: watch::Receiver<bool>,
|
||||
fingerprint: String,
|
||||
atomic_replace: bool,
|
||||
fsync: bool,
|
||||
}
|
||||
impl SftpClient {
|
||||
pub async fn connect(
|
||||
@@ -92,21 +94,14 @@ impl SftpClient {
|
||||
if version.version != 3 {
|
||||
bail!(t!("sftp.client.no_v3"));
|
||||
}
|
||||
let atomic_replace = version
|
||||
.extensions
|
||||
.get("posix-rename@openssh.com")
|
||||
.is_some_and(|v| v == "1");
|
||||
let fsync = version
|
||||
.extensions
|
||||
.get("fsync@openssh.com")
|
||||
.is_some_and(|v| v == "1");
|
||||
// No extension is used, even when offered: a bastion host that offers
|
||||
// `fsync@openssh.com` has been seen to end the whole session on it.
|
||||
// WinSCP sends none of them for a transfer.
|
||||
Ok(Self {
|
||||
raw,
|
||||
ssh,
|
||||
closed,
|
||||
fingerprint,
|
||||
atomic_replace,
|
||||
fsync,
|
||||
})
|
||||
}
|
||||
/// Only the Unix tests have a local `sftp-server` to talk to.
|
||||
@@ -281,12 +276,6 @@ impl RemoteFs for SftpClient {
|
||||
Err(error) => Err(error.into()),
|
||||
}
|
||||
}
|
||||
async fn sync(&self, handle: &str) -> Result<()> {
|
||||
if self.fsync {
|
||||
self.raw.fsync(handle).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
async fn write(&self, handle: &str, offset: u64, bytes: Vec<u8>) -> Result<()> {
|
||||
self.raw.write(handle, offset, bytes).await?;
|
||||
Ok(())
|
||||
@@ -298,13 +287,13 @@ impl RemoteFs for SftpClient {
|
||||
|
||||
async fn attributes(
|
||||
&self,
|
||||
handle: &str,
|
||||
path: &RemotePath,
|
||||
modified: Option<u32>,
|
||||
permissions: Option<u32>,
|
||||
) -> Result<()> {
|
||||
self.raw
|
||||
.fsetstat(
|
||||
handle,
|
||||
.setstat(
|
||||
path.as_str(),
|
||||
FileAttributes {
|
||||
mtime: modified,
|
||||
atime: modified,
|
||||
@@ -337,25 +326,8 @@ impl RemoteFs for SftpClient {
|
||||
.filename,
|
||||
)
|
||||
}
|
||||
async fn rename(&self, from: &RemotePath, to: &RemotePath, replace: bool) -> Result<()> {
|
||||
if replace {
|
||||
let mut payload = Vec::new();
|
||||
for value in [from.as_str(), to.as_str()] {
|
||||
payload.extend_from_slice(&(value.len() as u32).to_be_bytes());
|
||||
payload.extend_from_slice(value.as_bytes());
|
||||
}
|
||||
match self
|
||||
.raw
|
||||
.extended("posix-rename@openssh.com", payload)
|
||||
.await?
|
||||
{
|
||||
Packet::Status(status) if status.status_code == StatusCode::Ok => {}
|
||||
Packet::Status(status) => return Err(Error::Status(status).into()),
|
||||
_ => bail!(t!("sftp.client.bad_rename_reply")),
|
||||
}
|
||||
} else {
|
||||
self.raw.rename(from.as_str(), to.as_str()).await?;
|
||||
}
|
||||
async fn rename(&self, from: &RemotePath, to: &RemotePath) -> Result<()> {
|
||||
self.raw.rename(from.as_str(), to.as_str()).await?;
|
||||
Ok(())
|
||||
}
|
||||
async fn remove(&self, path: &RemotePath) -> Result<()> {
|
||||
@@ -381,9 +353,6 @@ impl RemoteFs for SftpClient {
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
fn atomic_replace(&self) -> bool {
|
||||
self.atomic_replace
|
||||
}
|
||||
}
|
||||
fn metadata(attrs: &FileAttributes) -> FileMetadata {
|
||||
// Compare the whole type field: russh-sftp's `is_dir()` tests one bit,
|
||||
|
||||
+1
-4
@@ -381,10 +381,7 @@ pub(crate) async fn write_in_place<F: RemoteFs>(
|
||||
let message = format!("{error:#}");
|
||||
error.context(SaveFailure::Interrupted(message))
|
||||
};
|
||||
let written = match write_all(fs, &handle, bytes).await {
|
||||
Ok(()) => fs.sync(&handle).await,
|
||||
Err(error) => Err(error),
|
||||
};
|
||||
let written = write_all(fs, &handle, bytes).await;
|
||||
let close = fs.close(&handle).await;
|
||||
written.map_err(interrupted)?;
|
||||
close.map_err(interrupted)?;
|
||||
|
||||
@@ -18,7 +18,7 @@ pub(crate) async fn run<F: RemoteFs>(fs: &F, operation: &RemoteOperation) -> Res
|
||||
if fs.metadata(to).await?.is_some() {
|
||||
bail!(t!("sftp.error.exists", name = to.file_name()));
|
||||
}
|
||||
fs.rename(from, to, false).await
|
||||
fs.rename(from, to).await
|
||||
}
|
||||
RemoteOperation::CreateDirectory { path } => {
|
||||
if fs.metadata(path).await?.is_some() {
|
||||
|
||||
@@ -29,6 +29,7 @@ struct TestServer {
|
||||
interrupts: Arc<AtomicUsize>,
|
||||
hangup: Arc<tokio::sync::Notify>,
|
||||
unsupported: bool,
|
||||
bastion: bool,
|
||||
}
|
||||
struct Handler {
|
||||
directory: PathBuf,
|
||||
@@ -39,6 +40,8 @@ struct Handler {
|
||||
/// Ends an SFTP session while nothing is asked of it.
|
||||
hangup: Arc<tokio::sync::Notify>,
|
||||
unsupported: bool,
|
||||
/// Ends the SFTP session on what a bastion host refuses.
|
||||
bastion: bool,
|
||||
channels: HashMap<ChannelId, russh::Channel<server::Msg>>,
|
||||
}
|
||||
impl server::Server for TestServer {
|
||||
@@ -52,6 +55,7 @@ impl server::Server for TestServer {
|
||||
interrupts: self.interrupts.clone(),
|
||||
hangup: self.hangup.clone(),
|
||||
unsupported: self.unsupported,
|
||||
bastion: self.bastion,
|
||||
channels: HashMap::new(),
|
||||
}
|
||||
}
|
||||
@@ -120,6 +124,7 @@ impl server::Handler for Handler {
|
||||
let directory = self.directory.clone();
|
||||
let interrupts = self.interrupts.clone();
|
||||
let hangup = self.hangup.clone();
|
||||
let bastion = self.bastion;
|
||||
tokio::spawn(async move {
|
||||
let executable = if cfg!(target_os = "macos") {
|
||||
"/usr/libexec/sftp-server"
|
||||
@@ -149,6 +154,9 @@ impl server::Handler for Handler {
|
||||
}
|
||||
let mut bytes = vec![0; size as usize];
|
||||
reader.read_exact(&mut bytes).await?;
|
||||
if bastion && refused_by_bastion(&bytes) {
|
||||
return Err::<(), _>(std::io::Error::other("refused by the bastion host"));
|
||||
}
|
||||
// SSH_FXP_WRITE (6) for uploads, SSH_FXP_READ (5) for downloads.
|
||||
if matches!(bytes.first(), Some(&5) | Some(&6)) {
|
||||
writes += 1;
|
||||
@@ -192,7 +200,27 @@ impl Drop for Running {
|
||||
self.task.abort();
|
||||
}
|
||||
}
|
||||
/// What a bastion host in front of OpenSSH was seen to refuse, though it
|
||||
/// passes on OpenSSH's offer of extensions: `SSH_FXP_FSETSTAT` (10), which it
|
||||
/// answered with a failure, and `fsync@openssh.com`, on which it ended the
|
||||
/// session. Here every `SSH_FXP_EXTENDED` (200) is refused: like WinSCP,
|
||||
/// ShellRS uses none to transfer or save a file.
|
||||
fn refused_by_bastion(packet: &[u8]) -> bool {
|
||||
matches!(packet.first(), Some(10 | 200))
|
||||
}
|
||||
async fn server(directory: PathBuf, interrupts: usize, unsupported: bool) -> Option<Running> {
|
||||
serve(directory, interrupts, unsupported, false).await
|
||||
}
|
||||
/// A server that ends the SFTP session on what a bastion host refuses.
|
||||
async fn bastion_server(directory: PathBuf) -> Option<Running> {
|
||||
serve(directory, 0, false, true).await
|
||||
}
|
||||
async fn serve(
|
||||
directory: PathBuf,
|
||||
interrupts: usize,
|
||||
unsupported: bool,
|
||||
bastion: bool,
|
||||
) -> Option<Running> {
|
||||
let listener = match tokio::net::TcpListener::bind(("127.0.0.1", 0)).await {
|
||||
Ok(l) => l,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::PermissionDenied => {
|
||||
@@ -215,6 +243,7 @@ async fn server(directory: PathBuf, interrupts: usize, unsupported: bool) -> Opt
|
||||
interrupts: interrupts.clone(),
|
||||
hangup: hangup.clone(),
|
||||
unsupported,
|
||||
bastion,
|
||||
};
|
||||
let key = russh::keys::PrivateKey::random(
|
||||
&mut russh::keys::key::safe_rng(),
|
||||
@@ -696,3 +725,109 @@ fn scp_style_transfers_land_where_scp_would_put_them() {
|
||||
assert!(!remote.join("missing").exists());
|
||||
});
|
||||
}
|
||||
|
||||
/// Behind a bastion host that refuses `fsetstat` and extensions, an upload,
|
||||
/// an upload over that file and an editor's save all finish on the one
|
||||
/// connection, and the upload keeps the local file's modification time.
|
||||
#[test]
|
||||
fn uploads_and_saves_get_past_a_bastion_host_that_refuses_fsetstat_and_extensions() {
|
||||
runtime().block_on(async {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let remote = temp.path().join("remote");
|
||||
std::fs::create_dir(&remote).unwrap();
|
||||
let Some(server) = bastion_server(remote.clone()).await else {
|
||||
return;
|
||||
};
|
||||
let worker = worker(server.port, temp.path());
|
||||
let home = loop {
|
||||
match next(&worker).await {
|
||||
SftpEvent::Connected { home } => break home,
|
||||
SftpEvent::Disconnected(e) => panic!("connect failed: {e}"),
|
||||
_ => {}
|
||||
}
|
||||
};
|
||||
let source = temp.path().join("README.md");
|
||||
let modified = std::time::UNIX_EPOCH + Duration::from_secs(1_500_000_000);
|
||||
let write_source = |bytes: &[u8]| {
|
||||
std::fs::write(&source, bytes).unwrap();
|
||||
std::fs::File::options()
|
||||
.write(true)
|
||||
.open(&source)
|
||||
.unwrap()
|
||||
.set_modified(modified)
|
||||
.unwrap();
|
||||
};
|
||||
let upload =
|
||||
|| SftpCommand::Upload(UploadRequest::new(vec![source.clone()], home.clone()).unwrap());
|
||||
let uploaded = remote.join("README.md");
|
||||
|
||||
write_source(b"first");
|
||||
let done = transfer(&worker, upload()).await;
|
||||
assert_eq!(done.phase(), TransferPhase::Completed);
|
||||
assert_eq!(std::fs::read(&uploaded).unwrap(), b"first");
|
||||
assert_eq!(
|
||||
std::fs::metadata(&uploaded).unwrap().modified().unwrap(),
|
||||
modified
|
||||
);
|
||||
|
||||
write_source(b"second");
|
||||
worker.commands.send(upload()).await.unwrap();
|
||||
loop {
|
||||
match next(&worker).await {
|
||||
SftpEvent::Question(question) => {
|
||||
assert_eq!(question.kind(), TransferQuestionKind::Conflict);
|
||||
worker
|
||||
.commands
|
||||
.send(SftpCommand::Answer {
|
||||
request_id: question.id(),
|
||||
answer: TransferAnswer::new(TransferChoice::Overwrite, false),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
SftpEvent::Progress(p) if p.phase() == TransferPhase::Completed => {
|
||||
assert_eq!(p.succeeded(), 1);
|
||||
break;
|
||||
}
|
||||
SftpEvent::Progress(p)
|
||||
if matches!(
|
||||
p.phase(),
|
||||
TransferPhase::Stopped | TransferPhase::Reconnecting
|
||||
) =>
|
||||
{
|
||||
panic!("the upload over the file broke off: {p:?}")
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
assert_eq!(std::fs::read(&uploaded).unwrap(), b"second");
|
||||
assert_eq!(
|
||||
std::fs::metadata(&uploaded).unwrap().modified().unwrap(),
|
||||
modified
|
||||
);
|
||||
|
||||
worker
|
||||
.commands
|
||||
.send(SftpCommand::WriteFile {
|
||||
request_id: 7,
|
||||
path: home.join("README.md").unwrap(),
|
||||
bytes: b"edited".to_vec(),
|
||||
expected: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
loop {
|
||||
match next(&worker).await {
|
||||
SftpEvent::FileWritten { request_id, result } => {
|
||||
assert_eq!(request_id, 7);
|
||||
result.unwrap();
|
||||
break;
|
||||
}
|
||||
SftpEvent::Disconnected(e) => panic!("the save broke off: {e}"),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
assert_eq!(std::fs::read(&uploaded).unwrap(), b"edited");
|
||||
assert_eq!(server.connections.load(Ordering::SeqCst), 1);
|
||||
});
|
||||
}
|
||||
+39
-52
@@ -41,11 +41,10 @@ struct Remote {
|
||||
reads: Cell<usize>,
|
||||
short_reads: Cell<bool>,
|
||||
renames: Cell<usize>,
|
||||
atomic: bool,
|
||||
denied: RefCell<Option<String>>,
|
||||
}
|
||||
impl Remote {
|
||||
fn new(atomic: bool) -> Self {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
nodes: RefCell::new(BTreeMap::new()),
|
||||
fault: Cell::new(None),
|
||||
@@ -55,7 +54,6 @@ impl Remote {
|
||||
reads: Cell::new(0),
|
||||
short_reads: Cell::new(false),
|
||||
renames: Cell::new(0),
|
||||
atomic,
|
||||
denied: RefCell::new(None),
|
||||
}
|
||||
}
|
||||
@@ -225,9 +223,6 @@ impl RemoteFs for Remote {
|
||||
self.writes.set(self.writes.get() + 1);
|
||||
self.fail(Fault::Write(self.writes.get()))
|
||||
}
|
||||
async fn sync(&self, _: &str) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
async fn close(&self, _: &str) -> Result<()> {
|
||||
if self.fault.get() == Some(Fault::ChangeTargetOnClose) {
|
||||
self.fault.set(None);
|
||||
@@ -241,12 +236,12 @@ impl RemoteFs for Remote {
|
||||
}
|
||||
async fn attributes(
|
||||
&self,
|
||||
handle: &str,
|
||||
path: &RemotePath,
|
||||
modified: Option<u32>,
|
||||
permissions: Option<u32>,
|
||||
) -> Result<()> {
|
||||
let mut nodes = self.nodes.borrow_mut();
|
||||
let n = nodes.get_mut(handle).unwrap();
|
||||
let n = nodes.get_mut(path.as_str()).unwrap();
|
||||
n.metadata = FileMetadata::new(
|
||||
EntryKind::File,
|
||||
n.data.len() as u64,
|
||||
@@ -283,13 +278,13 @@ impl RemoteFs for Remote {
|
||||
async fn readlink(&self, path: &RemotePath) -> Result<String> {
|
||||
Ok(self.nodes.borrow()[path.as_str()].link.clone())
|
||||
}
|
||||
async fn rename(&self, from: &RemotePath, to: &RemotePath, replace: bool) -> Result<()> {
|
||||
async fn rename(&self, from: &RemotePath, to: &RemotePath) -> Result<()> {
|
||||
if self.fault.get() == Some(Fault::RejectRename(self.renames.get() + 1)) {
|
||||
self.fault.set(None);
|
||||
bail!("publish denied");
|
||||
}
|
||||
let mut nodes = self.nodes.borrow_mut();
|
||||
if !replace && nodes.contains_key(to.as_str()) {
|
||||
if nodes.contains_key(to.as_str()) {
|
||||
bail!("target exists");
|
||||
}
|
||||
let node = nodes
|
||||
@@ -331,9 +326,6 @@ impl RemoteFs for Remote {
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
fn atomic_replace(&self) -> bool {
|
||||
self.atomic
|
||||
}
|
||||
}
|
||||
impl Remote {
|
||||
/// The path a link chain ends at, as a server's `open` follows it.
|
||||
@@ -496,7 +488,7 @@ fn recursive_upload_deduplicates_sources_and_preserves_empty_directories_and_lin
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let remote = Remote::new(true);
|
||||
let remote = Remote::new();
|
||||
run(&mut batch, &remote, &answers.control).await.unwrap();
|
||||
assert_eq!(remote.bytes("/dest/中文 目录/large.bin"), data);
|
||||
assert_eq!(remote.bytes("/dest/中文 目录/零 字节"), Vec::<u8>::new());
|
||||
@@ -540,7 +532,7 @@ fn winscp_style_upload_finishes_without_a_verification_phase() {
|
||||
&answers.control,
|
||||
)
|
||||
.await;
|
||||
let remote = Remote::new(true);
|
||||
let remote = Remote::new();
|
||||
|
||||
run(&mut batch, &remote, &answers.control).await.unwrap();
|
||||
tokio::task::yield_now().await;
|
||||
@@ -583,7 +575,7 @@ fn resume_uses_remote_filepart_size_and_preserves_old_target() {
|
||||
let data = vec![71; 150_000];
|
||||
std::fs::write(&source, &data).unwrap();
|
||||
let journal = Journal::new(tmp.path().join("journal"));
|
||||
let remote = Remote::new(false);
|
||||
let remote = Remote::new();
|
||||
remote.file("/dest/file", b"old file intact");
|
||||
remote.fault.set(Some(Fault::Write(2)));
|
||||
let answers = Answers::new(vec![TransferChoice::Overwrite]);
|
||||
@@ -633,7 +625,7 @@ fn filepart_larger_than_source_requires_explicit_restart() {
|
||||
let source = tmp.path().join("file");
|
||||
std::fs::write(&source, vec![1; 100_000]).unwrap();
|
||||
let journal = Journal::new(tmp.path().join("journal"));
|
||||
let remote = Remote::new(true);
|
||||
let remote = Remote::new();
|
||||
remote.fault.set(Some(Fault::Write(2)));
|
||||
let answers = Answers::new(vec![]);
|
||||
let mut first = batch(&source, journal.clone(), &answers.control).await;
|
||||
@@ -663,7 +655,7 @@ fn winscp_style_resume_does_not_prevalidate_the_source_version() {
|
||||
let data = vec![1; 100_000];
|
||||
std::fs::write(&source, &data).unwrap();
|
||||
let journal = Journal::new(tmp.path().join("journal"));
|
||||
let remote = Remote::new(true);
|
||||
let remote = Remote::new();
|
||||
remote.fault.set(Some(Fault::Write(2)));
|
||||
let answers = Answers::new(vec![]);
|
||||
let mut first = batch(&source, journal.clone(), &answers.control).await;
|
||||
@@ -700,7 +692,7 @@ fn winscp_style_resume_detects_filepart_without_a_local_record() {
|
||||
let source = tmp.path().join("file");
|
||||
let data = vec![3; 100_000];
|
||||
std::fs::write(&source, &data).unwrap();
|
||||
let remote = Remote::new(true);
|
||||
let remote = Remote::new();
|
||||
remote.file("/dest/file.filepart", &data[..32_768]);
|
||||
let answers = Answers::new(vec![TransferChoice::Resume]);
|
||||
let mut upload = batch(
|
||||
@@ -724,17 +716,12 @@ fn winscp_style_resume_detects_filepart_without_a_local_record() {
|
||||
#[test]
|
||||
fn lost_replies_at_backup_publish_and_close_are_reconciled() {
|
||||
runtime().block_on(async {
|
||||
for (atomic, fault) in [
|
||||
(true, Fault::Rename(1)),
|
||||
(false, Fault::Rename(1)),
|
||||
(false, Fault::Rename(2)),
|
||||
(false, Fault::Close),
|
||||
] {
|
||||
for fault in [Fault::Rename(1), Fault::Rename(2), Fault::Close] {
|
||||
let tmp = tempfile::tempdir_in(std::env::temp_dir().canonicalize().unwrap()).unwrap();
|
||||
let source = tmp.path().join("file");
|
||||
std::fs::write(&source, b"new contents").unwrap();
|
||||
let journal = Journal::new(tmp.path().join("journal"));
|
||||
let remote = Remote::new(atomic);
|
||||
let remote = Remote::new();
|
||||
remote.file("/dest/file", b"original");
|
||||
remote.fault.set(Some(fault));
|
||||
let answers = Answers::new(vec![TransferChoice::Overwrite]);
|
||||
@@ -756,7 +743,7 @@ fn cancellation_retains_journal_and_skip_does_not_overwrite() {
|
||||
let tmp = tempfile::tempdir_in(std::env::temp_dir().canonicalize().unwrap()).unwrap();
|
||||
let source = tmp.path().join("file");
|
||||
std::fs::write(&source, b"new").unwrap();
|
||||
let remote = Remote::new(true);
|
||||
let remote = Remote::new();
|
||||
remote.file("/dest/file", b"old");
|
||||
let answers = Answers::new(vec![TransferChoice::Skip]);
|
||||
let journal = Journal::new(tmp.path().join("journal"));
|
||||
@@ -783,7 +770,7 @@ fn directory_permission_error_is_reported_as_failure() {
|
||||
let tmp = tempfile::tempdir_in(std::env::temp_dir().canonicalize().unwrap()).unwrap();
|
||||
let source = tmp.path().join("blocked");
|
||||
std::fs::create_dir(&source).unwrap();
|
||||
let remote = Remote::new(true);
|
||||
let remote = Remote::new();
|
||||
*remote.denied.borrow_mut() = Some("/dest/blocked".into());
|
||||
let answers = Answers::new(vec![TransferChoice::Skip]);
|
||||
let mut batch = batch(
|
||||
@@ -842,7 +829,7 @@ fn retrying_an_unreadable_folder_adds_only_its_files_to_the_upload() {
|
||||
set_mode(0o755);
|
||||
assert_eq!(batch.meter.progress.total_bytes(), 10_000);
|
||||
|
||||
let remote = Remote::new(true);
|
||||
let remote = Remote::new();
|
||||
run(&mut batch, &remote, &answers.control).await.unwrap();
|
||||
assert_eq!(batch.meter.progress.failed(), 0);
|
||||
assert_eq!(batch.meter.progress.total_bytes(), 10_300);
|
||||
@@ -859,7 +846,7 @@ fn retrying_an_unreadable_folder_adds_only_its_files_to_the_download() {
|
||||
let tmp = tempfile::tempdir_in(std::env::temp_dir().canonicalize().unwrap()).unwrap();
|
||||
let out = tmp.path().join("out");
|
||||
std::fs::create_dir(&out).unwrap();
|
||||
let remote = Remote::new(true);
|
||||
let remote = Remote::new();
|
||||
remote.nodes.borrow_mut().insert(
|
||||
"/srv/folder".into(),
|
||||
Node {
|
||||
@@ -1066,7 +1053,7 @@ fn failed_compatibility_publish_restores_old_target_and_reports_failure() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let source = temp.path().join("file");
|
||||
std::fs::write(&source, b"new").unwrap();
|
||||
let remote = Remote::new(false);
|
||||
let remote = Remote::new();
|
||||
remote.file("/dest/file", b"original");
|
||||
remote.fault.set(Some(Fault::RejectRename(2)));
|
||||
let answers = Answers::new(vec![TransferChoice::Overwrite, TransferChoice::Skip]);
|
||||
@@ -1097,7 +1084,7 @@ fn target_changed_during_upload_requires_another_conflict_answer() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let source = temp.path().join("file");
|
||||
std::fs::write(&source, b"new").unwrap();
|
||||
let remote = Remote::new(true);
|
||||
let remote = Remote::new();
|
||||
remote.file("/dest/file", b"original");
|
||||
remote.fault.set(Some(Fault::ChangeTargetOnClose));
|
||||
let answers = Answers::new(vec![TransferChoice::Overwrite, TransferChoice::Skip]);
|
||||
@@ -1140,7 +1127,7 @@ fn target_removed_during_upload_still_requires_confirmation() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let source = temp.path().join("file");
|
||||
std::fs::write(&source, b"new").unwrap();
|
||||
let remote = Remote::new(true);
|
||||
let remote = Remote::new();
|
||||
remote.file("/dest/file", b"old");
|
||||
remote.fault.set(Some(Fault::RemoveTargetOnClose));
|
||||
let answers = Answers::new(vec![TransferChoice::Overwrite, TransferChoice::Skip]);
|
||||
@@ -1170,7 +1157,7 @@ fn remote_path(path: &str) -> RemotePath {
|
||||
#[test]
|
||||
fn deleting_a_tree_removes_links_but_never_what_they_point_to() {
|
||||
runtime().block_on(async {
|
||||
let remote = Remote::new(true);
|
||||
let remote = Remote::new();
|
||||
remote.dir("/keep", 0o755);
|
||||
remote.file("/keep/important", b"data");
|
||||
remote.dir("/gone", 0o755);
|
||||
@@ -1198,7 +1185,7 @@ fn deleting_a_tree_removes_links_but_never_what_they_point_to() {
|
||||
#[test]
|
||||
fn recursive_permissions_skip_links_and_can_keep_directories_searchable() {
|
||||
runtime().block_on(async {
|
||||
let remote = Remote::new(true);
|
||||
let remote = Remote::new();
|
||||
remote.dir("/site", 0o700);
|
||||
remote.dir("/site/css", 0o700);
|
||||
remote.file("/site/css/a.css", b"a");
|
||||
@@ -1231,7 +1218,7 @@ fn recursive_permissions_skip_links_and_can_keep_directories_searchable() {
|
||||
#[test]
|
||||
fn rename_and_create_refuse_existing_names() {
|
||||
runtime().block_on(async {
|
||||
let remote = Remote::new(true);
|
||||
let remote = Remote::new();
|
||||
remote.file("/a", b"a");
|
||||
remote.file("/b", b"b");
|
||||
let error = super::operations::run(
|
||||
@@ -1329,7 +1316,7 @@ fn download_copies_trees_links_and_empty_directories_in_order() {
|
||||
for short_reads in [false, true] {
|
||||
let out = tmp.path().join(format!("out-{short_reads}"));
|
||||
std::fs::create_dir(&out).unwrap();
|
||||
let remote = Remote::new(true);
|
||||
let remote = Remote::new();
|
||||
remote.short_reads.set(short_reads);
|
||||
remote.dir("/srv/中文 目录", 0o755);
|
||||
remote.file("/srv/中文 目录/large.bin", &data);
|
||||
@@ -1385,7 +1372,7 @@ fn download_resumes_from_the_partial_file_after_a_lost_connection() {
|
||||
runtime().block_on(async {
|
||||
let tmp = tempfile::tempdir_in(std::env::temp_dir().canonicalize().unwrap()).unwrap();
|
||||
let data: Vec<u8> = (0..300_000).map(|i| (i % 253) as u8).collect();
|
||||
let remote = Remote::new(true);
|
||||
let remote = Remote::new();
|
||||
remote.file("/big", &data);
|
||||
remote.fault.set(Some(Fault::Read(5)));
|
||||
let journal = DownloadJournal::new(tmp.path().join("journal"));
|
||||
@@ -1440,7 +1427,7 @@ fn download_asks_before_overwriting_and_keeps_the_replaced_permissions() {
|
||||
use std::os::unix::fs::PermissionsExt as _;
|
||||
std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o600)).unwrap();
|
||||
}
|
||||
let remote = Remote::new(true);
|
||||
let remote = Remote::new();
|
||||
remote.file("/file", b"new contents");
|
||||
let journal = DownloadJournal::new(tmp.path().join("journal"));
|
||||
let answers = Answers::new(vec![TransferChoice::Skip]);
|
||||
@@ -1484,7 +1471,7 @@ fn download_asks_before_overwriting_and_keeps_the_replaced_permissions() {
|
||||
fn a_changed_remote_file_is_not_resumed_and_discard_cleans_up() {
|
||||
runtime().block_on(async {
|
||||
let tmp = tempfile::tempdir_in(std::env::temp_dir().canonicalize().unwrap()).unwrap();
|
||||
let remote = Remote::new(true);
|
||||
let remote = Remote::new();
|
||||
remote.file("/file", &vec![7; 200_000]);
|
||||
remote.fault.set(Some(Fault::Read(4)));
|
||||
let journal = DownloadJournal::new(tmp.path().join("journal"));
|
||||
@@ -1554,7 +1541,7 @@ fn a_changed_remote_file_is_not_resumed_and_discard_cleans_up() {
|
||||
fn download_cancellation_keeps_the_partial_file() {
|
||||
runtime().block_on(async {
|
||||
let tmp = tempfile::tempdir_in(std::env::temp_dir().canonicalize().unwrap()).unwrap();
|
||||
let remote = Remote::new(true);
|
||||
let remote = Remote::new();
|
||||
remote.file("/file", &vec![1; 100_000]);
|
||||
let answers = Answers::new(vec![]);
|
||||
let mut batch = download_batch(
|
||||
@@ -1745,7 +1732,7 @@ fn an_scp_upload_takes_the_destination_name_and_keeps_the_execute_bits() {
|
||||
let source = tmp.path().join("build.sh");
|
||||
std::fs::write(&source, b"#!/bin/sh\n").unwrap();
|
||||
std::fs::set_permissions(&source, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
let remote = Remote::new(true);
|
||||
let remote = Remote::new();
|
||||
|
||||
let request = UploadRequest::scp(source.clone(), remote_path("/dest/deploy.sh"))
|
||||
.resolved(remote_path("/dest"), Some("deploy.sh".into()));
|
||||
@@ -1797,7 +1784,7 @@ fn an_scp_download_takes_the_destination_name_and_keeps_the_execute_bits() {
|
||||
let tmp = tempfile::tempdir_in(std::env::temp_dir().canonicalize().unwrap()).unwrap();
|
||||
let out = tmp.path().join("out");
|
||||
std::fs::create_dir(&out).unwrap();
|
||||
let remote = Remote::new(true);
|
||||
let remote = Remote::new();
|
||||
remote.file("/srv/run.sh", b"#!/bin/sh\n");
|
||||
remote
|
||||
.nodes
|
||||
@@ -1856,7 +1843,7 @@ fn an_scp_download_takes_the_destination_name_and_keeps_the_execute_bits() {
|
||||
#[test]
|
||||
fn a_text_file_is_read_whole_through_short_and_reordered_reads() {
|
||||
runtime().block_on(async {
|
||||
let remote = Remote::new(true);
|
||||
let remote = Remote::new();
|
||||
let text: String = (0..40_000).map(|i| format!("line {i}\n")).collect();
|
||||
remote.file("/etc/app.conf", text.as_bytes());
|
||||
remote.short_reads.set(true);
|
||||
@@ -1875,7 +1862,7 @@ fn a_text_file_is_read_whole_through_short_and_reordered_reads() {
|
||||
#[test]
|
||||
fn a_link_is_followed_to_the_file_it_points_at() {
|
||||
runtime().block_on(async {
|
||||
let remote = Remote::new(true);
|
||||
let remote = Remote::new();
|
||||
remote.file("/srv/real.conf", b"old\n");
|
||||
remote.link("/etc/app.conf", "/srv/real.conf");
|
||||
let path = remote_path("/etc/app.conf");
|
||||
@@ -1896,7 +1883,7 @@ fn a_link_is_followed_to_the_file_it_points_at() {
|
||||
#[test]
|
||||
fn directories_large_files_and_binary_files_are_refused() {
|
||||
runtime().block_on(async {
|
||||
let remote = Remote::new(true);
|
||||
let remote = Remote::new();
|
||||
remote.dir("/etc", 0o755);
|
||||
remote.file("/var/big.log", &vec![b'x'; EDIT_LIMIT as usize + 1]);
|
||||
let mut binary = vec![b'a'; 1_000_000];
|
||||
@@ -1924,7 +1911,7 @@ fn directories_large_files_and_binary_files_are_refused() {
|
||||
#[test]
|
||||
fn a_save_writes_in_place_and_keeps_the_permissions() {
|
||||
runtime().block_on(async {
|
||||
let remote = Remote::new(true);
|
||||
let remote = Remote::new();
|
||||
let long: Vec<u8> = (0..100_000u32).map(|i| (i % 251) as u8 + 1).collect();
|
||||
remote.file("/etc/app.conf", &long);
|
||||
let path = remote_path("/etc/app.conf");
|
||||
@@ -1952,7 +1939,7 @@ fn a_save_writes_in_place_and_keeps_the_permissions() {
|
||||
#[test]
|
||||
fn a_save_over_a_file_changed_or_removed_since_is_refused() {
|
||||
runtime().block_on(async {
|
||||
let remote = Remote::new(true);
|
||||
let remote = Remote::new();
|
||||
remote.file("/etc/app.conf", b"mine\n");
|
||||
let path = remote_path("/etc/app.conf");
|
||||
let file = read_text(&remote, &path).await.unwrap();
|
||||
@@ -1980,7 +1967,7 @@ fn a_save_over_a_file_changed_or_removed_since_is_refused() {
|
||||
#[test]
|
||||
fn a_save_that_fails_after_truncating_is_interrupted() {
|
||||
runtime().block_on(async {
|
||||
let remote = Remote::new(true);
|
||||
let remote = Remote::new();
|
||||
remote.file("/etc/app.conf", &vec![b'a'; 200_000]);
|
||||
remote.fault.set(Some(Fault::Write(2)));
|
||||
let error = write_in_place(
|
||||
@@ -2123,7 +2110,7 @@ fn local_files_are_edited_in_place_through_links() {
|
||||
#[test]
|
||||
fn a_binary_file_is_read_whole_for_a_preview_within_its_limit() {
|
||||
runtime().block_on(async {
|
||||
let remote = Remote::new(true);
|
||||
let remote = Remote::new();
|
||||
let image: Vec<u8> = (0..300_000u32).map(|i| (i % 256) as u8).collect();
|
||||
remote.file("/srv/photo.png", &image);
|
||||
remote.short_reads.set(true);
|
||||
@@ -2169,7 +2156,7 @@ fn a_sync_copies_only_what_changed_since_the_last() {
|
||||
.unwrap();
|
||||
std::fs::write(dist.join("assets/logo.png"), b"png").unwrap();
|
||||
std::os::unix::fs::symlink("index.html", dist.join("home.html")).unwrap();
|
||||
let remote = Remote::new(true);
|
||||
let remote = Remote::new();
|
||||
remote.dir("/srv", 0o755);
|
||||
|
||||
let answers = Answers::new(vec![]);
|
||||
@@ -2217,7 +2204,7 @@ fn a_sync_with_delete_removes_what_is_not_here_and_nothing_it_cannot_see() {
|
||||
std::fs::set_permissions(dist.join("locked"), std::fs::Permissions::from_mode(0o000))
|
||||
.unwrap();
|
||||
|
||||
let remote = Remote::new(true);
|
||||
let remote = Remote::new();
|
||||
remote.dir("/srv/app", 0o755);
|
||||
remote.file("/srv/app/keep.txt", b"k");
|
||||
remote.file("/srv/app/extra.txt", b"x");
|
||||
|
||||
+29
-37
@@ -532,25 +532,6 @@ impl UploadBatch {
|
||||
uploaded += length;
|
||||
self.meter.advance(length, uploaded, control);
|
||||
}
|
||||
control
|
||||
.run(
|
||||
fs.attributes(
|
||||
&handle,
|
||||
u32::try_from(source_metadata.modified_ns / 1_000_000_000).ok(),
|
||||
record
|
||||
.original
|
||||
.as_ref()
|
||||
.and_then(FileMetadata::permissions)
|
||||
.or_else(|| {
|
||||
self.preserve_mode
|
||||
.then(|| item.metadata.permissions())
|
||||
.flatten()
|
||||
.map(|mode| mode & 0o777)
|
||||
}),
|
||||
),
|
||||
)
|
||||
.await?;
|
||||
control.run(fs.sync(&handle)).await?;
|
||||
Ok(())
|
||||
}
|
||||
.await;
|
||||
@@ -559,7 +540,24 @@ impl UploadBatch {
|
||||
let close = fs.close(&handle).await;
|
||||
result?;
|
||||
close?;
|
||||
Ok(())
|
||||
control
|
||||
.run(
|
||||
fs.attributes(
|
||||
&record.temporary,
|
||||
u32::try_from(source_metadata.modified_ns / 1_000_000_000).ok(),
|
||||
record
|
||||
.original
|
||||
.as_ref()
|
||||
.and_then(FileMetadata::permissions)
|
||||
.or_else(|| {
|
||||
self.preserve_mode
|
||||
.then(|| item.metadata.permissions())
|
||||
.flatten()
|
||||
.map(|mode| mode & 0o777)
|
||||
}),
|
||||
),
|
||||
)
|
||||
.await
|
||||
}
|
||||
async fn matches_expected_item<F: RemoteFs>(
|
||||
&self,
|
||||
@@ -603,7 +601,7 @@ impl UploadBatch {
|
||||
if fs.metadata(&record.target).await?.is_none()
|
||||
&& fs.metadata(&record.backup).await?.is_some()
|
||||
{
|
||||
fs.rename(&record.backup, &record.target, false).await?;
|
||||
fs.rename(&record.backup, &record.target).await?;
|
||||
}
|
||||
bail!(t!("sftp.upload.publish_unconfirmed"));
|
||||
}
|
||||
@@ -635,24 +633,22 @@ impl UploadBatch {
|
||||
record.original = current;
|
||||
self.journal.save(record).await?;
|
||||
if let Some(source_metadata) = &record.source_metadata {
|
||||
let handle = control.run(fs.open(&record.temporary, false)).await?;
|
||||
let result = control
|
||||
control
|
||||
.run(fs.attributes(
|
||||
&handle,
|
||||
&record.temporary,
|
||||
u32::try_from(source_metadata.modified_ns / 1_000_000_000).ok(),
|
||||
record.original.as_ref().and_then(FileMetadata::permissions),
|
||||
))
|
||||
.await;
|
||||
let closed = fs.close(&handle).await;
|
||||
result?;
|
||||
closed?;
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
if backup.is_none() && record.original.is_some() && !fs.atomic_replace() {
|
||||
// Over a file, the old one is moved aside first and removed once
|
||||
// the new one is in its place: no `posix-rename@openssh.com`.
|
||||
if backup.is_none() && record.original.is_some() {
|
||||
record.phase = PublishPhase::BackingUp;
|
||||
self.journal.save(record).await?;
|
||||
control
|
||||
.run(fs.rename(&record.target, &record.backup, false))
|
||||
.run(fs.rename(&record.target, &record.backup))
|
||||
.await?;
|
||||
record.phase = PublishPhase::BackedUp;
|
||||
self.journal.save(record).await?;
|
||||
@@ -660,11 +656,7 @@ impl UploadBatch {
|
||||
record.phase = PublishPhase::Publishing;
|
||||
self.journal.save(record).await?;
|
||||
let result = control
|
||||
.run(fs.rename(
|
||||
&record.temporary,
|
||||
&record.target,
|
||||
fs.atomic_replace() && record.original.is_some() && backup.is_none(),
|
||||
))
|
||||
.run(fs.rename(&record.temporary, &record.target))
|
||||
.await;
|
||||
if let Err(error) = result {
|
||||
if !super::client::is_network_error(&error)
|
||||
@@ -672,7 +664,7 @@ impl UploadBatch {
|
||||
&& fs.metadata(&record.target).await?.is_none()
|
||||
&& fs.metadata(&record.backup).await?.is_some()
|
||||
{
|
||||
fs.rename(&record.backup, &record.target, false).await?;
|
||||
fs.rename(&record.backup, &record.target).await?;
|
||||
record.phase = PublishPhase::Ready;
|
||||
self.journal.save(record).await?;
|
||||
}
|
||||
@@ -701,7 +693,7 @@ impl UploadBatch {
|
||||
)?;
|
||||
if fs.metadata(&record.backup).await?.is_some() {
|
||||
if fs.metadata(&record.target).await?.is_none() {
|
||||
fs.rename(&record.backup, &record.target, false).await?;
|
||||
fs.rename(&record.backup, &record.target).await?;
|
||||
} else {
|
||||
bail!(t!("sftp.upload.backup_and_target"));
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
@@ -62,6 +63,8 @@ pub struct TerminalSnapshot {
|
||||
pub cursor_shape: CursorShape,
|
||||
pub cursor_blinking: bool,
|
||||
pub display_offset: usize,
|
||||
/// The lines above the screen, which the view can scroll back through.
|
||||
pub history_size: usize,
|
||||
pub colors: [Option<Rgb>; COUNT],
|
||||
/// The addresses of the links on screen, by the cells' `link`.
|
||||
pub links: Vec<gpui_kit::SharedString>,
|
||||
@@ -359,6 +362,11 @@ impl TerminalEngine {
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Scrolls the view without a context, for the scrollbar's drag.
|
||||
pub fn scrollback(&self) -> Scrollback {
|
||||
Scrollback(self.runtime.term.clone())
|
||||
}
|
||||
|
||||
pub fn start_selection(
|
||||
&mut self,
|
||||
point: Point,
|
||||
@@ -493,6 +501,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(),
|
||||
@@ -561,12 +595,35 @@ impl TerminalEngine {
|
||||
cursor_shape: content.cursor.shape,
|
||||
cursor_blinking: term.cursor_style().blinking,
|
||||
display_offset: content.display_offset,
|
||||
history_size: term.grid().history_size(),
|
||||
colors: std::array::from_fn(|index| content.colors[index]),
|
||||
links: links.into_links(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A terminal's view of its history, for the scrollbar, whose drag hands
|
||||
/// over a position and no context: whoever draws the terminal is told to
|
||||
/// redraw it. Taken for each frame, as a restart replaces the terminal.
|
||||
#[derive(Clone)]
|
||||
pub struct Scrollback(Arc<FairMutex<AlacrittyTerm>>);
|
||||
|
||||
impl Scrollback {
|
||||
/// Show the history from `lines` below its oldest line.
|
||||
pub fn scroll_to(&self, lines: usize) {
|
||||
scroll_to(&mut self.0.lock(), lines);
|
||||
}
|
||||
}
|
||||
|
||||
/// Lines are counted from the top, which stays put while output arrives
|
||||
/// below: the view stays where the pointer let it go.
|
||||
fn scroll_to(term: &mut AlacrittyTerm, lines: usize) {
|
||||
let grid = term.grid();
|
||||
let offset = grid.history_size().saturating_sub(lines);
|
||||
let delta = offset as i32 - grid.display_offset() as i32;
|
||||
term.scroll_display(Scroll::Delta(delta));
|
||||
}
|
||||
|
||||
/// The text of the line the cursor is on, or of the nearest line above it
|
||||
/// on screen that has any: where a program that rings for an answer has
|
||||
/// its question.
|
||||
@@ -636,6 +693,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 +722,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 +773,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 +816,9 @@ impl TerminalRuntime {
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(run) = lock_run(&parser_run).take() {
|
||||
run.disconnected();
|
||||
}
|
||||
})
|
||||
.expect("terminal parser thread");
|
||||
|
||||
@@ -772,6 +838,7 @@ impl TerminalRuntime {
|
||||
commands,
|
||||
size: shared_size,
|
||||
wakeup_pending,
|
||||
run,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -971,6 +1038,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 {
|
||||
@@ -1540,6 +1622,21 @@ mod tests {
|
||||
assert_eq!(cells[2].search, SearchMark::None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_scrollbar_counts_lines_from_the_top_of_the_history() {
|
||||
let mut term = test_term(8, 2);
|
||||
feed(&mut term, "1\r\n2\r\n3\r\n4\r\n5".as_bytes());
|
||||
assert_eq!(term.grid().history_size(), 3);
|
||||
|
||||
scroll_to(&mut term, 0);
|
||||
assert_eq!(term.grid().display_offset(), 3);
|
||||
scroll_to(&mut term, 2);
|
||||
assert_eq!(term.grid().display_offset(), 1);
|
||||
// Past the bottom is the bottom.
|
||||
scroll_to(&mut term, 10);
|
||||
assert_eq!(term.grid().display_offset(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn user_input_returns_to_bottom_and_clears_selection() {
|
||||
let mut term = test_term(8, 2);
|
||||
|
||||
@@ -10,6 +10,8 @@ mod local_terminal_panel;
|
||||
mod model;
|
||||
mod mouse;
|
||||
mod notices;
|
||||
mod run;
|
||||
mod scrollbar;
|
||||
mod search;
|
||||
mod terminal_panel;
|
||||
mod terminal_view;
|
||||
@@ -31,6 +33,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,712 @@
|
||||
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 $$");
|
||||
// dash, as the sh that waits for it, also says "Terminated" there.
|
||||
let Scanned::Output(output) = &found[1] else {
|
||||
panic!("{found:?}");
|
||||
};
|
||||
assert!(output.starts_with(b"/\n"), "{found:?}");
|
||||
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()))
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
//! The terminal's scrollbar: gpui-kit's, over the scrollback.
|
||||
//!
|
||||
//! The grid is drawn afresh each frame and scrolls by whole lines, so there
|
||||
//! is no scroll handle for gpui-kit's `Scrollbar` to follow. This one gives
|
||||
//! it the history in pixels: a line is a row's height, the content is the
|
||||
//! scrollbar's own height plus the history, and the offset is how far the
|
||||
//! view is from the oldest line.
|
||||
|
||||
use std::cell::Cell;
|
||||
use std::rc::Rc;
|
||||
|
||||
use gpui_kit::component::scroll::ScrollbarHandle;
|
||||
use gpui_kit::{Bounds, Pixels, Point, Size, point, px, size};
|
||||
|
||||
use super::TerminalSnapshot;
|
||||
use super::engine::Scrollback;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(super) struct ScrollbackHandle {
|
||||
scrollback: Scrollback,
|
||||
history_size: usize,
|
||||
display_offset: usize,
|
||||
line_height: Pixels,
|
||||
/// Where the scrollbar lies, which the canvas under it notes before the
|
||||
/// scrollbar is laid out in the same frame.
|
||||
viewport: Rc<Cell<Bounds<Pixels>>>,
|
||||
}
|
||||
|
||||
impl ScrollbackHandle {
|
||||
pub(super) fn new(
|
||||
scrollback: Scrollback,
|
||||
snapshot: &TerminalSnapshot,
|
||||
line_height: Pixels,
|
||||
) -> Self {
|
||||
Self {
|
||||
scrollback,
|
||||
history_size: snapshot.history_size,
|
||||
display_offset: snapshot.display_offset,
|
||||
line_height,
|
||||
viewport: Rc::default(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn set_viewport(&self, bounds: Bounds<Pixels>) {
|
||||
self.viewport.set(bounds);
|
||||
}
|
||||
}
|
||||
|
||||
impl ScrollbarHandle for ScrollbackHandle {
|
||||
fn viewport_bounds(&self) -> Bounds<Pixels> {
|
||||
self.viewport.get()
|
||||
}
|
||||
|
||||
fn offset(&self) -> Point<Pixels> {
|
||||
point(
|
||||
px(0.),
|
||||
offset(self.history_size, self.display_offset, self.line_height),
|
||||
)
|
||||
}
|
||||
|
||||
fn set_offset(&self, offset: Point<Pixels>) {
|
||||
self.scrollback
|
||||
.scroll_to(lines_from_top(offset.y, self.line_height));
|
||||
}
|
||||
|
||||
fn content_size(&self) -> Size<Pixels> {
|
||||
let viewport = self.viewport.get().size;
|
||||
size(
|
||||
viewport.width,
|
||||
viewport.height + self.line_height * self.history_size as f32,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// How far the view is scrolled down from the oldest line, as gpui-kit
|
||||
/// counts it: zero at the top, negative below.
|
||||
fn offset(history_size: usize, display_offset: usize, line_height: Pixels) -> Pixels {
|
||||
-(line_height * history_size.saturating_sub(display_offset) as f32)
|
||||
}
|
||||
|
||||
/// The line at the top of the view for a scroll `offset`.
|
||||
fn lines_from_top(offset: Pixels, line_height: Pixels) -> usize {
|
||||
(-offset / line_height).round().max(0.) as usize
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn the_offset_counts_whole_lines_from_the_oldest() {
|
||||
let line = px(17.);
|
||||
// At the bottom of 100 lines of history, 100 lines down.
|
||||
assert_eq!(offset(100, 0, line), px(-1700.));
|
||||
// At the top.
|
||||
assert_eq!(offset(100, 100, line), px(0.));
|
||||
assert_eq!(lines_from_top(offset(100, 40, line), line), 60);
|
||||
// A drag lands between lines; past the top is the top.
|
||||
assert_eq!(lines_from_top(px(-25.), line), 1);
|
||||
assert_eq!(lines_from_top(px(-26.), line), 2);
|
||||
assert_eq!(lines_from_top(px(5.), line), 0);
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ use gpui_kit::component::{
|
||||
h_flex,
|
||||
input::{Input, InputEvent, InputState},
|
||||
menu::PopupMenu,
|
||||
scroll::Scrollbar,
|
||||
tooltip::Tooltip,
|
||||
v_flex,
|
||||
};
|
||||
@@ -29,6 +30,7 @@ use crate::connection::{ConnectionPromptReply, Latency};
|
||||
use crate::i18n::{UiLocale, t};
|
||||
|
||||
use super::mouse::{self, MouseReport, ReportButton, ReportKind};
|
||||
use super::scrollbar::ScrollbackHandle;
|
||||
use super::search::SearchMark;
|
||||
use super::{
|
||||
SearchDirection, SharedTerminalTransportFactory, TerminalColors, TerminalEngine, TerminalEvent,
|
||||
@@ -402,6 +404,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()
|
||||
}
|
||||
@@ -910,6 +917,12 @@ impl EntityInputHandler for TerminalView {
|
||||
impl Render for TerminalView {
|
||||
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let snapshot = self.engine.read(cx).snapshot();
|
||||
let font = TerminalFont::current(cx);
|
||||
let scrollback = ScrollbackHandle::new(
|
||||
self.engine.read(cx).scrollback(),
|
||||
&snapshot,
|
||||
font.row_height(window),
|
||||
);
|
||||
let view = cx.entity();
|
||||
let context_menu = self.context_menu.clone();
|
||||
let element = TerminalElement {
|
||||
@@ -922,7 +935,6 @@ impl Render for TerminalView {
|
||||
requested_size: self.requested_size,
|
||||
};
|
||||
|
||||
let font = TerminalFont::current(cx);
|
||||
let terminal = div()
|
||||
.id(self.element_id.clone())
|
||||
.test_support()
|
||||
@@ -979,6 +991,26 @@ impl Render for TerminalView {
|
||||
// the terminal's too.
|
||||
.bg(TerminalColors::current(cx).background())
|
||||
.child(terminal)
|
||||
// Over the whole margin, so the thumb lies mostly beside the text.
|
||||
.child(
|
||||
div()
|
||||
.absolute()
|
||||
.inset_0()
|
||||
.child(
|
||||
canvas(
|
||||
{
|
||||
let scrollback = scrollback.clone();
|
||||
move |bounds, _, _| scrollback.set_viewport(bounds)
|
||||
},
|
||||
|_, _, _, _| {},
|
||||
)
|
||||
.absolute()
|
||||
.top_0()
|
||||
.left_0()
|
||||
.size_full(),
|
||||
)
|
||||
.child(Scrollbar::vertical(&scrollback).id("terminal-scrollbar")),
|
||||
)
|
||||
.when_some(self.find.as_ref(), |container, find| {
|
||||
container.child(self.render_find_bar(find, cx))
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -921,6 +921,25 @@ async fn programs_that_ask_for_the_mouse_hear_clicks_and_the_wheel(cx: &mut Test
|
||||
take_written(cx, handle, &factory, "\x1b[<64;5;2M").await;
|
||||
}
|
||||
|
||||
#[gpui_kit::test]
|
||||
async fn the_scrollbar_scrolls_back_through_the_history(cx: &mut TestAppContext) {
|
||||
let text: String = (1..=200).map(|line| format!("line {line}\r\n")).collect();
|
||||
let (handle, workspace, _) = terminal_printing(cx, String::leak(text + "end"), "end").await;
|
||||
let screen = |cx: &mut TestAppContext| cx.read(|cx| local_screen(&workspace, cx));
|
||||
assert!(!screen(cx).lines().any(|line| line == "line 1"));
|
||||
|
||||
// A click at the top of the scrollbar's track, in the margin beside the
|
||||
// text, goes to the oldest line.
|
||||
in_frame(cx, handle, |window, cx| {
|
||||
let terminal = window.find(("local-terminal", 1_u64)).bounds();
|
||||
let top = point(terminal.right() + px(2.), terminal.top() + px(2.));
|
||||
click_at(window, top, gpui_kit::Modifiers::none(), cx);
|
||||
});
|
||||
let lines = screen(cx);
|
||||
assert!(lines.lines().any(|line| line == "line 1"), "{lines}");
|
||||
assert!(!lines.contains("end"), "{lines}");
|
||||
}
|
||||
|
||||
#[gpui_kit::test]
|
||||
async fn a_program_copies_to_the_local_clipboard(cx: &mut TestAppContext) {
|
||||
// 「你好」 in base64.
|
||||
|
||||
Reference in new issue
Block a user