From 208454e202eecd2431dd3adc25255aacff992d9c Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Tue, 28 Jul 2026 10:59:46 +0800 Subject: [PATCH 01/16] =?UTF-8?q?feat(remote):=20remote=20workspaces=20?= =?UTF-8?q?=E2=80=94=20a=20window=20that=20is=20one=20machine?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Split the framework-free half of tty7 into `tty7-core` and add a headless `tty7-server` built on it, so a workspace's filesystem, git and session state can live on another machine while the GUI stays where it is. - `crates/tty7-core`: wire protocol, session daemon, PTY, native SSH engine and the domain model, with no gpui dependency. Module paths are unchanged. - `crates/tty7-server`: the same daemon with no GUI attached, linked fully static against musl and pushed onto the remote box. One dependency, on purpose — a second one the GUI also needs belongs in core. - `Host` trait + `HostId`/`HostRegistry`: every fs/git/watch call a workspace makes goes through the machine it belongs to. `LocalHost` answers on this box, `RemoteHost` over a routed control connection. - `ui::host_ops`: the GUI's single door to a `Host`. Host calls block, so all of them run on the background executor with the result landed on the UI thread; de-duplication, staleness and error reporting live here rather than at each call site. Enforced by a CI grep. - Connect flow: home page → pick a configured SSH host → the machine's own workspace list → a window bound to one workspace on it. Workspace switcher groups by machine, this computer included. - CI: static musl builds of `tty7-server` for x86_64/aarch64 via cargo-zigbuild, a host-boundary grep, and version stamping factored out of the nightly workflow. Both new jobs are non-required so branch protection does not wedge open PRs. Design and the interface contract it was built to are in `docs/2026-07-27-remote-workspace-{design,impl-contract}.md`. --- .github/scripts/assert-static.sh | 55 + .github/scripts/bundle-linux.sh | 9 +- .github/scripts/bundle-macos.sh | 10 +- .github/scripts/bundle-windows.ps1 | 17 + .github/scripts/check-host-boundary.sh | 167 + .github/scripts/stamp-version.sh | 34 + .github/scripts/windows-installer.iss | 4 + .github/workflows/ci.yml | 109 + .github/workflows/nightly.yml | 137 +- .github/workflows/release.yml | 146 +- .gitignore | 3 + Cargo.lock | 91 +- Cargo.toml | 134 +- crates/tty7-core/Cargo.toml | 181 + .../tty7-core/src}/core/agent_hooks.rs | 0 .../tty7-core/src}/core/cli_agent.rs | 0 crates/tty7-core/src/core/config.rs | 1731 ++++++++++ {src => crates/tty7-core/src}/core/crash.rs | 0 crates/tty7-core/src/core/git.rs | 303 ++ crates/tty7-core/src/core/gitignore.rs | 169 + crates/tty7-core/src/core/keychain.rs | 178 + crates/tty7-core/src/core/logfile.rs | 235 ++ crates/tty7-core/src/core/mod.rs | 34 + {src => crates/tty7-core/src}/core/osc.rs | 0 {src => crates/tty7-core/src}/core/proc.rs | 0 crates/tty7-core/src/core/session.rs | 1672 ++++++++++ {src => crates/tty7-core/src}/core/shells.rs | 0 .../tty7-core/src}/core/ssh_profile.rs | 0 {src => crates/tty7-core/src}/core/threads.rs | 0 crates/tty7-core/src/core/window_state.rs | 91 + crates/tty7-core/src/core/workspace_store.rs | 1020 ++++++ .../tty7-core/src}/core/worktree.rs | 288 +- crates/tty7-core/src/daemon/control.rs | 2917 ++++++++++++++++ crates/tty7-core/src/daemon/duplex.rs | 345 ++ crates/tty7-core/src/daemon/install/asset.rs | 429 +++ .../tty7-core/src/daemon/install/checksums.rs | 301 ++ .../tty7-core/src/daemon/install/download.rs | 168 + crates/tty7-core/src/daemon/install/mod.rs | 1154 +++++++ .../tty7-core/src/daemon/install/ssh_ops.rs | 273 ++ crates/tty7-core/src/daemon/install/tests.rs | 1159 +++++++ crates/tty7-core/src/daemon/install/wsl.rs | 1917 +++++++++++ {src => crates/tty7-core/src}/daemon/mod.rs | 8 + {src => crates/tty7-core/src}/daemon/pane.rs | 0 .../tty7-core/src}/daemon/pidfile.rs | 0 .../tty7-core/src}/daemon/procinfo.rs | 0 .../tty7-core/src}/daemon/protocol.rs | 241 +- .../tty7-core/src}/daemon/remote.rs | 0 crates/tty7-core/src/daemon/remote_link.rs | 676 ++++ crates/tty7-core/src/daemon/router.rs | 1877 +++++++++++ .../tty7-core/src}/daemon/server.rs | 45 +- .../src}/daemon/shell_integration.rs | 0 {src => crates/tty7-core/src}/daemon/spawn.rs | 1 + .../tty7-core/src}/daemon/ssh/auth.rs | 58 +- .../tty7-core/src}/daemon/ssh/broker.rs | 0 .../tty7-core/src}/daemon/ssh/connect.rs | 81 +- .../tty7-core/src}/daemon/ssh/forward.rs | 480 ++- .../tty7-core/src}/daemon/ssh/handler.rs | 0 .../tty7-core/src}/daemon/ssh/known_hosts.rs | 0 .../tty7-core/src}/daemon/ssh/mod.rs | 238 ++ .../tty7-core/src}/daemon/ssh/session.rs | 73 + .../tty7-core/src}/daemon/ssh/sftp.rs | 37 + crates/tty7-core/src/daemon/ssh/workspace.rs | 146 + .../tty7-core/src}/daemon/transport.rs | 84 +- .../tty7-core/src}/daemon/winproc.rs | 0 crates/tty7-core/src/host/conformance.rs | 1274 +++++++ crates/tty7-core/src/host/local.rs | 690 ++++ crates/tty7-core/src/host/mod.rs | 738 +++++ crates/tty7-core/src/host/remote.rs | 1013 ++++++ crates/tty7-core/src/host/server.rs | 2947 +++++++++++++++++ crates/tty7-core/src/lib.rs | 23 + crates/tty7-server/Cargo.toml | 35 + crates/tty7-server/src/main.rs | 387 +++ crates/tty7-server/tests/cli.rs | 198 ++ crates/tty7-server/tests/remote_router.rs | 201 ++ crates/tty7-server/tests/routed_pane.rs | 301 ++ crates/tty7-server/tests/stdio_conformance.rs | 171 + crates/tty7-server/tests/workspace_store.rs | 539 +++ docs/2026-07-27-remote-workspace-design.md | 430 +++ ...26-07-27-remote-workspace-impl-contract.md | 1380 ++++++++ docs/remote-server-assets.md | 122 + src/core/actions.rs | 4 + src/core/config.rs | 1586 +-------- src/core/keychain.rs | 195 +- src/core/mod.rs | 30 +- src/core/session.rs | 1164 ++----- src/core/window_state.rs | 100 +- src/daemon.rs | 15 + src/main.rs | 9 +- src/terminal/element.rs | 5 +- src/terminal/git_diff.rs | 41 +- src/terminal/git_status.rs | 491 ++- src/terminal/mod.rs | 3 +- src/terminal/pane_liveness.rs | 473 +++ src/terminal/remote.rs | 659 +++- src/terminal/view.rs | 1374 +++++++- src/ui/app.rs | 850 +++-- src/ui/code_editor.rs | 800 ++++- src/ui/diff_overlay.rs | 71 +- src/ui/file_tree.rs | 1491 ++++++--- src/ui/home.rs | 311 +- src/ui/host_ops.rs | 497 +++ src/ui/host_registry.rs | 145 + src/ui/keymap.rs | 2 + src/ui/mod.rs | 11 + src/ui/palette.rs | 57 +- src/ui/remote_connect.rs | 1262 +++++++ src/ui/remote_workspace.rs | 2101 ++++++++++++ src/ui/right_panel.rs | 128 +- src/ui/sftp.rs | 167 +- src/ui/ssh_prompt.rs | 97 +- src/ui/switcher.rs | 1301 ++++++++ src/ui/tab_sidebar.rs | 36 +- src/ui/tab_strip.rs | 290 +- src/ui/theme.rs | 6 +- src/ui/windows.rs | 243 +- src/ui/worktree_prompt.rs | 24 +- 116 files changed, 41232 insertions(+), 4782 deletions(-) create mode 100755 .github/scripts/assert-static.sh create mode 100755 .github/scripts/check-host-boundary.sh create mode 100755 .github/scripts/stamp-version.sh create mode 100644 crates/tty7-core/Cargo.toml rename {src => crates/tty7-core/src}/core/agent_hooks.rs (100%) rename {src => crates/tty7-core/src}/core/cli_agent.rs (100%) create mode 100644 crates/tty7-core/src/core/config.rs rename {src => crates/tty7-core/src}/core/crash.rs (100%) create mode 100644 crates/tty7-core/src/core/git.rs create mode 100644 crates/tty7-core/src/core/gitignore.rs create mode 100644 crates/tty7-core/src/core/keychain.rs create mode 100644 crates/tty7-core/src/core/logfile.rs create mode 100644 crates/tty7-core/src/core/mod.rs rename {src => crates/tty7-core/src}/core/osc.rs (100%) rename {src => crates/tty7-core/src}/core/proc.rs (100%) create mode 100644 crates/tty7-core/src/core/session.rs rename {src => crates/tty7-core/src}/core/shells.rs (100%) rename {src => crates/tty7-core/src}/core/ssh_profile.rs (100%) rename {src => crates/tty7-core/src}/core/threads.rs (100%) create mode 100644 crates/tty7-core/src/core/window_state.rs create mode 100644 crates/tty7-core/src/core/workspace_store.rs rename {src => crates/tty7-core/src}/core/worktree.rs (62%) create mode 100644 crates/tty7-core/src/daemon/control.rs create mode 100644 crates/tty7-core/src/daemon/duplex.rs create mode 100644 crates/tty7-core/src/daemon/install/asset.rs create mode 100644 crates/tty7-core/src/daemon/install/checksums.rs create mode 100644 crates/tty7-core/src/daemon/install/download.rs create mode 100644 crates/tty7-core/src/daemon/install/mod.rs create mode 100644 crates/tty7-core/src/daemon/install/ssh_ops.rs create mode 100644 crates/tty7-core/src/daemon/install/tests.rs create mode 100644 crates/tty7-core/src/daemon/install/wsl.rs rename {src => crates/tty7-core/src}/daemon/mod.rs (87%) rename {src => crates/tty7-core/src}/daemon/pane.rs (100%) rename {src => crates/tty7-core/src}/daemon/pidfile.rs (100%) rename {src => crates/tty7-core/src}/daemon/procinfo.rs (100%) rename {src => crates/tty7-core/src}/daemon/protocol.rs (88%) rename {src => crates/tty7-core/src}/daemon/remote.rs (100%) create mode 100644 crates/tty7-core/src/daemon/remote_link.rs create mode 100644 crates/tty7-core/src/daemon/router.rs rename {src => crates/tty7-core/src}/daemon/server.rs (95%) rename {src => crates/tty7-core/src}/daemon/shell_integration.rs (100%) rename {src => crates/tty7-core/src}/daemon/spawn.rs (99%) rename {src => crates/tty7-core/src}/daemon/ssh/auth.rs (93%) rename {src => crates/tty7-core/src}/daemon/ssh/broker.rs (100%) rename {src => crates/tty7-core/src}/daemon/ssh/connect.rs (85%) rename {src => crates/tty7-core/src}/daemon/ssh/forward.rs (67%) rename {src => crates/tty7-core/src}/daemon/ssh/handler.rs (100%) rename {src => crates/tty7-core/src}/daemon/ssh/known_hosts.rs (100%) rename {src => crates/tty7-core/src}/daemon/ssh/mod.rs (71%) rename {src => crates/tty7-core/src}/daemon/ssh/session.rs (85%) rename {src => crates/tty7-core/src}/daemon/ssh/sftp.rs (96%) create mode 100644 crates/tty7-core/src/daemon/ssh/workspace.rs rename {src => crates/tty7-core/src}/daemon/transport.rs (88%) rename {src => crates/tty7-core/src}/daemon/winproc.rs (100%) create mode 100644 crates/tty7-core/src/host/conformance.rs create mode 100644 crates/tty7-core/src/host/local.rs create mode 100644 crates/tty7-core/src/host/mod.rs create mode 100644 crates/tty7-core/src/host/remote.rs create mode 100644 crates/tty7-core/src/host/server.rs create mode 100644 crates/tty7-core/src/lib.rs create mode 100644 crates/tty7-server/Cargo.toml create mode 100644 crates/tty7-server/src/main.rs create mode 100644 crates/tty7-server/tests/cli.rs create mode 100644 crates/tty7-server/tests/remote_router.rs create mode 100644 crates/tty7-server/tests/routed_pane.rs create mode 100644 crates/tty7-server/tests/stdio_conformance.rs create mode 100644 crates/tty7-server/tests/workspace_store.rs create mode 100644 docs/2026-07-27-remote-workspace-design.md create mode 100644 docs/2026-07-27-remote-workspace-impl-contract.md create mode 100644 docs/remote-server-assets.md create mode 100644 src/daemon.rs create mode 100644 src/terminal/pane_liveness.rs create mode 100644 src/ui/host_ops.rs create mode 100644 src/ui/host_registry.rs create mode 100644 src/ui/remote_connect.rs create mode 100644 src/ui/remote_workspace.rs create mode 100644 src/ui/switcher.rs diff --git a/.github/scripts/assert-static.sh b/.github/scripts/assert-static.sh new file mode 100755 index 00000000..8c4cca95 --- /dev/null +++ b/.github/scripts/assert-static.sh @@ -0,0 +1,55 @@ +#!/bin/bash +# Usage: assert-static.sh +# Fail unless the binary is a fully static ELF — no dynamic loader, no shared +# library dependencies. +# +# This is the mechanical guard behind D10 (docs/2026-07-27-remote-workspace-design.md): +# one `tty7-server` binary is pushed to arbitrary remote machines and must run +# there regardless of what libc, and what *version* of it, that machine has. A +# build that silently picked up a dynamic dependency would still pass a +# compile-only CI job and then fail on the first old box a user connects to — +# far from the change that caused it. Cheap to assert, expensive to discover. +set -euo pipefail + +BIN="$1" + +if [ ! -f "$BIN" ]; then + echo "::error::assert-static.sh: $BIN does not exist" + exit 1 +fi + +echo "--- file ---" +file "$BIN" +echo "--- readelf -d ---" +readelf -d "$BIN" || true + +fail=0 + +# `file` says "statically linked" for a classic static binary and "static-pie +# linked" for a position-independent one. Rust's musl targets have shipped both +# shapes depending on toolchain version, and both are equally self-contained, so +# accept either — but nothing else. +if ! file "$BIN" | grep -Eq 'statically linked|static-pie linked'; then + echo "::error::$BIN is not statically linked (D10 requires a self-contained binary)" + fail=1 +fi + +# The decisive check: a static binary has no PT_INTERP segment, i.e. no +# request for /lib/ld-musl-*.so or ld-linux-*.so. This catches the case `file` +# alone would not, where a dynamic loader is still required. +if readelf -l "$BIN" | grep -q 'Requesting program interpreter'; then + echo "::error::$BIN requires a dynamic loader (PT_INTERP present) — not a static build" + fail=1 +fi + +# Belt and braces: no DT_NEEDED entries, i.e. no shared libraries to resolve. +if readelf -d "$BIN" 2>/dev/null | grep -q 'NEEDED'; then + echo "::error::$BIN has shared-library dependencies (DT_NEEDED) — not a static build" + fail=1 +fi + +if [ "$fail" -ne 0 ]; then + exit 1 +fi + +echo "✅ $BIN is a static binary ($(du -h "$BIN" | cut -f1))" diff --git a/.github/scripts/bundle-linux.sh b/.github/scripts/bundle-linux.sh index 581bab37..c053849a 100755 --- a/.github/scripts/bundle-linux.sh +++ b/.github/scripts/bundle-linux.sh @@ -13,7 +13,14 @@ set -euo pipefail TARGET="$1" ARCH="$2" -VERSION="$(grep -m1 '^version' Cargo.toml | sed -E 's/.*"([^"]+)".*/\1/')" +# Anchored on `= "` — see the note in bundle-macos.sh: the root manifest leads +# with `version.workspace = true`, which a bare `^version` match would return +# verbatim as the "version" and bake into every asset filename. +VERSION="$(grep -m1 '^version = "' Cargo.toml | sed -E 's/.*"([^"]+)".*/\1/')" +if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+ ]]; then + echo "bundle-linux: could not read a version from Cargo.toml (got '$VERSION')" >&2 + exit 1 +fi NAME="tty7-${VERSION}-linux-${ARCH}" STAGE="dist/${NAME}" diff --git a/.github/scripts/bundle-macos.sh b/.github/scripts/bundle-macos.sh index 9bfe9bdb..c82a5b96 100755 --- a/.github/scripts/bundle-macos.sh +++ b/.github/scripts/bundle-macos.sh @@ -12,7 +12,15 @@ set -euo pipefail TARGET="$1" ARCH="$2" -VERSION="$(grep -m1 '^version' Cargo.toml | sed -E 's/.*"([^"]+)".*/\1/')" +# Anchored on `= "` because the root manifest's `[package]` section leads with +# `version.workspace = true` — a bare `^version` match grabs that line, finds no +# quotes to substitute, and passes it through as the "version", which then lands +# in CFBundleVersion and the .dmg filename. Guard against a silent recurrence. +VERSION="$(grep -m1 '^version = "' Cargo.toml | sed -E 's/.*"([^"]+)".*/\1/')" +if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+ ]]; then + echo "bundle-macos: could not read a version from Cargo.toml (got '$VERSION')" >&2 + exit 1 +fi APP="dist/tty7.app" rm -rf dist diff --git a/.github/scripts/bundle-windows.ps1 b/.github/scripts/bundle-windows.ps1 index 91ad133a..f57cbb16 100644 --- a/.github/scripts/bundle-windows.ps1 +++ b/.github/scripts/bundle-windows.ps1 @@ -26,6 +26,23 @@ Copy-Item "assets/completions/*.json" "$Stage/completions/" Copy-Item LICENSE "$Stage/LICENSE.txt" Copy-Item README.md "$Stage/README.md" +# The Linux musl `tty7-server`, staged at server/ so a WSL distro can be handed +# the binary this client shipped with (design §12: WSL downloads nothing). The +# lookup path is a contract with `daemon::install::wsl` — it searches +# /server/ first — so this directory name is not free to +# change on its own. Missing is a warning, not an error, matching `server-musl`'s +# own skip-don't-fail probe; WSL then fails at connect time with a message +# naming the directories it searched. +$ServerAsset = "tty7-server-x86_64-unknown-linux-musl" +$ServerSrc = "bundled-server/$ServerAsset" +if (Test-Path $ServerSrc) { + New-Item -ItemType Directory -Force -Path "$Stage/server" | Out-Null + Copy-Item $ServerSrc "$Stage/server/$ServerAsset" + Write-Host "OK bundled $ServerAsset" +} else { + Write-Warning "no $ServerAsset to bundle - this build cannot serve WSL distros" +} + Compress-Archive -Path "$Stage/*" -DestinationPath "dist/$Name.zip" -Force # Installer, built from the same staged payload. ISCC is on PATH on GitHub's diff --git a/.github/scripts/check-host-boundary.sh b/.github/scripts/check-host-boundary.sh new file mode 100755 index 00000000..51eb7149 --- /dev/null +++ b/.github/scripts/check-host-boundary.sh @@ -0,0 +1,167 @@ +#!/usr/bin/env bash +# +# Contract §10.6 — the GUI must not touch the filesystem or git directly. +# +# Once a workspace can live on a remote machine, a path held by `ui::` or +# `terminal::` is not necessarily a path on *this* box, and `std::path`'s +# fs-backed APIs quietly answer for the wrong machine (contract §4.3): +# `canonicalize` walks the local filesystem, `is_absolute` says `false` for +# `/home/me` on Windows, `read_dir` lists the client's disk. Everything that may +# be looking at a workspace path has to go through `ui::host_ops` / the `Host` +# trait, which routes to the local disk or the far side as appropriate. +# +# This script enforces that. It is deliberately *not* the raw grep from the +# contract: run bare, that grep reports 42 hits on a clean tree — not one of them +# git, all of them modules whose paths are local by construction — and a guard +# that always fails is a guard everyone learns to ignore. Two things fix it: +# +# 1. Test bodies are cut off properly. The contract's `grep -v '#\[cfg(test)\]'` +# only removes the attribute line itself, leaving the whole test module +# behind it in scope; here the scan stops at the `#[cfg(test)] mod …` that +# opens the trailing test region. +# 2. An explicit allowlist, keyed on (file, pattern) rather than whole files, so +# a module exempted for its `.is_absolute()` still trips on a new +# `std::fs::`. Every entry carries the reason its paths cannot be remote. +# +# Adding an entry is a deliberate act: if the path could ever be a workspace +# path, the answer is `Host`, not an allowlist line. +# +# Usage: bash .github/scripts/check-host-boundary.sh +# Exit 0 = clean, 1 = violations (printed to stderr). + +set -euo pipefail + +cd "$(dirname "$0")/../.." + +ROOTS=(src/ui src/terminal) + +# The forbidden constructs, as extended-regex alternatives. +PATTERN='std::fs::|Command::new\("git"\)|\.canonicalize\(\)|\.is_absolute\(\)' + +# Allowlist entries are `|`, one per line. `|*` +# exempts a file wholesale (used only for the boundary module itself). The +# pattern side is matched as a plain substring of the offending line. +ALLOW=$( + cat <<'EOF' +# The host boundary itself: every routed filesystem call lands here by design. +src/ui/host_ops.rs|* + +# Themes and presets are app-owned files under the local config dir +# (`themes_dir()`), never a workspace path — a remote workspace does not carry +# the user's color schemes with it. +src/ui/presets.rs|std::fs:: +src/ui/presets.rs|.is_absolute() +src/ui/app.rs|std::fs::create_dir_all + +# Reading a private key off *this* machine to hash it into a keychain account +# (`core::keychain`). The key is the client's credential; the far side never +# sees the file, only the resulting auth. +src/ui/ssh_prompt.rs|std::fs::read +src/ui/ssh_connect.rs|std::fs::read + +# Shell history lives in the local user's home (`~/.zsh_history` &co.) and backs +# this app's own history search. A remote pane's history is the remote shell's +# business, read over the wire, not through here. +src/terminal/history.rs|std::fs:: + +# Completion is already remote-aware: a remote pane is signalled by `cwd: None`, +# which disables exactly these local-filesystem candidate sources in favour of +# `remote_path_request` / `remote_path_candidates`. The `$PATH` scan is likewise +# this machine's `$PATH`, deliberately withheld from remote panes. +src/terminal/completion.rs|std::fs::read_dir +src/terminal/completion.rs|.is_absolute() + +# Bundled completion specs shipped in `assets/completions`, resolved from the +# app bundle / dev manifest dir. Program data, not user or workspace data. +src/terminal/signature.rs|std::fs::read_to_string + +# Opening a path scraped out of terminal output resolves it against the local +# cwd — a local-pane affordance; `resolve_existing_path` declines when there is +# no local cwd. +src/terminal/search.rs|.is_absolute() + +# Clipboard-image paste stages the bytes into the OS temp dir so an agent TUI +# can be handed a path. Always `std::env::temp_dir()` on this machine. +src/terminal/view.rs|std::fs::create_dir_all +src/terminal/view.rs|std::fs::write +EOF +) + +# Where the trailing `#[cfg(test)] mod …` starts, if any. Line numbers are +# preserved because only the tail is dropped. +body_of() { + local file=$1 cut + cut=$(awk ' + /^#\[cfg\(test\)\]$/ { attr = NR } + /^mod [A-Za-z_]/ { if (attr == NR - 1) { print NR - 1; exit } } + ' "$file") + if [ -n "$cut" ]; then + head -n "$((cut - 1))" "$file" + else + cat "$file" + fi +} + +allowed() { + local file=$1 line=$2 entry path pat + while IFS= read -r entry; do + case "$entry" in '' | '#'*) continue ;; esac + path=${entry%%|*} + pat=${entry#*|} + [ "$path" = "$file" ] || continue + if [ "$pat" = '*' ] || [ "${line#*"$pat"}" != "$line" ]; then + return 0 + fi + done <<<"$ALLOW" + return 1 +} + +# A guard that scans nothing reports success, which is the one failure mode worse +# than a noisy guard. Fail loudly if a root has moved out from under us. +for root in "${ROOTS[@]}"; do + if [ ! -d "$root" ]; then + echo "check-host-boundary: scan root '$root' does not exist (moved? renamed?)" >&2 + exit 2 + fi +done + +violations=0 +scanned=0 +while IFS= read -r file; do + scanned=$((scanned + 1)) + while IFS= read -r hit; do + lineno=${hit%%:*} + text=${hit#*:} + # Prose, not code. + case "$(printf '%s' "$text" | sed 's/^[[:space:]]*//')" in '//'*) continue ;; esac + if allowed "$file" "$text"; then + continue + fi + printf '%s:%s:%s\n' "$file" "$lineno" "$text" >&2 + violations=$((violations + 1)) + done < <(body_of "$file" | grep -nE "$PATTERN" || true) +done < <(find "${ROOTS[@]}" -name '*.rs' | sort) + +if [ "$scanned" -lt 10 ]; then + echo "check-host-boundary: only $scanned files scanned — the roots look wrong" >&2 + exit 2 +fi + +if [ "$violations" -ne 0 ]; then + cat >&2 <<'EOF' + +-------------------------------------------------------------------------------- +Contract §10.6: the GUI reached the filesystem/git directly. + +A path in `ui::` or `terminal::` may belong to a remote workspace, where these +calls answer for the wrong machine. Route it through `ui::host_ops` / `Host` +instead (`Host::read_dir`, `join`, `is_absolute`, `canonicalize`, …). + +If the path genuinely cannot be remote — app config, bundled assets, the local +temp dir — add it to the allowlist in this script with the reason why. +-------------------------------------------------------------------------------- +EOF + exit 1 +fi + +echo "host boundary clean: $scanned files in ${ROOTS[*]}, no direct fs/git calls" diff --git a/.github/scripts/stamp-version.sh b/.github/scripts/stamp-version.sh new file mode 100755 index 00000000..198e08bf --- /dev/null +++ b/.github/scripts/stamp-version.sh @@ -0,0 +1,34 @@ +#!/bin/bash +# Usage: stamp-version.sh +# Rewrite the package version in Cargo.toml. Used by the nightly workflow, where +# every versioned artifact — the binary's CARGO_PKG_VERSION, asset names, the DMG +# plist, the Inno installer — reads Cargo.toml, so this one edit covers them all. +# Cargo.lock is left alone: cargo refreshes the root package's own lock entry +# automatically, without network access. +# +# Extracted from the inline step so nightly's several build jobs stamp +# identically, and so the guard below has exactly one home. +set -euo pipefail + +VERSION="$1" + +awk -v ver="$VERSION" '!done && /^version = /{ $0 = "version = \"" ver "\""; done = 1 } { print }' \ + Cargo.toml > Cargo.toml.tmp +mv Cargo.toml.tmp Cargo.toml + +# Fail loudly if the substitution missed. Without this the awk is a silent no-op +# whenever Cargo.toml's shape changes — e.g. if the root manifest ever becomes a +# virtual workspace whose members carry `version.workspace = true`, in which case +# the version to stamp moves to `[workspace.package]`. A nightly that quietly +# publishes assets named after the *last stable* version is far worse than one +# that fails here. +if ! grep -qx "version = \"$VERSION\"" Cargo.toml; then + echo "::error::stamp-version.sh did not stamp $VERSION into Cargo.toml — the manifest's version line is not where this script expects it" + exit 1 +fi + +# Report the line actually stamped, not the first thing that looks like a +# version: post-crate-split the root package carries `version.workspace = true` +# and the real value lives further down under [workspace.package], so a naive +# `grep -m1 '^version'` prints the inherit marker and tells you nothing. +grep -n "^version = \"$VERSION\"" Cargo.toml diff --git a/.github/scripts/windows-installer.iss b/.github/scripts/windows-installer.iss index 1303603c..fa3d89d8 100644 --- a/.github/scripts/windows-installer.iss +++ b/.github/scripts/windows-installer.iss @@ -57,6 +57,10 @@ Source: "{#StageDir}\tty7.exe"; DestDir: "{app}"; Flags: ignoreversion Source: "{#StageDir}\completions\*"; DestDir: "{app}\completions"; Flags: ignoreversion recursesubdirs Source: "{#StageDir}\LICENSE.txt"; DestDir: "{app}"; Flags: ignoreversion Source: "{#StageDir}\README.md"; DestDir: "{app}"; Flags: ignoreversion +; The Linux musl tty7-server, for serving WSL distros without a download. +; `skipifsourcedoesntexist` because a build whose server-musl leg was skipped +; still has to produce an installer. See bundle-windows.ps1. +Source: "{#StageDir}\server\*"; DestDir: "{app}\server"; Flags: ignoreversion recursesubdirs skipifsourcedoesntexist [Icons] Name: "{autoprograms}\tty7"; Filename: "{app}\tty7.exe" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 282b59c0..86a803f9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,6 +22,24 @@ jobs: components: rustfmt - run: cargo fmt --check + # Contract §10.6: `ui::` and `terminal::` must not reach the filesystem or git + # directly, because once a workspace can be remote those calls answer for the + # wrong machine (§4.3). The allowlist of genuinely-local paths lives in the + # script, next to the reason each one is exempt. + # + # A standalone job on purpose, and one that must stay *non-required*: main's + # required checks are `rustfmt` and the three `build & test ()` names, + # and folding this into either would make it required the moment it lands — + # wedging every open PR on a check they have never seen. Same reasoning as + # `server-musl` below. Cheap enough (a checkout and a grep) that it does not + # need caching or a toolchain. + host-boundary: + name: host boundary (§10.6) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - run: bash .github/scripts/check-host-boundary.sh + build: name: build & test (${{ matrix.target }}) strategy: @@ -74,3 +92,94 @@ jobs: - name: Test run: cargo test --locked --target ${{ matrix.target }} + + # Static musl builds of the headless server binary that remote workspaces push + # onto the far machine (docs/2026-07-27-remote-workspace-design.md, D10/§12). + # One binary has to run on any distro without regard to the target's glibc + # version, so it is linked fully static against musl rather than built per + # distro. Compile-only — this job publishes nothing; release.yml and + # nightly.yml carry the same job with an upload step. + # + # Deliberately a *separate* job rather than two more rows in the `build` matrix + # above: those three `build & test ()` names are main's required + # checks, and reshaping that matrix would wedge branch protection on every open + # PR. Keep this job non-required until it has a few weeks of green — see + # docs/remote-server-assets.md. + server-musl: + name: tty7-server musl (${{ matrix.target }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + target: + - x86_64-unknown-linux-musl + - aarch64-unknown-linux-musl + # `-C strip=symbols` is applied by rustc through the linker, so it strips the + # aarch64 output from an x86_64 runner — plain `strip(1)` would not. Set here + # rather than in [profile.release] because the release profile is shared with + # the GUI builds, which keep their symbols. + env: + RUSTFLAGS: -C strip=symbols + steps: + - uses: actions/checkout@v4 + + - uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.target }} + + # zig supplies both the musl sysroot and the C cross-compiler, which is + # what makes one x86_64 runner able to emit both musl targets. russh's + # default crypto backend (aws-lc-rs) builds a sizable C/asm library through + # cmake, and that is the part every other approach trips over: `cross` + # needs a custom image to get cmake into the sandbox, and Ubuntu's + # `musl-tools` only ships an x86_64 `musl-gcc` wrapper with no C++ driver + # and nothing at all for aarch64. Verified locally: both targets link + # statically and the resulting binaries run under Alpine. + # + # If setup-zig ever becomes a problem (its GitHub repo is a mirror of a + # Codeberg original), cargo-zigbuild also accepts zig from PyPI — + # `pip3 install ziglang`, which it finds via CARGO_ZIGBUILD_PYTHON_PATH — + # so this can drop to zero third-party actions without changing anything + # else. + - uses: mlugg/setup-zig@v2 + with: + version: 0.16.0 + + - uses: taiki-e/install-action@v2 + with: + tool: cargo-zigbuild + + - uses: Swatinem/rust-cache@v2 + with: + key: ${{ matrix.target }} + + # The crate split (§11) lands separately; until `tty7-server` exists as a + # workspace member this job has nothing to build. Skip cleanly rather than + # fail, so the workflow can land before the split and simply start working + # once it arrives. `--no-deps` keeps this to a manifest parse — no + # resolution, no network. + - name: Look for the tty7-server package + id: probe + run: | + set -euo pipefail + if cargo metadata --no-deps --format-version 1 \ + | jq -e '[.packages[].name] | index("tty7-server")' >/dev/null; then + echo "present=true" >> "$GITHUB_OUTPUT" + else + echo "present=false" >> "$GITHUB_OUTPUT" + echo "::notice::tty7-server is not a workspace member yet (crate split, design §11 / M1) — nothing to build" + fi + + # `-p tty7-server` addresses the package by name, so this survives whatever + # directory layout the split settles on. It also keeps feature unification + # scoped to the server's own dependency graph: the GUI crate is the one + # that turns on tty7-core's `gssapi` feature, and that feature cannot build + # under musl (libgssapi-sys wants a system MIT/Heimdal krb5). Building the + # whole workspace here would drag it in and fail. + - name: Build static tty7-server + if: steps.probe.outputs.present == 'true' + run: cargo zigbuild --release --locked -p tty7-server --target ${{ matrix.target }} + + - name: Assert the binary is static + if: steps.probe.outputs.present == 'true' + run: bash .github/scripts/assert-static.sh "target/${{ matrix.target }}/release/tty7-server" diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 4631de9e..4a115ed5 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -52,7 +52,9 @@ jobs: # Mirrors release.yml's build matrix; keep the two in sync when editing. build: - needs: plan + # Also behind server-musl: the Windows installer embeds its Linux musl + # binary for WSL. See the same note in release.yml. + needs: [plan, server-musl] if: needs.plan.outputs.build == 'true' strategy: fail-fast: false @@ -82,19 +84,13 @@ jobs: path: tty7 # Everything versioned — the binary (CARGO_PKG_VERSION), asset names, - # DMG plist, Inno installer — reads Cargo.toml, so stamping it here is - # the only edit needed. Cargo.lock is left alone: cargo refreshes the - # root package's own lock entry automatically, without network access. + # DMG plist, Inno installer — reads Cargo.toml, so stamping it is the only + # edit needed. Shared with the server-musl job below, and it fails loudly + # if the manifest's shape ever moves the version line out from under it. - name: Stamp nightly version working-directory: tty7 shell: bash - env: - VERSION: ${{ needs.plan.outputs.version }} - run: | - awk -v ver="$VERSION" '!done && /^version = /{ $0 = "version = \"" ver "\""; done = 1 } { print }' \ - Cargo.toml > Cargo.toml.tmp - mv Cargo.toml.tmp Cargo.toml - grep -m1 '^version' Cargo.toml + run: bash .github/scripts/stamp-version.sh "${{ needs.plan.outputs.version }}" - name: Install Linux system dependencies if: matrix.os == 'linux' @@ -141,6 +137,15 @@ jobs: working-directory: tty7 run: bash .github/scripts/bundle-appimage.sh "${{ matrix.target }}" "${{ matrix.arch }}" + # See release.yml for why this is best-effort rather than required. + - name: Fetch the bundled Linux server + if: matrix.os == 'windows' + continue-on-error: true + uses: actions/download-artifact@v7 + with: + name: nightly-server-x86_64-unknown-linux-musl + path: tty7/bundled-server + - name: Package Windows installer + zip if: matrix.os == 'windows' working-directory: tty7 @@ -161,11 +166,100 @@ jobs: tty7/dist/*.AppImage if-no-files-found: error + # Mirrors release.yml's server-musl job; keep the two in sync when editing. + # Nightly carries the server binaries too so the remote-install path (§12) can + # be exercised against the rolling channel instead of waiting for a tag. + server-musl: + needs: plan + if: needs.plan.outputs.build == 'true' + strategy: + fail-fast: false + matrix: + target: + - x86_64-unknown-linux-musl + - aarch64-unknown-linux-musl + runs-on: ubuntu-latest + env: + RUSTFLAGS: -C strip=symbols + steps: + - name: Checkout tty7 + uses: actions/checkout@v4 + with: + path: tty7 + + # Stamped for the same reason the GUI builds are: the server reports + # CARGO_PKG_VERSION over the wire during the version handshake, and a + # nightly server claiming the last stable version would make that + # negotiation lie. The asset *name* is version-free either way. + - name: Stamp nightly version + working-directory: tty7 + run: bash .github/scripts/stamp-version.sh "${{ needs.plan.outputs.version }}" + + - uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.target }} + + - uses: mlugg/setup-zig@v2 + with: + version: 0.16.0 + + - uses: taiki-e/install-action@v2 + with: + tool: cargo-zigbuild + + - uses: Swatinem/rust-cache@v2 + with: + workspaces: tty7 + key: ${{ matrix.target }} + + - name: Look for the tty7-server package + id: probe + working-directory: tty7 + run: | + set -euo pipefail + if cargo metadata --no-deps --format-version 1 \ + | jq -e '[.packages[].name] | index("tty7-server")' >/dev/null; then + echo "present=true" >> "$GITHUB_OUTPUT" + else + echo "present=false" >> "$GITHUB_OUTPUT" + echo "::warning::tty7-server is not a workspace member yet — tonight's nightly carries no remote-server assets" + fi + + # No `--locked` here, matching the rest of nightly: the version stamp above + # rewrites Cargo.toml, and cargo has to be free to refresh the root + # package's own lock entry. + - name: Build static tty7-server + if: steps.probe.outputs.present == 'true' + working-directory: tty7 + run: cargo zigbuild --release -p tty7-server --target ${{ matrix.target }} + + - name: Assert the binary is static + if: steps.probe.outputs.present == 'true' + working-directory: tty7 + run: bash .github/scripts/assert-static.sh "target/${{ matrix.target }}/release/tty7-server" + + - name: Stage the asset + if: steps.probe.outputs.present == 'true' + working-directory: tty7 + run: | + set -euo pipefail + mkdir -p dist + cp "target/${{ matrix.target }}/release/tty7-server" \ + "dist/tty7-server-${{ matrix.target }}" + chmod +x "dist/tty7-server-${{ matrix.target }}" + + - uses: actions/upload-artifact@v7 + if: steps.probe.outputs.present == 'true' + with: + name: nightly-server-${{ matrix.target }} + path: tty7/dist/tty7-server-${{ matrix.target }} + if-no-files-found: error + # Single publish step after all platforms succeed, so the rolling release is # always complete — a failed platform means tonight's nightly is skipped # entirely and users keep yesterday's, never a partial asset set. publish: - needs: [plan, build] + needs: [plan, build, server-musl] runs-on: ubuntu-latest env: GH_TOKEN: ${{ github.token }} @@ -178,6 +272,25 @@ jobs: path: dist merge-multiple: true + # Same contract as release.yml — see docs/remote-server-assets.md. Written + # into dist/ before the upload below so it ships as an asset like any + # other, and so the prune step at the end sees it as current. + - name: Generate checksums.txt + run: | + set -euo pipefail + cd dist + rm -f checksums.txt + # Built in $RUNNER_TEMP and moved in: a redirect straight into dist/ + # creates the file before find walks the directory, so it would hash + # itself as a zero-byte entry. `xargs -r` so an empty dist/ fails + # instead of hanging on stdin. + find . -type f -printf '%P\n' \ + | LC_ALL=C sort | xargs -r sha256sum > "$RUNNER_TEMP/checksums.txt" + [ -s "$RUNNER_TEMP/checksums.txt" ] || { echo "::error::no assets to checksum"; exit 1; } + mv "$RUNNER_TEMP/checksums.txt" checksums.txt + sha256sum -c checksums.txt + cat checksums.txt + - name: Update rolling nightly release run: | # Move the tag first so the release object follows it to this SHA. diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index eff57e33..6f1fe16e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -10,6 +10,14 @@ permissions: jobs: build: + # The Windows installer embeds the Linux musl `tty7-server` so a WSL distro + # can be served the binary the client already shipped with, instead of + # downloading one (design §12: WSL installs nothing over the network). That + # binary comes from `server-musl`, so the two jobs can no longer run in + # parallel. Serialising all four platforms behind it costs a few minutes on + # a release — cheap next to splitting the Windows entry into its own job and + # duplicating the whole toolchain/caching preamble. + needs: server-musl strategy: fail-fast: false matrix: @@ -103,6 +111,19 @@ jobs: working-directory: tty7 run: bash .github/scripts/bundle-appimage.sh "${{ matrix.target }}" "${{ matrix.arch }}" + # The bundled server for WSL. `continue-on-error` mirrors `server-musl`'s + # own probe step: if there is no server asset, the release still ships and + # `bundle-windows.ps1` warns. It is not silent at runtime either — a WSL + # connect then fails with `MissingBundled`, naming every directory it + # searched, rather than quietly falling back to a download. + - name: Fetch the bundled Linux server + if: matrix.os == 'windows' + continue-on-error: true + uses: actions/download-artifact@v7 + with: + name: release-server-x86_64-unknown-linux-musl + path: tty7/bundled-server + - name: Package Windows installer + zip if: matrix.os == 'windows' working-directory: tty7 @@ -128,6 +149,100 @@ jobs: tty7/dist/*.AppImage if-no-files-found: error + # The headless server binary remote workspaces install on the far machine + # (design doc D10/§12). Statically linked against musl so a single binary runs + # on any distro whatever its glibc vintage, and shipped as a bare executable + # rather than an archive so the client can fetch exactly one file and verify it + # against checksums.txt. Asset naming contract: docs/remote-server-assets.md. + # + # Separate from the `build` matrix above because it shares nothing with it: no + # GUI toolchain, no bundling, no code signing, two targets off one runner. + server-musl: + strategy: + fail-fast: false + matrix: + target: + - x86_64-unknown-linux-musl + - aarch64-unknown-linux-musl + runs-on: ubuntu-latest + env: + RUSTFLAGS: -C strip=symbols + steps: + - name: Checkout tty7 + uses: actions/checkout@v4 + with: + path: tty7 + + - uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.target }} + + # zig provides the musl sysroot and the C cross-compiler for both targets + # from one x86_64 runner — see the same job in ci.yml for why the + # alternatives (cross, musl-tools) do not cope with aws-lc-rs' cmake build. + - uses: mlugg/setup-zig@v2 + with: + version: 0.16.0 + + - uses: taiki-e/install-action@v2 + with: + tool: cargo-zigbuild + + - uses: Swatinem/rust-cache@v2 + with: + workspaces: tty7 + key: ${{ matrix.target }} + + # Until the crate split (§11) lands there is no tty7-server to build. Skip + # rather than fail, so this workflow can ship ahead of the split; the + # release simply carries no server assets until it arrives. + - name: Look for the tty7-server package + id: probe + working-directory: tty7 + run: | + set -euo pipefail + if cargo metadata --no-deps --format-version 1 \ + | jq -e '[.packages[].name] | index("tty7-server")' >/dev/null; then + echo "present=true" >> "$GITHUB_OUTPUT" + else + echo "present=false" >> "$GITHUB_OUTPUT" + echo "::warning::tty7-server is not a workspace member yet — this release will carry no remote-server assets" + fi + + # `--locked` for the same reason the GUI build uses it: a release ships the + # dependency set the tag recorded. `-p tty7-server` both addresses the + # package independently of its path and keeps feature unification off the + # GUI's `gssapi` feature, which cannot build under musl. + - name: Build static tty7-server + if: steps.probe.outputs.present == 'true' + working-directory: tty7 + run: cargo zigbuild --release --locked -p tty7-server --target ${{ matrix.target }} + + - name: Assert the binary is static + if: steps.probe.outputs.present == 'true' + working-directory: tty7 + run: bash .github/scripts/assert-static.sh "target/${{ matrix.target }}/release/tty7-server" + + # Flat, version-free asset name — the tag in the download URL carries the + # version. See docs/remote-server-assets.md for the contract the client + # installer derives this name from. + - name: Stage the asset + if: steps.probe.outputs.present == 'true' + working-directory: tty7 + run: | + set -euo pipefail + mkdir -p dist + cp "target/${{ matrix.target }}/release/tty7-server" \ + "dist/tty7-server-${{ matrix.target }}" + chmod +x "dist/tty7-server-${{ matrix.target }}" + + - uses: actions/upload-artifact@v7 + if: steps.probe.outputs.present == 'true' + with: + name: release-server-${{ matrix.target }} + path: tty7/dist/tty7-server-${{ matrix.target }} + if-no-files-found: error + # Single assembly step, after all four platforms succeed. The release object is # created as a **draft** and left that way: a draft is invisible to both # /releases/latest and the releases page, so nothing can prompt a user to @@ -135,7 +250,7 @@ jobs: # empty. Publishing is the release skill's job — it verifies the six assets and # writes the body first, then flips the draft. See .claude/skills/release/SKILL.md. draft-release: - needs: build + needs: [build, server-musl] if: startsWith(github.ref, 'refs/tags/') runs-on: ubuntu-latest env: @@ -146,6 +261,35 @@ jobs: path: dist merge-multiple: true + # sha256 over every asset, so the remote-server installer can verify what + # it downloaded before writing it to someone else's machine (design §16 — + # a mismatch aborts the install outright). Generated here rather than in + # the build jobs because only this job sees the complete asset set, and a + # per-job fragment would have to be concatenated in a deterministic order + # anyway. GNU coreutils format (" "), bare filenames, sorted — + # see docs/remote-server-assets.md for the format the client parses. + - name: Generate checksums.txt + run: | + set -euo pipefail + cd dist + rm -f checksums.txt + # `find -type f` rather than a glob: nested files (should any appear) + # would otherwise be silently skipped, leaving an asset unverifiable. + # + # Built in $RUNNER_TEMP and moved in, rather than redirected straight + # into dist/: the `>` redirect creates its target *before* find walks + # the directory, so a file written in place would end up hashing + # itself as a zero-byte entry — a line that can never verify. + # + # `xargs -r` — without it an empty dist/ would leave sha256sum reading + # stdin and the job would hang rather than fail. + find . -type f -printf '%P\n' \ + | LC_ALL=C sort | xargs -r sha256sum > "$RUNNER_TEMP/checksums.txt" + [ -s "$RUNNER_TEMP/checksums.txt" ] || { echo "::error::no assets to checksum"; exit 1; } + mv "$RUNNER_TEMP/checksums.txt" checksums.txt + sha256sum -c checksums.txt + cat checksums.txt + # Reuse an existing release rather than failing: re-triggering a tag # (force-push after a fixed platform) must top up the same draft. If the # release was already published, --clobber just replaces its assets and it diff --git a/.gitignore b/.gitignore index 2e73c3e6..eafad03c 100644 --- a/.gitignore +++ b/.gitignore @@ -27,3 +27,6 @@ repomix-output.* # benchmark harness work dir + generated fixtures (scripts/bench/) .bench/ /big.log + +# Lavish review artifacts (design surfaces rendered for a single review pass) +/.lavish/ diff --git a/Cargo.lock b/Cargo.lock index f63f5adf..3b615901 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2022,7 +2022,7 @@ version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a" dependencies = [ - "libloading 0.7.4", + "libloading 0.8.9", ] [[package]] @@ -9547,43 +9547,71 @@ dependencies = [ "alacritty_terminal", "anyhow", "core-foundation 0.10.0", - "getrandom 0.3.4", "gpui", "gpui-component", "gpui-component-assets", "gpui_platform", - "ignore", "image", "jieba-rs", "keyring", "ksni", "libc", - "libgssapi", "log", - "memchr", "notify 8.2.0", "notify-rust", "objc2 0.6.4", "objc2-app-kit 0.3.2", "objc2-foundation 0.3.2", "plist", - "portable-pty", "regex", "reqwest_client", "resvg", + "serde", + "serde_json", + "serde_yaml", + "smallvec", + "smol", + "tray-icon", + "tty7-core", + "uuid", + "winresource", +] + +[[package]] +name = "tty7-core" +version = "26.7.5" +dependencies = [ + "anyhow", + "base64", + "core-foundation 0.10.0", + "getrandom 0.3.4", + "ignore", + "libc", + "libgssapi", + "log", + "memchr", + "notify 8.2.0", + "portable-pty", "russh", "russh-sftp", "serde", "serde_json", - "serde_yaml", "sha2 0.10.9", - "smallvec", "smol", + "tempfile", "tokio", - "tray-icon", + "ureq", "uuid", "windows-sys 0.59.0", - "winresource", +] + +[[package]] +name = "tty7-server" +version = "26.7.5" +dependencies = [ + "serde_json", + "tempfile", + "tty7-core", ] [[package]] @@ -9721,6 +9749,34 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" +[[package]] +name = "ureq" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dea7109cdcd5864d4eeb1b58a1648dc9bf520360d7af16ec26d0a9354bafcfc0" +dependencies = [ + "base64", + "log", + "percent-encoding", + "rustls", + "rustls-pki-types", + "ureq-proto", + "utf8-zero", + "webpki-roots", +] + +[[package]] +name = "ureq-proto" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e994ba84b0bd1b1b0cf92878b7ef898a5c1760108fe7b6010327e274917a808c" +dependencies = [ + "base64", + "http", + "httparse", + "log", +] + [[package]] name = "url" version = "2.5.8" @@ -9766,6 +9822,12 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" +[[package]] +name = "utf8-zero" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" + [[package]] name = "utf8_iter" version = "1.0.4" @@ -10198,6 +10260,15 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "weezl" version = "0.1.12" diff --git a/Cargo.toml b/Cargo.toml index 9d076654..f1378352 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "tty7" -version = "26.7.5" +version.workspace = true edition = "2024" description = "A terminal workbench: shells, persistent sessions, SSH, coding agents — GPU-rendered on Zed's gpui, pure Rust" repository = "https://github.com/l0ng-ai/tty7" @@ -14,6 +14,21 @@ name = "tty7" path = "src/main.rs" [dependencies] +# The framework-free half of tty7: wire protocol, session daemon, PTY, the +# native SSH engine, and the domain model the headless `tty7-server` shares with +# this GUI. Everything that does *not* need gpui lives there — see +# `docs/2026-07-27-remote-workspace-design.md` §11. +# `gssapi` is off in tty7-core's defaults (a static musl `tty7-server` cannot +# link the system krb5 it binds); the GUI, which builds against a real desktop +# toolchain, turns it on so managed SSH connections keep offering +# `gssapi-with-mic` exactly as before. +# +# `remote-install` is off there for the same shape of reason: it pulls an HTTPS +# client used only to *download* a `tty7-server` onto a remote machine +# (design §12, D5). The server binary is the thing being downloaded, so it can +# never take that path; the GUI, which is the client that pushes it, can. +tty7-core = { path = "crates/tty7-core", features = ["gssapi", "remote-install"] } + gpui = { workspace = true } gpui_platform = { workspace = true } gpui-component = { workspace = true } @@ -29,16 +44,23 @@ smallvec.workspace = true serde = { workspace = true } serde_json.workspace = true -# SSH connection-manager data layer (`core::ssh_profile` / `core::keychain`). -# `uuid` mints stable profile ids (v4) and serde-serializes them as strings; -# `sha2` hashes private-key file contents to the sha512-hex account key that -# passphrases are stored under in the OS keychain (per PRD §7.2). Both are already -# in the tree transitively, so pinning them here adds no new native code. `keyring` -# 4.x is the OS credential-vault backend: its default `v1` feature auto-selects the -# platform store (macOS Keychain / Windows Credential Manager / Linux Secret -# Service), so no per-platform feature guards are needed. +# SSH profile ids in the connection manager UI (`ui::ssh_connect`, +# `ui::settings`, the command palette). The profiles themselves — and the +# secret-free `CredentialRef` they carry — live in `tty7-core`, which `Config` +# needs to parse `config.json` without gpui. uuid = { version = "1", features = ["v4", "serde"] } -sha2 = "0.10" + +# The credential vault's storage half (`core::keychain`). `keyring` 4.x's default +# `v1` feature auto-selects the platform store (macOS Keychain / Windows +# Credential Manager / Linux Secret Service), so no per-platform guards are +# needed. +# +# It sits here rather than in `tty7-core` on purpose: every caller is in `ui::` +# (`ssh_prompt`, `ssh_connect`, `settings`, `app`), and leaving it downstairs made +# the headless `tty7-server` — a static binary pushed onto machines that have no +# keychain at all — link `zbus`/`secret-service` and thirty-odd crates behind them +# for code it can never reach. Only the secret-free naming half (`CredentialRef`, +# the account scheme) stays in core, where `config.json` parsing needs it. keyring = "4" # Theme files. tty7 themes are authored as YAML (`~/.config/tty7/themes/*.yaml`, @@ -99,57 +121,6 @@ notify-rust = "4" # editor, `ui::code`) reuses it to refresh the tree and detect external edits. notify = "8" -# Code panel (file tree + editor, `ui::code`): `ignore` supplies the gitignore -# matcher chain the file tree uses to dim ignored entries (same crate ripgrep -# uses). The editor itself needs nothing else — text storage and syntax -# highlighting both live inside gpui-component's `InputState`. -ignore = "0.4" - -# Cross-platform PTY for the daemon: a Unix pty on Unix, ConPTY on Windows, behind -# one blocking `Read`/`Write`/`resize` API. This is what lets `daemon::pane` share -# a single code path across platforms instead of hand-rolling fd/ioctl/signal code. -portable-pty = "0.8" - -# Native (pure-Rust) SSH client for the daemon's russh session engine -# (`daemon::ssh`). Replaces shelling out to the system `ssh` binary for managed -# connections: it gives us the protocol stack that GUI-hosted auth, host-key -# prompts, in-memory connection reuse, and (later) SFTP / native port-forwarding -# all need. 0.62 folded the old `russh-keys` crate in as `russh::keys` (key -# parsing + ssh-agent client). The async russh session lives on a small tokio -# runtime owned by `daemon::ssh`; the rest of the daemon stays std-threads and -# bridges to it through blocking `Read`/`Write` adapters. -russh = "0.62" - -# SFTP client for the native SSH engine (`daemon::ssh::sftp`, Workstream 5). -# Not part of russh proper: `russh-sftp` drives the SFTP subsystem over any -# AsyncRead+AsyncWrite stream, which a russh session channel provides via -# `Channel::into_stream()`. Version-independent of russh (it only needs the -# channel byte stream), so it rides the same tokio runtime `daemon::ssh` owns. -russh-sftp = "2" - -# tokio powers only the russh session engine — a single runtime `daemon::ssh` -# owns. The daemon's PTY/reader/writer threads remain std threads and never touch -# it; they cross into async through bounded/unbounded channels (the blocking -# `Read`/`Write` adapters in `daemon::ssh::session`). -tokio = { version = "1", features = [ - "rt-multi-thread", - "net", - "io-util", - "sync", - "time", - "macros", - "process", - # `fs` powers the local side of SFTP transfers (`daemon::ssh::sftp`): async - # file/dir IO on the daemon process's own filesystem during upload/download. - "fs", -] } - -# SIMD byte search for the OSC tokenizer's Ground/Ignore fast paths — the -# sniffers sit on the full-throughput output stream (100+ MB/s at full drain), -# where a per-byte state machine costs a measurable slice of the reader loop. -# Already in the tree transitively (vte et al.), so this pins no new code. -memchr = "2" - # System tray / menu bar status item (`ui::tray`). Rasterizes the bundled SVG # logo into the tray bitmap at runtime — gpui's own SVG path only yields a # tinted alpha mask, not raw RGBA. Pinned to the exact version already in the @@ -165,30 +136,11 @@ resvg = { version = "0.45", default-features = false } [target.'cfg(any(target_os = "macos", target_os = "windows"))'.dependencies] tray-icon = "0.24" -# `setsid` (daemon detach) and the macOS foreground-process proc queries are the -# only libc users left, both Unix-only — so the dep is Unix-only too. +# The pty-size ioctl in `terminal::generator` is the only libc user left in the +# GUI (the daemon's own libc calls went to tty7-core), and it is Unix-only — so +# the dep is Unix-only too. [target.'cfg(unix)'.dependencies] libc = "0.2" -libgssapi = "0.11" - -# The Windows GUI⇄daemon transport is loopback TCP, which (unlike a Unix socket) -# any local process can connect to — so the daemon authenticates each connection -# against a random token it writes into the user-private port file. `getrandom` -# is the OS CSPRNG that mints that token; already in the tree transitively, so -# this pins no new code. Windows-only, matching the transport it guards. -[target.'cfg(windows)'.dependencies] -getrandom = "0.3" - -# Toolhelp process enumeration + `TerminateProcess`, used by `daemon::winproc` to -# title a pane by its foreground command and to tear down a shell's descendant -# tree on hangup (ConPTY's `kill` only reaches the shell itself). Already in the -# tree via gpui's Windows backend, so this pins no new code. Windows-only. -windows-sys = { version = "0.59", features = [ - "Win32_Foundation", - "Win32_System_Console", - "Win32_System_Diagnostics_ToolHelp", - "Win32_System_Threading", -] } # Embeds `assets/favicon.ico` into the `.exe` so Windows shows the tty7 logo in # the taskbar / window / Explorer (macOS gets its icon from the `.app` bundle via @@ -237,9 +189,6 @@ ksni = { version = "0.3.6", default-features = false, features = ["blocking", "a # into test builds and never reaches a release binary. [dev-dependencies] gpui = { workspace = true, features = ["test-support"] } -# `test-util` unlocks tokio's paused clock (`start_paused`) so the ssh prompt -# broker's timeout/retry tests run instantly instead of in real time. Dev-only. -tokio = { version = "1", features = ["test-util", "macros", "rt"] } [lints] workspace = true @@ -247,10 +196,21 @@ workspace = true # ---- Standalone workspace mirroring gpui-component's pins so the git/source # ---- caches are shared and versions stay aligned. ---- [workspace] -members = ["."] +members = ["crates/*"] +# The root `tty7` package is a member implicitly; naming all three here is what +# makes a bare `cargo build` / `cargo test` at the root cover the whole +# workspace, which is how CI invokes them. +default-members = [".", "crates/tty7-core", "crates/tty7-server"] [workspace.package] edition = "2024" +# The single version for all three crates — `tty7`, `tty7-core`, `tty7-server` +# all inherit it with `version.workspace = true`. They ship together and a +# client talking to a server of a different build has to be able to say so, so +# they must never drift apart. This is the line a release bump edits (and the +# one nightly's `awk '/^version = /'` stamps: it is the first such line in the +# file, since the package entries above are all `version.workspace = true`). +version = "26.7.5" [workspace.dependencies] # Our fork's `tty7` branch carries the local customizations tty7 relies on: diff --git a/crates/tty7-core/Cargo.toml b/crates/tty7-core/Cargo.toml new file mode 100644 index 00000000..7fa1c4ea --- /dev/null +++ b/crates/tty7-core/Cargo.toml @@ -0,0 +1,181 @@ +[package] +name = "tty7-core" +version.workspace = true +edition.workspace = true +description = "tty7's framework-free core: wire protocol, session daemon, PTY, SSH engine, and the domain model both the GUI and the headless server build on" +repository = "https://github.com/l0ng-ai/tty7" +license = "Apache-2.0" +publish = false + +# The whole point of this crate is that it does *not* depend on gpui. Everything +# here has to compile and run on a headless Linux box (that is what +# `tty7-server` is), so nothing windowing-, rendering- or GUI-shaped belongs in +# these dependencies — see `docs/2026-07-27-remote-workspace-design.md` §11. +[dependencies] +anyhow.workspace = true +log.workspace = true +serde = { workspace = true } +serde_json.workspace = true + +# SSH connection-manager data layer (`core::ssh_profile` / `core::keychain`) and +# workspace identity (`core::session`). `uuid` mints stable ids (v4) and +# serde-serializes them as strings. +# +# These live here rather than in the GUI because `Config` embeds +# `Vec` and `config.json` has to parse identically on the server. +# +# Deliberately *absent*: `keyring`. Nothing in this crate reads or writes a +# secret — the daemon gets them pre-resolved on the wire (`NativeSshSpec`) — and +# a headless `tty7-server` has no OS keychain to read from, so the vault's +# storage half lives in the GUI crate instead (`tty7::core::keychain`). Keeping +# it out here is what stops a static server binary from linking +# `zbus`/`secret-service` and 30-odd crates behind them for code it can never +# call. What stays is the *naming* half: `CredentialRef` and the account scheme, +# which `config.json` parsing needs. +uuid = { version = "1", features = ["v4", "serde"] } + +# Two unrelated hashes, both server-side: `daemon::install::checksums` verifies a +# downloaded `tty7-server` asset against the release's sha256 manifest, and +# `core::keychain::key_account_from_contents` derives the sha512-hex account key +# a private key's passphrase is stored under (PRD §7.2) — the account *name* is +# part of the persisted config contract, so it belongs next to `CredentialRef` +# even though only the GUI computes one today. +sha2 = "0.10" + +# The gitignore matcher chain (`core::gitignore`) the file tree dims entries +# with — the same crate ripgrep uses. Lives here rather than in the GUI because +# the remote server has to answer "is this path ignored?" with the identical +# implementation. +ignore = "0.4" + +# Cross-platform PTY for the daemon: a Unix pty on Unix, ConPTY on Windows, +# behind one blocking `Read`/`Write`/`resize` API. This is what lets +# `daemon::pane` share a single code path across platforms instead of +# hand-rolling fd/ioctl/signal code. +portable-pty = "0.8" + +# Native (pure-Rust) SSH client for the daemon's russh session engine +# (`daemon::ssh`) — see the root manifest's note for why we own this stack +# instead of shelling out to `ssh`. +# HTTPS client for the remote-server installer (`daemon::install::download`). +# The GUI's own update check rides `reqwest_client`, which wraps Zed's reqwest +# fork behind `gpui::http_client` — unavailable here, since this crate must not +# depend on gpui. `ureq` is blocking (matching the installer, which runs on a +# daemon std thread), rustls-based (no OpenSSL to find at build time), and +# reuses the `rustls`/`http` versions already in the tree. `gzip` is off: the +# assets are already-compressed binaries. +# +# Optional, and off by default, so the static musl `tty7-server` never links a +# TLS stack for a code path it cannot take — it *is* the binary being +# downloaded. The GUI package turns the feature on (see the root `Cargo.toml`). +ureq = { version = "3", default-features = false, features = [ + "rustls", +], optional = true } + +russh = "0.62" +russh-sftp = "2" + +# tokio powers only the russh session engine — a single runtime `daemon::ssh` +# owns. The daemon's PTY/reader/writer threads remain std threads and never +# touch it; they cross into async through bounded/unbounded channels. +tokio = { version = "1", features = [ + "rt-multi-thread", + "net", + "io-util", + "sync", + "time", + "macros", + "process", + "fs", +] } + +# Filesystem watching for `host::local` (`Host::watch`): one non-recursive +# watcher per expanded directory, coalesced into 100ms batches. Same crate and +# version the GUI already used for the file tree, so the server watches a remote +# tree exactly the way the client watched a local one. +notify = "8" + +# `smol::channel` carries watch batches out of the coalescing thread. Only the +# channel is used — the executor stays in the GUI — but taking it from `smol` +# rather than `async-channel` directly keeps the `Receiver` type identical to the +# one gpui code already awaits. +smol.workspace = true + +# SIMD byte search for the OSC tokenizer's Ground/Ignore fast paths — the +# sniffers sit on the full-throughput output stream (100+ MB/s at full drain), +# where a per-byte state machine costs a measurable slice of the reader loop. +memchr = "2" + +# Base64 for the byte fields that cross the control dialect's JSON wire +# (`host::Output`'s stdout/stderr). JSON has no byte type — `serde_json` renders +# a `Vec` as an array of decimal numbers, inflating a 1 MB `git diff` to +# roughly 4 MB. Base64 costs 1.33× instead. Already in the tree via russh. +base64 = "0.22" + +[target.'cfg(unix)'.dependencies] +libc = "0.2" +# GSSAPI/Kerberos SSH auth — see the `gssapi` feature below for why it is +# optional rather than an unconditional Unix dependency. +libgssapi = { version = "0.11", optional = true } + +# CFStringTokenizer-adjacent CoreFoundation FFI: `daemon::pane` reads a pane's +# foreground process name through it on macOS. +[target.'cfg(target_os = "macos")'.dependencies] +core-foundation = "0.10" + +# The Windows GUI⇄daemon transport is loopback TCP, which (unlike a Unix socket) +# any local process can connect to — so the daemon authenticates each connection +# against a random token it writes into the user-private port file. `getrandom` +# is the OS CSPRNG that mints that token. +[target.'cfg(windows)'.dependencies] +getrandom = "0.3" + +# Toolhelp process enumeration + `TerminateProcess`, used by `daemon::winproc` to +# title a pane by its foreground command and to tear down a shell's descendant +# tree on hangup (ConPTY's `kill` only reaches the shell itself). +windows-sys = { version = "0.59", features = [ + "Win32_Foundation", + "Win32_System_Console", + "Win32_System_Diagnostics_ToolHelp", + "Win32_System_Threading", +] } + +[dev-dependencies] +# Sandboxes for the `host::conformance` suite: a fresh empty directory per case, +# removed on drop. +tempfile = "3" + +# `test-util` unlocks tokio's paused clock (`start_paused`) so the ssh prompt +# broker's timeout/retry tests run instantly instead of in real time. +tokio = { version = "1", features = ["test-util", "macros", "rt"] } + +# `gssapi` — GSSAPI/Kerberos `gssapi-with-mic` SSH auth (`daemon::ssh::auth`). +# +# Off by default, and the `tty7` GUI turns it on, so the GUI's behavior is +# unchanged. It has to be optional because `libgssapi` binds the *system* MIT / +# Heimdal krb5 through bindgen: a machine without krb5 headers cannot build it +# at all, and a static musl `tty7-server` — the binary remote workspaces push +# onto arbitrary hosts (design §12, D10) — cannot link it under any +# circumstances. Without the feature, `SshAuthMode::Gssapi` reports that the +# method is unavailable in this build and the other auth families are untouched. +# +# **Test coverage.** Everything in `daemon::ssh::auth` that can be tested without +# krb5 — the service-host list — is gated on `unix` alone, so a plain +# `cargo test -p tty7-core` runs it. Only the libgssapi FFI half needs +# `--features gssapi`, and it has no unit tests (it is all foreign calls). Do not +# gate a testable helper on this feature: `cargo test --workspace` unifies it on +# from the GUI package, so a feature-gated test looks green there and silently +# vanishes the moment anyone narrows to `-p tty7-core`. +[features] +default = [] +gssapi = ["dep:libgssapi"] +# Lets this build download a `tty7-server` release asset over HTTPS and push it +# onto a remote machine (design §12). Off by default so `tty7-server` — which is +# the thing being downloaded, and never the thing doing the downloading — links +# no HTTP client. Without it, `daemon::install` still installs from bytes it is +# handed and still launches/probes a remote daemon; only the fetch fails, with a +# message saying so. +remote-install = ["dep:ureq"] + +[lints] +workspace = true diff --git a/src/core/agent_hooks.rs b/crates/tty7-core/src/core/agent_hooks.rs similarity index 100% rename from src/core/agent_hooks.rs rename to crates/tty7-core/src/core/agent_hooks.rs diff --git a/src/core/cli_agent.rs b/crates/tty7-core/src/core/cli_agent.rs similarity index 100% rename from src/core/cli_agent.rs rename to crates/tty7-core/src/core/cli_agent.rs diff --git a/crates/tty7-core/src/core/config.rs b/crates/tty7-core/src/core/config.rs new file mode 100644 index 00000000..8239436e --- /dev/null +++ b/crates/tty7-core/src/core/config.rs @@ -0,0 +1,1731 @@ +//! User configuration loaded from `~/.config/tty7/config.json`. +//! +//! Every field is optional in the file: a missing or malformed config falls back +//! to the built-in defaults (which mirror the values previously hardcoded across +//! the app), so the terminal always starts cleanly. Parse failures are logged via +//! `log::warn!` rather than panicking. + +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::{Arc, OnceLock}; + +use serde::{Deserialize, Serialize}; + +/// The OpenType features configured for terminal text, as an ordered tag → value +/// list (`[("calt", 1), ("liga", 1)]`). +/// +/// This is a deliberate, behavior-identical replica of `gpui::FontFeatures`: the +/// field it backs is a real key in the user's `config.json`, so its wire format +/// is frozen, but `Config` itself has to parse on a headless machine that never +/// links gpui. The GUI crate converts this into the gpui type at the one place +/// it hands features to the text system (`ui::app::gpui_font_features`), and a +/// test there pins the two serializations together. +/// +/// Wire format, matching gpui byte for byte: +/// - a JSON object of four-character alphanumeric tags to `true` / `false` / +/// a non-negative integer; +/// - `true` → 1, `false` → 0, an integer passes through; +/// - a tag that isn't four alphanumeric characters, a negative or fractional +/// value, or a `null` value is logged and skipped rather than failing the +/// whole config parse; +/// - serialization always writes integers, so `{"calt":true}` round-trips as +/// `{"calt":1}`. +#[derive(Default, Clone, Eq, PartialEq, Hash)] +pub struct FontFeatures(pub Arc>); + +impl FontFeatures { + /// The tag → value pairs, in the order they were parsed. + pub fn tag_value_list(&self) -> &[(String, u32)] { + self.0.as_slice() + } + + /// Whether `calt` is enabled, or `None` when the feature isn't present. + pub fn is_calt_enabled(&self) -> Option { + self.0 + .iter() + .find(|(feature, _)| feature == "calt") + .map(|(_, value)| *value == 1) + } +} + +impl std::fmt::Debug for FontFeatures { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let mut debug = f.debug_struct("FontFeatures"); + for (tag, value) in self.tag_value_list() { + debug.field(tag, value); + } + debug.finish() + } +} + +/// A feature value as it appears in `config.json`: `true`/`false` or a number. +#[derive(Debug, Serialize, Deserialize)] +#[serde(untagged)] +enum FeatureValue { + Bool(bool), + Number(serde_json::Number), +} + +/// A tag in the OpenType sense: exactly four ASCII alphanumerics (`calt`, +/// `ss01`, `zero`). +fn is_valid_feature_tag(tag: &str) -> bool { + tag.len() == 4 && tag.chars().all(|c| c.is_ascii_alphanumeric()) +} + +impl<'de> serde::Deserialize<'de> for FontFeatures { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + use serde::de::{MapAccess, Visitor}; + + struct FontFeaturesVisitor; + + impl<'de> Visitor<'de> for FontFeaturesVisitor { + type Value = FontFeatures; + + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { + formatter.write_str("a map of font features") + } + + fn visit_map(self, mut access: M) -> Result + where + M: MapAccess<'de>, + { + let mut feature_list = Vec::new(); + while let Some((key, value)) = + access.next_entry::>()? + { + if !is_valid_feature_tag(&key) { + log::error!("Incorrect font feature tag: {key}"); + continue; + } + let Some(value) = value else { continue }; + match value { + FeatureValue::Bool(enable) => { + feature_list.push((key, u32::from(enable))); + } + FeatureValue::Number(value) => match value.as_u64() { + Some(value) => feature_list.push((key, value as u32)), + None => { + log::error!( + "Incorrect font feature value {value} for feature tag {key}" + ); + continue; + } + }, + } + } + Ok(FontFeatures(Arc::new(feature_list))) + } + } + + deserializer.deserialize_map(FontFeaturesVisitor) + } +} + +impl serde::Serialize for FontFeatures { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + use serde::ser::SerializeMap; + + let mut map = serializer.serialize_map(None)?; + for (tag, value) in self.tag_value_list() { + map.serialize_entry(tag, value)?; + } + map.end() + } +} + +/// Top-level configuration. The GUI installs it as a GPUI global (through +/// `ui`'s `ConfigGlobal` wrapper) so any view can read it; the headless server +/// just holds it. +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(default)] +pub struct Config { + /// Primary monospace font face. + pub font_family: String, + /// Fallback faces tried, in order, for glyphs the primary lacks. + pub font_fallbacks: Vec, + /// Optional distinct face for bold cells. `None` reuses `font_family` with a + /// synthesized bold weight (the current behavior). + pub font_family_bold: Option, + /// Optional distinct face for italic cells. `None` reuses `font_family` with a + /// synthesized italic slant. + pub font_family_italic: Option, + /// Optional OpenType font features for terminal text. When absent, tty7 keeps + /// terminal-safe defaults and disables contextual ligatures; when present, + /// this map is handed to gpui's text system as-is (for example + /// `{ "calt": true }`) — see [`FontFeatures`]. + pub font_features: Option, + /// Base font size in pixels. + pub font_size: f32, + /// Line height as a multiple of the font size (e.g. 1.35 → a 13px font gets + /// ~18px rows). Larger values loosen the vertical rhythm; smaller ones pack + /// rows tighter. Clamped to a sane range when applied. + pub line_height: f32, + /// Startup theme mode: "dark" or "light". + pub theme: String, + /// Selected color theme id. Resolves against the theme registry (built-ins + + /// `~/.config/tty7/themes/*`); unknown ids fall back to the default theme. The + /// native chrome is forced to match the theme's light/dark brightness. + pub theme_preset: String, + /// Follow the OS light/dark appearance. When `true` the active theme is + /// resolved from `theme_preset_light` / `theme_preset_dark` by the current + /// system appearance (switching live when the OS mode flips) and + /// `theme_preset` is ignored; the native chrome follows the OS instead of + /// being pinned to the theme. + pub theme_follow_system: bool, + /// Theme id used while `theme_follow_system` is on and the OS is in light + /// mode. Same registry/fallback rules as `theme_preset`. + pub theme_preset_light: String, + /// Theme id used while `theme_follow_system` is on and the OS is in dark + /// mode. Same registry/fallback rules as `theme_preset`. + pub theme_preset_dark: String, + /// Global window-opacity override, 0.2–1.0. `None` (the default) follows the + /// active theme's own `opacity`; when set it applies to every theme, so a + /// chosen translucency survives theme switches. + pub window_opacity: Option, + /// Global window-blur override. `None` follows the active theme's `blur`. + pub window_blur: Option, + /// Optional keybinding overrides: action name (e.g. "NewTab") → keystroke + /// (e.g. "secondary-t", which is ⌘ on macOS and Ctrl elsewhere). Unknown + /// actions and unparseable keystrokes are ignored (with a warning) so a bad + /// entry never blocks startup. + pub keybindings: HashMap, + /// Keybinding preset layered between the built-in defaults and the user's + /// `keybindings` overrides. `"default"` (the default) adds nothing; `"tmux"` + /// remaps pane/tab actions onto `prefix`-led sequences (e.g. `ctrl-b c`). + /// Parsed leniently — an unknown value resolves back to the default preset. + #[serde(default = "default_preset")] + pub keybinding_preset: String, + /// The prefix chord the `tmux` preset builds its sequences from (tmux's + /// `C-b`). Only meaningful when `keybinding_preset` is `"tmux"`. Validated as + /// a gpui keystroke where it's consumed; a common alternative is `ctrl-a`. + #[serde(default = "default_prefix")] + pub prefix: String, + /// Optional shell override for the terminals tty7 spawns. When unset (the + /// default), the platform's default shell is used: the user's login shell on + /// Unix (via `$SHELL`), and PowerShell on Windows (PowerShell 7 when + /// installed, else Windows PowerShell). Set this to run a specific shell + /// instead — e.g. `cmd` / WSL `bash` on Windows, or `fish` / `bash` on Unix. + pub shell: Option, + + // ── Behavior ──────────────────────────────────────────────────────────── + /// Detect URLs (OSC 8 hyperlinks + bare URLs in the text), underline them on + /// hover, and open them on ⌘/Ctrl-click. On by default. + pub link_url: bool, + /// Optional command template run when ⌘/Ctrl-clicking a detected file-path + /// link, instead of tty7's built-in "open in the default app" behavior. The + /// template is tokenized on whitespace and the placeholders `{path}`, + /// `{line}`, and `{column}` are substituted per argument; an argument that + /// contains a placeholder with no value (e.g. `{line}` on a link that has no + /// line number) is dropped. `None` (the default) keeps the built-in open. + /// Example: `"herdr edit {path} --line {line}"`. + pub link_file_command: Option, + /// When a pane is in a detected SSH session, Command-clicking loopback URLs + /// opens them through a temporary local SSH port-forward. Off by default + /// because it starts background `ssh` processes. + pub ssh_loopback_forward: bool, + /// Blink the block cursor while the terminal is focused. On by default; when + /// off the cursor stays solid. + pub cursor_blink: bool, + /// Scrollback lines kept per pane. Clamped to alacritty's ceiling (100 000) + /// in `sanitize`. Only applies to newly spawned/attached panes. + pub scrollback_limit: usize, + /// Where a newly opened tab lands relative to the active one. + #[serde(default, deserialize_with = "de_lenient")] + pub new_tab_position: NewTabPosition, + /// Where the tab bar is rendered: a vertical list down the left side + /// (`left`, the default) or a horizontal strip in the title bar (`top`). + #[serde(default, deserialize_with = "de_lenient")] + pub tab_bar_position: TabBarPosition, + /// Width (px) of the vertical tab sidebar (only meaningful when + /// `tab_bar_position` is `left`). Set by dragging the sidebar's right edge; + /// the live layout re-clamps it to `[180, window_width/2]`. + #[serde(default = "default_sidebar_width")] + pub sidebar_width: f32, + /// Whether the vertical tab sidebar starts collapsed out of the layout (only + /// meaningful when `tab_bar_position` is `left`). Distinct from + /// `tab_bar_position`: collapsing hides the rail *without* falling back to + /// the horizontal title-bar strip, so the terminal gets the full width and + /// re-expanding restores the same rail. Toggled by `ToggleLeftPanel`. + /// + /// Like `right_panel_visible` and `right_panel_tab` below, this is the value + /// a *newly opened window* starts with, not the live state of any window on + /// screen — that lives on [`Tty7App`](crate::ui::app::Tty7App), so toggling + /// one window's chrome leaves every other window alone. Each toggle writes + /// back here, so a new window inherits the last choice made anywhere. + #[serde(default)] + pub sidebar_collapsed: bool, + /// Whether the right detail panel (session info / changes / files) starts + /// docked open. Toggled by `ToggleRightPanel`. Per-window at runtime — see + /// `sidebar_collapsed`. + #[serde(default)] + pub right_panel_visible: bool, + /// Width (px) of the right detail panel. Re-clamped by the live layout the + /// same way `sidebar_width` is. Unlike the two flags around it this stays + /// shared: a width is a preference, not a view state, and every window + /// tracking the config is what makes a drag in one hold in the next. + #[serde(default = "default_right_panel_width")] + pub right_panel_width: f32, + /// Which tab the right detail panel starts on, so reopening it lands where + /// it was left. Per-window at runtime — see `sidebar_collapsed`. + #[serde(default, deserialize_with = "de_lenient")] + pub right_panel_tab: RightPanelTab, + /// How the vertical tab sidebar arranges its rows (only meaningful when + /// `tab_bar_position` is `left`): grouped under a header per git work tree + /// (`repo`, the default), or one flat list (`none`). + #[serde(default, deserialize_with = "de_lenient")] + pub sidebar_grouping: SidebarGrouping, + /// When to post a desktop notification after a long foreground command + /// finishes. + #[serde(default, deserialize_with = "de_lenient")] + pub notify_on_command_finish: NotifyMode, + /// On startup, ask GitHub whether a newer release has shipped and, if so, + /// surface a "download" prompt in Settings → About. Never downloads or + /// self-updates — it only links to the Releases page. On by default; set to + /// `false` to skip the network call entirely (offline / privacy). + pub check_for_updates: bool, + /// Seconds a foreground command must run before it's eligible for a + /// "command finished" notification (further gated by + /// `notify_on_command_finish`). Defaults to 10; clamped in `sanitize` so a + /// hand-edit can't set a degenerate value. + #[serde(default = "default_notify_threshold_secs")] + pub notify_threshold_secs: u64, + /// Restore the previous session (tab/split layout + each pane's cwd) on + /// launch. On by default; when off, every launch starts with a single fresh + /// terminal instead of the last window's layout. The session is still saved + /// on quit — it's just ignored at startup. + #[serde(default = "default_true")] + pub restore_session: bool, + /// Show the system tray / menu bar status item: the icon flips to an + /// attention state when a coding agent needs input, and its menu lists the + /// agent panes. On by default; the tray's poll loop re-reads this every + /// second, so toggling it (Settings or a `config.json` edit) applies live. + #[serde(default = "default_true")] + pub show_tray_icon: bool, + /// Whether the user has already been told, once, that closing a window puts + /// its workspace away rather than ending it. ⌘W is muscle memory and the + /// result is off-screen, so the first time it happens deserves one line + /// pointing at the title bar's workspace menu — and never again. Set to + /// `true` by that hint; there is no UI to reset it (nor a reason to). + #[serde(default)] + pub workspace_detach_hint_seen: bool, + /// Ask before closing the *last* window (the close that also quits the app). + /// On by default, which is the behavior every build so far has had. + /// + /// The prompt was only ever a teaching device, not a safety net: ⌘Q, the + /// tray's Quit and the palette's Quit all leave without asking, and nothing + /// is lost either way — the panes keep running in the daemon. So once the + /// user has learned that (Settings states it permanently under "How sessions + /// work"), being asked on every quit is pure friction. Off makes the last + /// window close exactly like any other: detach the workspace, quit. + #[serde(default = "default_true")] + pub confirm_window_close: bool, + /// How the terminal bell (BEL / `^G`) is signalled. Defaults to a brief + /// visual flash (the current behavior). + #[serde(default, deserialize_with = "de_lenient")] + pub bell: BellMode, + /// Tab at the prompt opens tty7's own completion menu (commands, paths, + /// per-command signatures). On by default. When off — or whenever the + /// engine has nothing to offer — the prompt line is handed to the shell + /// and Tab goes to the PTY, so the shell's native completion (compsys, + /// fzf-tab, …) answers instead. + #[serde(default = "default_true")] + pub tab_completion: bool, + /// Ctrl+R at the prompt opens tty7's fuzzy history menu. On by default. + /// When off, the prompt line is handed to the shell and Ctrl+R goes to the + /// PTY, so whatever is bound there answers instead — readline/zle's own + /// reverse-i-search, or a widget like fzf's or percol's (#163). + #[serde(default = "default_true")] + pub history_search: bool, + + // ── Appearance ────────────────────────────────────────────────────────── + /// The shape drawn for the terminal cursor. + #[serde(default, deserialize_with = "de_lenient")] + pub cursor_style: CursorStyle, + + // ── Input / Mouse ─────────────────────────────────────────────────────── + /// macOS only: treat the Option (⌥) key as Alt/Meta. On, an Option chord + /// sends the ESC-prefixed sequence Meta bindings expect (Option+B → `ESC b`, + /// readline's backward-word), like Ghostty's `macos-option-as-alt` / + /// iTerm2's "Option as Meta". Off (the default), Option keeps its macOS + /// role of composing special characters (Option+B → `∫`). Ignored on other + /// platforms, where Alt always carries the Meta meaning. + pub macos_option_as_alt: bool, + /// Hide the OS mouse pointer while typing; it reappears on the next mouse + /// move. Off by default. + pub mouse_hide_while_typing: bool, + /// Focus a pane as soon as the mouse moves over it, without a click. Off by + /// default; handy with split panes. + pub focus_follows_mouse: bool, + /// Multiplier applied to mouse-wheel scroll distance. 1.0 = one row per wheel + /// line (the raw amount). Clamped to a sane band in `sanitize`. + pub mouse_scroll_multiplier: f32, + /// Report mouse events (click / drag / wheel) to full-screen apps that ask + /// for them (vim, tmux, htop). On by default. When off, the mouse always + /// stays local — native selection and scrollback — regardless of what the + /// app requested. Holding Shift already forces local behavior for a single + /// gesture even while this is on. + #[serde(default = "default_true")] + pub mouse_reporting: bool, + /// Drop trailing whitespace from each copied line. Off by default. + pub clipboard_trim_trailing_spaces: bool, + /// Copy a mouse selection to the clipboard as soon as the gesture ends, + /// without ⌘C (à la Ghostty/iTerm2's copy-on-select). Off by default — + /// the clipboard is never overwritten by a stray selection unless opted + /// into. + pub copy_on_select: bool, + /// Double-click smart selection: expand the selection to the whole URL, + /// email address, file path, or matching bracket pair under the cursor + /// when the plain word sits inside one. On by default; off restores the + /// bare word-boundary double-click. + #[serde(default = "default_true")] + pub smart_select: bool, + /// Characters (besides whitespace) that end a double-click word + /// selection, in both the terminal grid and the prompt's command editor. + /// The default mirrors alacritty's semantic escape set — note `/ . - _` + /// are *not* separators, so paths select as one word. JSON-only (no GUI + /// widget yet). + #[serde(default = "default_word_separators")] + pub word_separators: String, + /// Window state at launch: normal / maximized / fullscreen. + #[serde(default, deserialize_with = "de_lenient")] + pub startup_mode: StartupMode, + /// Reopen a normal (non-maximized/fullscreen) startup window at the size + /// and position it had when tty7 last quit. On by default; off opens + /// centered at the built-in default size. The remembered geometry itself + /// lives in `window.json` (see [`crate::core::window_state`]), not here. + #[serde(default = "default_true")] + pub remember_window_size: bool, + + // ── Shell environment ─────────────────────────────────────────────────── + /// Where a shell starts when the client doesn't pass an explicit directory + /// (a new tab inheriting the active pane's cwd, or session restore, always + /// win over this). + #[serde(default)] + pub working_directory: WorkingDirectory, + /// Extra environment variables injected into every spawned shell, on top of + /// the inherited environment. Currently JSON-only (no GUI widget yet); a + /// key/value editor is a future addition. + #[serde(default)] + pub env: HashMap, + + // ── SSH connection manager ─────────────────────────────────────────────── + /// Saved SSH connection profiles (the connection-manager data layer). Secrets + /// never live here — a profile only carries a `credential_ref` naming its OS + /// keychain entry (see [`crate::core::keychain`]). This is distinct from the + /// live `ssh_config` alias *discovery* in [`crate::core::ssh_config`]: these + /// are user-owned, editable profiles that can be imported from `~/.ssh/config`. + #[serde(default)] + pub ssh_profiles: Vec, + /// Global default for verifying SSH host keys against `known_hosts` on the + /// native (russh) path. On by default (never weaken security silently). A + /// per-profile `verify_host_keys` override wins over this when set; this is + /// the fallback when a profile leaves it unset and for QuickConnect. Turning + /// it off disables unknown/changed-host-key confirmation entirely — a + /// deliberate, documented escape hatch (PRD FR-S4). + #[serde(default = "default_true")] + pub verify_host_keys: bool, + /// Global default for the "confirm before closing a live SSH session" + /// prompt (PRD FR-E3). Off by default (closing is unsurprising for most + /// panes). A per-profile `warn_on_close: Some(true/false)` override wins over + /// this when set; this is the fallback for profiles that leave it unset and + /// for QuickConnect panes. + #[serde(default)] + pub ssh_warn_on_close: bool, + /// Per-profile usage stats driving the palette's frecency ordering (PRD + /// FR-P3): a saved profile's id → how many times it was connected and when it + /// was last used. Bumped on every connect; read to rank the palette's profile + /// rows. Entries for deleted profiles are harmless (never surfaced). + #[serde(default)] + pub ssh_profile_frecency: HashMap, + + /// Per-command usage for the palette's "Recent" group, keyed by the stable + /// id in `ui::palette::CommandKind::id`. The static command list is ordered + /// by hand, which means the first screenful is whatever the author typed + /// first rather than what this user actually runs; this is what lets the + /// palette lead with the latter. Only commands with a stable id are tracked + /// — a "switch to tab 3" is not a thing to be recently-used. + #[serde(default)] + pub command_frecency: HashMap, + + // ── CLI coding agents ──────────────────────────────────────────────────── + /// User-defined agent-detection rules: a command basename → an agent slug + /// (`{"cc": "claude", "my-codex": "codex"}`), so personal wrappers get + /// branded like the agent they launch. Complements the built-in registry in + /// [`crate::core::cli_agent`]; built-ins win on their own names. The daemon + /// reads this once per process (restart the daemon to apply changes). + #[serde(default)] + pub agent_commands: HashMap, + /// On session restore, when a pane can't re-attach (the daemon lost it — + /// reboot, daemon restart) but it was running a coding agent whose native + /// session id we captured, type that agent's resume command into the fresh + /// shell (`claude --resume `, `codex resume `, …) so the + /// conversation continues where it left off. cmux-style; on by default. + #[serde(default = "default_true")] + pub restore_agent_sessions: bool, +} + +/// One saved profile's usage record for palette frecency (see +/// [`Config::ssh_profile_frecency`]). +#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize)] +#[serde(default)] +pub struct ProfileUsage { + /// Times this profile has been connected. + pub count: u32, + /// Unix timestamp (seconds) of the most recent connect. + pub last_used: u64, +} + +impl ProfileUsage { + /// A frecency score combining frequency (how often) with recency (how + /// recently), so the palette floats both heavily-used and just-used profiles + /// to the top. Recency decays smoothly over days; `now` is unix seconds. + pub fn score(&self, now: u64) -> f64 { + if self.count == 0 { + return 0.0; + } + let age_days = now.saturating_sub(self.last_used) as f64 / 86_400.0; + // Frequency, discounted by how stale the last use is (halves ~weekly). + self.count as f64 / (1.0 + age_days / 7.0) + } +} + +/// The current unix time in whole seconds (0 before the epoch, which never +/// happens). Used to stamp [`ProfileUsage::last_used`]. +pub fn unix_now() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) +} + +/// Policy for a shell's starting directory (see [`Config::working_directory`]). +#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize)] +#[serde(default)] +pub struct WorkingDirectory { + /// Which base directory to use. + #[serde(deserialize_with = "de_lenient")] + pub strategy: WdStrategy, + /// The directory used when `strategy` is [`WdStrategy::Custom`]. Kept even + /// while another strategy is active so toggling back restores the last path. + pub path: String, +} + +/// The base-directory strategy for a freshly spawned shell. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum WdStrategy { + /// Inherit the daemon's current directory (falling back to `$HOME` when it's + /// unavailable / a bare `/`). The current behavior. + #[default] + Inherit, + /// Always start in the user's home directory. + Home, + /// Always start in [`WorkingDirectory::path`]. + Custom, +} + +/// Window state applied when tty7 launches (see [`Config::startup_mode`]). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum StartupMode { + /// A regular centered window at the default size (the current behavior). + #[default] + Normal, + /// Maximized (zoomed) to fill the work area. + Maximized, + /// Native fullscreen. + Fullscreen, +} + +/// The shape drawn for the block cursor (see [`Config::cursor_style`]). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum CursorStyle { + /// A filled rectangle covering the whole cell (the classic block). + #[default] + Block, + /// A thin vertical bar at the cell's left edge (i-beam). + Bar, + /// A thin horizontal line along the cell's baseline. + Underline, +} + +/// Where [`Config::new_tab_position`] inserts a freshly opened tab. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum NewTabPosition { + /// Immediately after the currently active tab (the current behavior). + #[default] + AfterCurrent, + /// At the very end of the tab strip. + End, +} + +/// Where the tab bar is rendered (see [`Config::tab_bar_position`]). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum TabBarPosition { + /// A horizontal strip of chips in the title bar. + Top, + /// A vertical list down the left side of the window (a tab sidebar). + #[default] + Left, +} + +/// How the vertical tab sidebar arranges its rows (see +/// [`Config::sidebar_grouping`]). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum SidebarGrouping { + /// Group tabs under a header per git repository (linked worktrees fold + /// into their main checkout's group), with non-repo tabs + /// collected in a trailing "Scratch" group. Branch changes and cds inside + /// a repo never move a tab; only changing repos does. + #[default] + Repo, + /// One flat list in tab order (the pre-grouping behavior). + None, +} + +/// When tty7 posts a "command finished" desktop notification (see +/// [`Config::notify_on_command_finish`]). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum NotifyMode { + /// Never notify. + Never, + /// Only when the window is not currently focused (the current behavior). + #[default] + Unfocused, + /// Always, even when the window is focused. + Always, +} + +/// How the terminal bell (BEL / `^G`) is signalled (see [`Config::bell`]). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum BellMode { + /// Ignore the bell entirely — no flash, no sound. + None, + /// A brief visual flash of the terminal (the current behavior). + #[default] + Visual, + /// Ring the system bell. On platforms without one, falls back to a flash so + /// an opted-in bell is never silent. + Audible, +} + +/// A shell program plus its launch arguments. Mirrors `alacritty_terminal`'s +/// `tty::Shell`, but lives here so config has no dependency on the PTY crate and +/// the daemon can read it straight from `config.json`. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +pub struct ShellConfig { + /// Executable to launch. Either a bare name resolved via `PATH` + /// (e.g. `"pwsh"`, `"bash"`) or an absolute path + /// (e.g. `"C:\\Windows\\System32\\cmd.exe"`, `"/usr/bin/fish"`). + pub program: String, + /// Arguments passed to the shell on launch (e.g. `["-l"]` for a login shell, + /// or `["-NoLogo"]` for PowerShell). Empty by default. + #[serde(default)] + pub args: Vec, +} + +/// The default fallback chain for the platform we were built for. +/// +/// Fallbacks are resolved by *family name* against installed fonts, so a list +/// that reads well on one OS can miss entirely on another: every name in the +/// original list (Menlo, Apple Color Emoji) shipped with macOS, which left +/// Windows and Linux with a chain that matched nothing and fell straight +/// through to the platform's own cascade. +/// +/// That fall-through is not merely cosmetic. `element.rs` pins each wide cell +/// to `2 × cell_width`, and with the bundled Hack primary a cell is 0.60205em, +/// so a two-column slot is 1.2041em. The OS cascade serves a *1.0em* CJK face +/// (Microsoft YaHei, PingFang, Noto Sans CJK), and `force_width` left-aligns — +/// the ideograph hugs the left of its slot and dumps the whole 0.2em remainder +/// on the right. Naming a 1.2em face first (Maple Mono NF CN: 0.6em Latin, +/// 1.2em CJK — an exact two-cell fit) keeps the ink centered instead. +/// +/// Maple is referenced by name only, never bundled — it is ~20MB per weight. +/// Users who lack it land on the stock CJK face below, which still renders; +/// it just carries the left-hugging tracking described above. +pub fn default_font_fallbacks() -> Vec { + let names: &[&str] = if cfg!(target_os = "macos") { + &[ + "Menlo", + "Hasklug Nerd Font Mono", + "Maple Mono NF CN", + "PingFang SC", + "Apple Color Emoji", + ] + } else if cfg!(target_os = "windows") { + &[ + "Maple Mono NF CN", + "Cascadia Mono", + "Microsoft YaHei", + "Segoe UI Emoji", + ] + } else { + &[ + "Maple Mono NF CN", + "DejaVu Sans Mono", + "Noto Sans CJK SC", + "Noto Color Emoji", + ] + }; + names.iter().map(|n| n.to_string()).collect() +} + +/// Stock faces this platform is expected to ship, appended to whatever the user +/// configured (see `terminal::view::fallback_chain`). +/// +/// [`default_font_fallbacks`] only helps a *fresh* config. Anyone who already +/// has a `config.json` carries the old macOS-only list forever, so the same +/// repair has to happen at use time. Appending is safe by construction: a +/// fallback is consulted only once everything ahead of it has missed, so these +/// can never displace a face the user chose. +pub fn platform_last_resort_fallbacks() -> &'static [&'static str] { + if cfg!(target_os = "macos") { + &["PingFang SC", "Apple Color Emoji"] + } else if cfg!(target_os = "windows") { + &["Microsoft YaHei", "Segoe UI Emoji"] + } else { + &["Noto Sans CJK SC", "Noto Color Emoji"] + } +} + +impl Default for Config { + fn default() -> Self { + // These defaults match the values that used to be hardcoded in + // `TerminalView::new` and `app::apply_theme`. + Self { + // "Hack" is bundled with the app (see `register_bundled_fonts` in + // main.rs), so this default renders identically everywhere without + // relying on a system install. Menlo stays as a safety net. + font_family: "Hack".to_string(), + font_fallbacks: default_font_fallbacks(), + font_family_bold: None, + font_family_italic: None, + font_features: None, + font_size: 15.0, + line_height: 1.4, + theme: "light".to_string(), + // The default theme id (mirrors `ui::presets::DEFAULT_ID`; core can't + // depend on ui). Unknown ids fall back to it anyway. + theme_preset: "light".to_string(), + theme_follow_system: false, + // The built-in light/dark pair; each side is user-swappable in + // Settings once "sync with system" is on. + theme_preset_light: "light".to_string(), + theme_preset_dark: "dark".to_string(), + window_opacity: None, + window_blur: None, + keybindings: HashMap::new(), + keybinding_preset: default_preset(), + prefix: default_prefix(), + // `None` → the platform default shell (login shell on Unix, + // PowerShell 7 / Windows PowerShell on Windows), chosen by the + // daemon at spawn time. + shell: None, + // Behavior defaults mirror the values previously hardcoded across the + // app, so exposing them as config changes nothing until the user opts + // out: URL detection on, cursor blinking, 10k scrollback, new tabs + // after the active one, notify only while unfocused. + link_url: true, + link_file_command: None, + ssh_loopback_forward: false, + cursor_blink: true, + scrollback_limit: 10_000, + new_tab_position: NewTabPosition::AfterCurrent, + // Vertical sidebar down the left side; `top` opts back into the + // horizontal title-bar strip. + tab_bar_position: TabBarPosition::Left, + sidebar_width: default_sidebar_width(), + sidebar_collapsed: false, + right_panel_visible: false, + right_panel_width: default_right_panel_width(), + right_panel_tab: RightPanelTab::Info, + sidebar_grouping: SidebarGrouping::Repo, + notify_on_command_finish: NotifyMode::Unfocused, + // Opt-out, not opt-in: a stale terminal that never tells you it's + // outdated is the status quo we're fixing. One cheap GET at startup. + check_for_updates: true, + notify_threshold_secs: default_notify_threshold_secs(), + restore_session: true, + show_tray_icon: true, + workspace_detach_hint_seen: false, + confirm_window_close: true, + // Visual flash preserves the pre-config behavior (the bell always + // flashed); opting into None/Audible is a deliberate change. + bell: BellMode::Visual, + tab_completion: true, + history_search: true, + cursor_style: CursorStyle::Block, + // Input/mouse defaults preserve today's behavior: Option composes + // characters as macOS ships it (opt into Option-as-Meta); GPUI + // already hides the pointer while typing (its `CursorHideMode` + // default), so that starts `true`; no focus-follows-mouse, raw 1× + // scroll, no copy trim, a normal centered window. + macos_option_as_alt: false, + mouse_hide_while_typing: true, + focus_follows_mouse: false, + mouse_scroll_multiplier: 1.0, + mouse_reporting: true, + clipboard_trim_trailing_spaces: false, + copy_on_select: false, + smart_select: true, + word_separators: default_word_separators(), + startup_mode: StartupMode::Normal, + remember_window_size: true, + working_directory: WorkingDirectory::default(), + env: HashMap::new(), + ssh_profiles: Vec::new(), + verify_host_keys: true, + ssh_warn_on_close: false, + ssh_profile_frecency: HashMap::new(), + command_frecency: HashMap::new(), + agent_commands: HashMap::new(), + restore_agent_sessions: true, + } + } +} + +impl Config { + /// Load the config, falling back to defaults if the file is absent or + /// unreadable, and to defaults (with a warning) if it fails to parse. + pub fn load() -> Self { + let Some(path) = Self::path() else { + return Config::default(); + }; + let Ok(text) = std::fs::read_to_string(&path) else { + // Missing/unreadable config is the common case — start with defaults. + return Config::default(); + }; + match serde_json::from_str::(strip_bom(&text)) { + Ok(mut cfg) => { + cfg.sanitize(); + cfg + } + Err(e) => { + log::warn!( + "failed to parse config at {}: {e}; using defaults", + path.display() + ); + Config::default() + } + } + } + + /// Clamp parsed values into sane ranges so a hand-edited or corrupt + /// `config.json` can't crash the renderer (e.g. `font_size: 0` or a tiny + /// `line_height` would round the row height to 0 → divide-by-zero → + /// `usize::MAX` rows → allocation panic on first paint). + fn sanitize(&mut self) { + if !self.font_size.is_finite() || self.font_size <= 0.0 { + self.font_size = Config::default().font_size; + } + self.font_size = self.font_size.clamp(4.0, 256.0); + if !self.line_height.is_finite() || self.line_height <= 0.0 { + self.line_height = Config::default().line_height; + } + self.line_height = self.line_height.clamp(0.5, 4.0); + // Keep scrollback in a sane band: a floor so it's never uselessly tiny, + // and alacritty's own ceiling (a huge value would just balloon memory — + // the emulator caps history there anyway). + self.scrollback_limit = self.scrollback_limit.clamp(100, MAX_SCROLLBACK); + if !self.mouse_scroll_multiplier.is_finite() || self.mouse_scroll_multiplier <= 0.0 { + self.mouse_scroll_multiplier = Config::default().mouse_scroll_multiplier; + } + self.mouse_scroll_multiplier = self.mouse_scroll_multiplier.clamp(0.1, 10.0); + // Keep the notify threshold in a usable band: a 1s floor so it can't fire + // on every trivial command, and a 1-hour ceiling above which "long + // command" stops meaning anything. + self.notify_threshold_secs = self.notify_threshold_secs.clamp(1, 3600); + // A NaN override would make the whole window invisible or poison the + // alpha math; drop it. The floor keeps a hand-edited value from hiding + // the window entirely. + self.window_opacity = self + .window_opacity + .filter(|o| o.is_finite()) + .map(|o| o.clamp(0.2, 1.0)); + // A corrupt/NaN width would poison `w(px(..))`; keep it in a broad safe + // band (the live layout enforces the real `[180, window/2]` bounds). + if !self.sidebar_width.is_finite() || self.sidebar_width <= 0.0 { + self.sidebar_width = default_sidebar_width(); + } + self.sidebar_width = self.sidebar_width.clamp(100.0, 2000.0); + if !self.right_panel_width.is_finite() || self.right_panel_width <= 0.0 { + self.right_panel_width = default_right_panel_width(); + } + self.right_panel_width = self.right_panel_width.clamp(100.0, 2000.0); + // An empty or whitespace-only file-open command means "no override"; the + // settings text field yields `""` when cleared, so fold it back to `None` + // rather than trying to run an empty command. + if let Some(command) = &self.link_file_command + && command.trim().is_empty() + { + self.link_file_command = None; + } + } + + /// Write the current config back to disk, creating the parent directory if + /// needed. Used to persist runtime changes (theme toggle, font zoom) so they + /// survive a restart. Failures are logged, never fatal. + pub fn save(&self) { + let Some(path) = Self::path() else { + return; + }; + if let Some(parent) = path.parent() { + let _ = std::fs::create_dir_all(parent); + } + match serde_json::to_string_pretty(self) { + Ok(text) => { + if let Err(e) = write_atomic(&path, text.as_bytes()) { + log::warn!("failed to write config at {}: {e}", path.display()); + } + } + Err(e) => log::warn!("failed to serialize config: {e}"), + } + } + + /// `~/.config/tty7/config.json`. + fn path() -> Option { + config_path("config.json") + } +} + +/// Process-wide override for the config directory. Set once at startup from the +/// `--config-dir` CLI flag (see `main`); `None` means "use the default". Lets a +/// dev build (`cargo dev`) keep its config/session/history out of the real +/// `~/.config/tty7/` so debugging never clobbers your live setup. +static CONFIG_DIR_OVERRIDE: OnceLock = OnceLock::new(); + +/// Pin the config directory for this process. Idempotent — only the first call +/// wins, so call it before any `config_path` use (i.e. before `Config::load`). +pub fn set_config_dir(dir: PathBuf) { + let _ = CONFIG_DIR_OVERRIDE.set(dir); +} + +/// The directory every config-dir file lives in. Resolution order: +/// 1. `--config-dir` override (via `set_config_dir`), +/// 2. `$TTY7_CONFIG_DIR` env var, +/// 3. the platform default (see [`default_config_dir`]). +fn config_dir() -> Option { + if let Some(dir) = CONFIG_DIR_OVERRIDE.get() { + return Some(dir.clone()); + } + if let Some(dir) = std::env::var_os("TTY7_CONFIG_DIR").filter(|d| !d.is_empty()) { + return Some(PathBuf::from(dir)); + } + default_config_dir() +} + +/// Default config directory on Unix: `$HOME/.config/tty7` (the XDG-ish location +/// tty7 has always used). +#[cfg(not(windows))] +fn default_config_dir() -> Option { + let home = std::env::var_os("HOME").filter(|h| !h.is_empty())?; + Some(PathBuf::from(home).join(".config/tty7")) +} + +/// Default config directory on Windows: `%APPDATA%\tty7` (the conventional +/// per-user roaming app-data location), falling back to +/// `%USERPROFILE%\.config\tty7` to mirror the Unix layout if `APPDATA` is unset. +#[cfg(windows)] +fn default_config_dir() -> Option { + if let Some(appdata) = std::env::var_os("APPDATA").filter(|d| !d.is_empty()) { + return Some(PathBuf::from(appdata).join("tty7")); + } + let profile = std::env::var_os("USERPROFILE").filter(|d| !d.is_empty())?; + Some(PathBuf::from(profile).join(".config").join("tty7")) +} + +/// Resolve a file under the config directory (no `dirs` dep). Shared by every +/// config-dir file (`config.json`, `session.json`, `history`). +pub fn config_path(file: &str) -> Option { + Some(config_dir()?.join(file)) +} + +/// Drop a leading UTF-8 BOM so a hand-edited config still parses. +/// +/// `serde_json` rejects U+FEFF before the opening brace, and every config-dir +/// file is read by a loader that treats *any* parse error as "there is no +/// config" — so a BOM doesn't surface as an error, it silently resets the +/// user's settings. Windows makes that easy to hit by accident: PowerShell's +/// `>`, `Out-File` and `Set-Content -Encoding utf8` all write one, so a quick +/// `... | Set-Content config.json` is enough to lose every setting. +/// +/// `read_to_string` decodes the BOM to the single char U+FEFF, so this strips +/// the char rather than the three raw bytes. +pub fn strip_bom(text: &str) -> &str { + text.strip_prefix('\u{FEFF}').unwrap_or(text) +} + +/// Write `bytes` to `path` atomically: write to a sibling temp file, fsync, then +/// rename over the target. A crash/power-loss mid-write then leaves either the +/// old file or the new one intact — never a truncated/half-written file that +/// fails to parse and silently reverts the user's settings to defaults. The temp +/// lives in the same directory so the rename stays on one filesystem (atomic). +/// Shared by `Config::save` and `Session::save`. +pub fn write_atomic(path: &std::path::Path, bytes: &[u8]) -> std::io::Result<()> { + use std::io::Write as _; + let dir = path.parent().unwrap_or_else(|| std::path::Path::new(".")); + // Per-process-unique temp name so two concurrent writers don't clobber the + // same scratch file (the final rename then resolves last-writer-wins, with no + // torn target either way). + let tmp = dir.join(format!( + ".{}.tmp.{}", + path.file_name().and_then(|n| n.to_str()).unwrap_or("out"), + std::process::id() + )); + { + let mut f = std::fs::File::create(&tmp)?; + f.write_all(bytes)?; + f.flush()?; + let _ = f.sync_all(); + } + match std::fs::rename(&tmp, path) { + Ok(()) => Ok(()), + Err(e) => { + let _ = std::fs::remove_file(&tmp); + Err(e) + } + } +} + +/// The resolved config directory, exposed so the daemon spawner can forward it to +/// the detached child as `--config-dir`. We hand the child the *resolved* path +/// rather than rely on inheritance, so the spawned daemon lands in the exact dir +/// the GUI is using (dev and prod each get their own daemon — that isolation is +/// intentional). `None` only when nothing resolves (no override, no env var, no +/// `$HOME`); the caller then omits the flag and lets the child fall back to its +/// own default resolution. +pub fn config_dir_path() -> Option { + config_dir() +} + +/// The user's configured shell override, if any, as `(program, args)`. Loaded +/// straight from `config.json` so the **daemon** process (which has no GPUI +/// `Config` global) can honor it when spawning a PTY. `None` → the daemon picks +/// the platform default (login shell on Unix, PowerShell 7 / Windows PowerShell +/// on Windows). +pub fn shell_command() -> Option<(String, Vec)> { + Config::load().shell.map(|s| (s.program, s.args)) +} + +/// The forced base directory for a spawned shell, per `working_directory`. +/// `Some(dir)` overrides the daemon's inherit fallback (but not an explicit +/// client-supplied cwd); `None` means "use the inherit fallback" (the default). +/// Read straight from `config.json` so the **daemon** can honor it. `Home`/an +/// empty `Custom` path resolve via `$HOME`. +pub fn working_directory_base() -> Option { + let wd = Config::load().working_directory; + let home = || std::env::var_os("HOME").map(PathBuf::from); + match wd.strategy { + WdStrategy::Inherit => None, + WdStrategy::Home => home(), + WdStrategy::Custom => { + let p = wd.path.trim(); + if p.is_empty() { + home() + } else { + Some(PathBuf::from(p)) + } + } + } +} + +/// Extra environment variables to inject into every spawned shell, read from +/// `config.json` on the daemon side (which has no GPUI `Config` global). +pub fn extra_env() -> HashMap { + Config::load().env +} + +/// User-defined agent-detection rules (`agent_commands`), keys lowercased, +/// cached once per process. The daemon consults this from its 0.5 s foreground +/// poll on every pane, so it must not re-read `config.json` each time; the +/// trade-off is that rule edits apply on the next daemon start (the GUI's +/// "Restart daemon" command counts). +pub fn agent_commands_cached() -> &'static HashMap { + static CACHE: std::sync::OnceLock> = std::sync::OnceLock::new(); + CACHE.get_or_init(|| { + Config::load() + .agent_commands + .into_iter() + .map(|(k, v)| (k.to_ascii_lowercase(), v)) + .collect() + }) +} + +/// Serde default for [`Config::keybinding_preset`]: the no-op `"default"` preset. +fn default_preset() -> String { + "default".to_string() +} + +/// Serde default for the several `bool` fields that default to `true` (so a +/// config predating them, or one omitting them, keeps the on-by-default +/// behavior instead of deserializing to `false`). +fn default_true() -> bool { + true +} + +/// Serde default for [`Config::word_separators`]: alacritty's stock semantic +/// escape set, the boundary characters double-click word selection used +/// before this was configurable. +fn default_word_separators() -> String { + ",│`|:\"' ()[]{}<>\t".to_string() +} + +/// Serde default for [`Config::notify_threshold_secs`]: the 10-second floor a +/// command had to cross before this was configurable. +fn default_notify_threshold_secs() -> u64 { + 10 +} + +/// Serde default for [`Config::prefix`]: tmux's classic `C-b`. +fn default_prefix() -> String { + "ctrl-b".to_string() +} + +/// Which tab the right detail panel shows. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RightPanelTab { + /// Session facts: cwd, shell, branch, agent. + #[default] + Info, + /// The pane's command history as a navigable outline (OSC 133 marks). + Outline, + /// The pane's working-tree diff. + Changes, + /// The file tree rooted at the pane's repository. + Files, +} + +/// Serde default for [`Config::right_panel_width`]: wide enough for a file path +/// plus its `+N −M` counts without the tree turning into an ellipsis parade. +fn default_right_panel_width() -> f32 { + 260. +} + +/// Serde default for [`Config::sidebar_width`]: a comfortable rail width that +/// clears the tab labels without eating too much of the terminal. +fn default_sidebar_width() -> f32 { + 220.0 +} + +/// Upper bound on `scrollback_limit`. Matches alacritty_terminal's own history +/// ceiling — asking for more just wastes memory since the emulator caps there. +pub const MAX_SCROLLBACK: usize = 100_000; + +/// Deserialize a field leniently: if it's present but unparseable (e.g. a typo'd +/// enum string), fall back to `Default` with a warning instead of failing the +/// whole `config.json` parse — one bad entry must never reset every other +/// setting to its default. Missing fields are still handled by the container's +/// `#[serde(default)]`, which never calls this. +pub(crate) fn de_lenient<'de, D, T>(deserializer: D) -> Result +where + D: serde::Deserializer<'de>, + T: serde::de::DeserializeOwned + Default, +{ + let value = serde_json::Value::deserialize(deserializer)?; + Ok(T::deserialize(&value).unwrap_or_else(|e| { + log::warn!("ignoring invalid config value {value}: {e}; using default"); + T::default() + })) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn profile_usage_score_ranks_frequency_and_recency() { + let now = 100_000_000u64; + let day = 86_400u64; + // Never-used scores zero. + assert_eq!(ProfileUsage::default().score(now), 0.0); + // Same recency, more uses ⇒ higher score. + let a = ProfileUsage { + count: 10, + last_used: now, + }; + let b = ProfileUsage { + count: 2, + last_used: now, + }; + assert!(a.score(now) > b.score(now)); + // Same count, more recent ⇒ higher score (recency decays with age). + let recent = ProfileUsage { + count: 3, + last_used: now, + }; + let stale = ProfileUsage { + count: 3, + last_used: now - 30 * day, + }; + assert!(recent.score(now) > stale.score(now)); + } + + #[test] + fn ssh_warn_on_close_and_frecency_round_trip() { + let mut cfg = Config::default(); + assert!(!cfg.ssh_warn_on_close); + cfg.ssh_warn_on_close = true; + let id = uuid::Uuid::new_v4(); + cfg.ssh_profile_frecency.insert( + id, + ProfileUsage { + count: 4, + last_used: 42, + }, + ); + let json = serde_json::to_string(&cfg).unwrap(); + let back: Config = serde_json::from_str(&json).unwrap(); + assert!(back.ssh_warn_on_close); + assert_eq!(back.ssh_profile_frecency.get(&id).unwrap().count, 4); + } + + /// Opt-*out*, unlike most flags here: a config written before this setting + /// existed must keep the prompt, or an update would silently take away the + /// one thing telling people their sessions survive a quit. + #[test] + fn confirm_window_close_defaults_on_and_round_trips() { + assert!(Config::default().confirm_window_close); + + let old: Config = serde_json::from_str(r#"{"font_size": 15.0}"#).unwrap(); + assert!(old.confirm_window_close); + + let off: Config = serde_json::from_str(r#"{"confirm_window_close": false}"#).unwrap(); + assert!(!off.confirm_window_close); + let json = serde_json::to_string(&off).unwrap(); + let back: Config = serde_json::from_str(&json).unwrap(); + assert!(!back.confirm_window_close); + + // ...and a key this build has never heard of — a config last written by + // a newer tty7, or hand-edited — must be ignored rather than failing the + // whole parse, which `Config::load` would swallow into *defaults*: the + // opt-out would come back on with nothing said. + let newer: Config = + serde_json::from_str(r#"{"confirm_window_close": false, "not_a_setting": 7}"#).unwrap(); + assert!(!newer.confirm_window_close); + } + + #[test] + fn theme_follow_system_defaults_and_round_trips() { + // Old configs (no follow-system keys) must land on off + the built-in + // light/dark pair, so nothing changes until the user opts in. + let cfg: Config = serde_json::from_str(r#"{"theme_preset":"dracula"}"#).unwrap(); + assert!(!cfg.theme_follow_system); + assert_eq!(cfg.theme_preset_light, "light"); + assert_eq!(cfg.theme_preset_dark, "dark"); + assert_eq!(cfg.theme_preset, "dracula"); + + let mut cfg = Config::default(); + cfg.theme_follow_system = true; + cfg.theme_preset_light = "one_light".to_string(); + cfg.theme_preset_dark = "dracula".to_string(); + let json = serde_json::to_string(&cfg).unwrap(); + let back: Config = serde_json::from_str(&json).unwrap(); + assert!(back.theme_follow_system); + assert_eq!(back.theme_preset_light, "one_light"); + assert_eq!(back.theme_preset_dark, "dracula"); + } + + #[test] + fn font_features_are_optional_and_parse_as_gpui_features() { + let cfg: Config = + serde_json::from_str(r#"{"font_features":{"calt":true,"liga":1}}"#).unwrap(); + let features = cfg.font_features.expect("font features should parse"); + assert_eq!(features.is_calt_enabled(), Some(true)); + assert!( + features + .tag_value_list() + .iter() + .any(|(tag, value)| tag == "liga" && *value == 1) + ); + + let default_cfg = Config::default(); + assert!(default_cfg.font_features.is_none()); + } + + /// The wire format is a real key in the user's `config.json`, so it is + /// frozen: bools and integers both parse, both land as integers, and the + /// integer form re-parses to the same value. (Matches what + /// `gpui::FontFeatures` did when this field was typed as it.) + #[test] + fn font_features_round_trip_to_integer_valued_json() { + let features: FontFeatures = + serde_json::from_str(r#"{"calt":true,"liga":1,"ss01":0,"zero":false}"#).unwrap(); + assert_eq!( + features.tag_value_list(), + &[ + ("calt".to_string(), 1), + ("liga".to_string(), 1), + ("ss01".to_string(), 0), + ("zero".to_string(), 0), + ] + ); + + let json = serde_json::to_string(&features).unwrap(); + assert_eq!(json, r#"{"calt":1,"liga":1,"ss01":0,"zero":0}"#); + let back: FontFeatures = serde_json::from_str(&json).unwrap(); + assert_eq!(back, features); + assert_eq!(serde_json::to_string(&back).unwrap(), json); + } + + /// Junk inside the map is dropped, never fatal — one typo'd tag must not + /// reset the rest of `config.json`. + #[test] + fn font_features_skip_bad_tags_and_values_instead_of_failing() { + let features: FontFeatures = serde_json::from_str( + r#"{"toolong":1,"ss":1,"calt":true,"liga":null,"kern":-1,"dlig":1.5}"#, + ) + .unwrap(); + assert_eq!(features.tag_value_list(), &[("calt".to_string(), 1)]); + } + + /// A `font_features` key nested in a whole config survives a full + /// `Config` round-trip unchanged. + #[test] + fn font_features_survive_a_config_round_trip() { + let cfg: Config = + serde_json::from_str(r#"{"font_features":{"calt":true,"liga":1}}"#).unwrap(); + let json = serde_json::to_string(&cfg).unwrap(); + let back: Config = serde_json::from_str(&json).unwrap(); + assert_eq!(back.font_features, cfg.font_features); + assert_eq!( + back.font_features.as_ref().map(|f| f.tag_value_list()), + Some(&[("calt".to_string(), 1), ("liga".to_string(), 1)][..]) + ); + } + + #[test] + fn stale_override_keys_are_ignored() { + // Leftover keys from the retired override system are ignored, not fatal. + let cfg: Config = serde_json::from_str( + r##"{"font_size": 20.0, "colors": {"border": "#fff"}, "ansi_colors": {"color1": "#f00"}}"##, + ) + .expect("stale override keys must be ignored"); + assert_eq!(cfg.font_size, 20.0); + assert_eq!(cfg.theme_preset, "light"); + } + + #[test] + fn sanitize_clamps_degenerate_font_metrics() { + // A zero/negative/NaN font size or line height would round the row height + // to 0 and crash the renderer (divide-by-zero → usize::MAX rows). Clamp. + let sanitized = |font_size: f32, line_height: f32| { + let mut cfg = Config { + font_size, + line_height, + ..Config::default() + }; + cfg.sanitize(); + (cfg.font_size, cfg.line_height) + }; + + let (fs, lh) = sanitized(0.0, 0.0); + assert!(fs >= 4.0, "font_size clamped above zero"); + assert!(lh >= 0.5, "line_height clamped above zero"); + + let (fs, lh) = sanitized(f32::NAN, f32::INFINITY); + assert!(fs.is_finite() && fs > 0.0); + assert!(lh.is_finite() && lh > 0.0); + + // A sane value is left untouched. + assert_eq!(sanitized(15.0, 1.4), (15.0, 1.4)); + } + + /// Per-test scratch directory, unique per test name + PID and removed on + /// drop — cleanup runs even when an assertion panics mid-test, so a failed + /// run can't leak state into (or collide with) the next one. + struct TestDir(std::path::PathBuf); + + impl TestDir { + fn new(name: &str) -> Self { + let dir = std::env::temp_dir().join(format!("tty7-test-{name}-{}", std::process::id())); + // A stale copy from a crashed earlier run would poison this one. + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + Self(dir) + } + + fn path(&self) -> &std::path::Path { + &self.0 + } + } + + impl Drop for TestDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + + #[test] + fn write_atomic_replaces_contents_and_leaves_no_temp() { + let dir = TestDir::new("atomic"); + let target = dir.path().join("data.json"); + write_atomic(&target, b"first").unwrap(); + assert_eq!(std::fs::read_to_string(&target).unwrap(), "first"); + // Overwrite is atomic and complete (no truncation/append residue). + write_atomic(&target, b"second-longer-and-then-short").unwrap(); + write_atomic(&target, b"3rd").unwrap(); + assert_eq!(std::fs::read_to_string(&target).unwrap(), "3rd"); + // The sibling temp file must not linger. + let leftover: Vec<_> = std::fs::read_dir(dir.path()) + .unwrap() + .flatten() + .filter(|e| e.file_name().to_string_lossy().contains(".tmp.")) + .collect(); + assert!(leftover.is_empty(), "temp file should be renamed away"); + } + + #[test] + fn behavior_enums_fall_back_leniently_on_bad_values() { + // A typo'd enum string must NOT reset the whole config: font_size is kept, + // and only the bad field falls back to its default. + let cfg: Config = serde_json::from_str( + r#"{"font_size": 20.0, "new_tab_position": "middle", "notify_on_command_finish": "sometimes", "tab_bar_position": "diagonal"}"#, + ) + .expect("a bad enum value must not fail the whole parse"); + assert_eq!(cfg.font_size, 20.0); + assert_eq!(cfg.new_tab_position, NewTabPosition::AfterCurrent); + assert_eq!(cfg.notify_on_command_finish, NotifyMode::Unfocused); + assert_eq!(cfg.tab_bar_position, TabBarPosition::Left); + + // Valid kebab-case values round-trip. `top` is the non-default here, so + // the assert still proves the field parsed rather than fell back. + let cfg: Config = serde_json::from_str( + r#"{"new_tab_position": "end", "notify_on_command_finish": "always", "tab_bar_position": "top"}"#, + ) + .unwrap(); + assert_eq!(cfg.new_tab_position, NewTabPosition::End); + assert_eq!(cfg.notify_on_command_finish, NotifyMode::Always); + assert_eq!(cfg.tab_bar_position, TabBarPosition::Top); + } + + #[test] + fn working_directory_defaults_to_inherit_and_parses_kebab() { + let cfg = Config::default(); + assert_eq!(cfg.working_directory.strategy, WdStrategy::Inherit); + assert!(cfg.working_directory.path.is_empty()); + + let cfg: Config = serde_json::from_str( + r#"{"working_directory": {"strategy": "custom", "path": "/tmp/x"}}"#, + ) + .unwrap(); + assert_eq!(cfg.working_directory.strategy, WdStrategy::Custom); + assert_eq!(cfg.working_directory.path, "/tmp/x"); + + // A bad strategy value falls back to the default without failing the parse. + let cfg: Config = + serde_json::from_str(r#"{"working_directory": {"strategy": "elsewhere"}}"#).unwrap(); + assert_eq!(cfg.working_directory.strategy, WdStrategy::Inherit); + } + + #[test] + fn sanitize_clamps_scroll_multiplier_into_band() { + let clamp = |m: f32| { + let mut cfg = Config { + mouse_scroll_multiplier: m, + ..Config::default() + }; + cfg.sanitize(); + cfg.mouse_scroll_multiplier + }; + assert_eq!(clamp(1.0), 1.0); + assert_eq!(clamp(0.0), 1.0); // non-positive → default + assert_eq!(clamp(-3.0), 1.0); + assert_eq!(clamp(100.0), 10.0); // ceiling + assert_eq!(clamp(0.01), 0.1); // floor + } + + /// The window-opacity override is clamped into its usable band; a NaN is + /// dropped back to "follow theme" rather than poisoning the alpha math. + #[test] + fn sanitize_clamps_window_opacity_override() { + let clamp = |o: Option| { + let mut cfg = Config { + window_opacity: o, + ..Config::default() + }; + cfg.sanitize(); + cfg.window_opacity + }; + assert_eq!(clamp(None), None); + assert_eq!(clamp(Some(0.8)), Some(0.8)); + assert_eq!(clamp(Some(0.0)), Some(0.2)); // floor: never invisible + assert_eq!(clamp(Some(2.0)), Some(1.0)); // ceiling + assert_eq!(clamp(Some(f32::NAN)), None); // NaN → follow theme + } + + #[test] + fn sanitize_clamps_scrollback_into_band() { + let clamp = |n: usize| { + let mut cfg = Config { + scrollback_limit: n, + ..Config::default() + }; + cfg.sanitize(); + cfg.scrollback_limit + }; + assert_eq!(clamp(0), 100); // floor + assert_eq!(clamp(10_000), 10_000); // untouched in-band + assert_eq!(clamp(usize::MAX), MAX_SCROLLBACK); // ceiling + } + + #[test] + fn new_terminal_prefs_default_and_parse_leniently() { + // Defaults preserve the pre-config behavior: restore on, mouse reporting + // on, a 10s notify floor, and a visual bell. + let cfg = Config::default(); + assert!(cfg.restore_session); + assert!(cfg.mouse_reporting); + assert!(cfg.tab_completion); + assert!(cfg.history_search); + assert_eq!(cfg.notify_threshold_secs, 10); + assert_eq!(cfg.bell, BellMode::Visual); + + // A config predating these fields keeps the on-by-default booleans (not + // `false`) and the 10s floor. + let cfg: Config = serde_json::from_str(r#"{"font_size": 15.0}"#).unwrap(); + assert!(cfg.restore_session); + assert!(cfg.mouse_reporting); + assert!(cfg.tab_completion); + assert!(cfg.history_search); + assert_eq!(cfg.notify_threshold_secs, 10); + assert_eq!(cfg.bell, BellMode::Visual); + + // The opt-outs round-trip. + let cfg: Config = serde_json::from_str(r#"{"tab_completion": false}"#).unwrap(); + assert!(!cfg.tab_completion); + let cfg: Config = serde_json::from_str(r#"{"history_search": false}"#).unwrap(); + assert!(!cfg.history_search); + + // Valid values round-trip; a bad bell string falls back without failing + // the whole parse. + let cfg: Config = serde_json::from_str( + r#"{"restore_session": false, "mouse_reporting": false, "bell": "audible"}"#, + ) + .unwrap(); + assert!(!cfg.restore_session); + assert!(!cfg.mouse_reporting); + assert_eq!(cfg.bell, BellMode::Audible); + + let cfg: Config = serde_json::from_str(r#"{"bell": "loud"}"#).unwrap(); + assert_eq!(cfg.bell, BellMode::Visual); + } + + #[test] + fn sanitize_clamps_notify_threshold_into_band() { + let clamp = |n: u64| { + let mut cfg = Config { + notify_threshold_secs: n, + ..Config::default() + }; + cfg.sanitize(); + cfg.notify_threshold_secs + }; + assert_eq!(clamp(0), 1); // floor + assert_eq!(clamp(10), 10); // untouched in-band + assert_eq!(clamp(100_000), 3600); // ceiling + } + + #[test] + fn keybinding_preset_and_prefix_default_and_round_trip() { + // Missing fields fall back to the no-op preset and the tmux-classic prefix. + let cfg = Config::default(); + assert_eq!(cfg.keybinding_preset, "default"); + assert_eq!(cfg.prefix, "ctrl-b"); + + let cfg: Config = serde_json::from_str(r#"{"font_size": 15.0}"#).unwrap(); + assert_eq!(cfg.keybinding_preset, "default"); + assert_eq!(cfg.prefix, "ctrl-b"); + + // Explicit values survive a parse. + let cfg: Config = + serde_json::from_str(r#"{"keybinding_preset": "tmux", "prefix": "ctrl-a"}"#).unwrap(); + assert_eq!(cfg.keybinding_preset, "tmux"); + assert_eq!(cfg.prefix, "ctrl-a"); + } + + /// A fresh config must name a CJK face the *host* platform actually ships. + /// The pre-fix list was macOS-only, so Windows and Linux wrote a chain that + /// matched nothing and left every ideograph to the OS cascade. + #[test] + fn default_font_fallbacks_are_platform_appropriate() { + let defaults = default_font_fallbacks(); + assert!(!defaults.is_empty()); + + for name in platform_last_resort_fallbacks() { + assert!( + defaults.iter().any(|f| f == name), + "default chain {defaults:?} omits stock face {name}" + ); + } + + // Maple Mono NF CN is the only face whose CJK advance (1.2em) is an exact + // two-cell fit against the bundled Hack primary, so it must be tried + // before the stock face on every platform. + let maple = defaults.iter().position(|f| f == "Maple Mono NF CN"); + let maple = maple.expect("the exact-fit CJK face must stay in the chain"); + for name in platform_last_resort_fallbacks() { + let stock = defaults.iter().position(|f| f == name).unwrap(); + assert!(maple < stock, "{name} must not preempt Maple Mono NF CN"); + } + + // macOS-only names must not leak into the other platforms' defaults. + if !cfg!(target_os = "macos") { + for name in ["Menlo", "Apple Color Emoji"] { + assert!( + !defaults.iter().any(|f| f == name), + "{name} ships only with macOS" + ); + } + } + } + + #[test] + fn config_deserialize_fills_missing_fields_from_defaults() { + // Only one field present; the rest must fall back via #[serde(default)]. + let cfg: Config = serde_json::from_str(r#"{"font_size": 20.0}"#).unwrap(); + assert_eq!(cfg.font_size, 20.0); + assert_eq!(cfg.line_height, 1.4); // default preserved + assert_eq!(cfg.font_family, "Hack"); // default preserved + assert_eq!(cfg.theme_preset, "light"); + assert!(cfg.keybindings.is_empty()); + } + + /// Pin the process config dir at a shared temp location so `load`/`save` never + /// touch the real `~/.config`. First-call-wins; every IO test uses the same path. + fn pin_config_dir() { + let dir = std::env::temp_dir().join(format!("tty7-covtest-{}", std::process::id())); + std::fs::create_dir_all(&dir).ok(); + set_config_dir(dir); + } + + /// Serialize the tests that write the shared `config.json`: they all resolve + /// the same pinned path, so without this they clobber each other's file. + static CONFIG_FILE: std::sync::Mutex<()> = std::sync::Mutex::new(()); + + fn lock_config_file() -> std::sync::MutexGuard<'static, ()> { + // A poisoned lock only means another test failed mid-sequence; every + // holder rewrites the file from scratch, so the state is still sound. + CONFIG_FILE.lock().unwrap_or_else(|e| e.into_inner()) + } + + #[test] + fn save_load_and_shell_command_round_trip_through_disk() { + let _guard = lock_config_file(); + pin_config_dir(); + // Persist a config with a non-default shell + font + an SSH profile, then + // read it back. + let mut cfg = Config { + font_size: 18.0, + ..Config::default() + }; + cfg.shell = Some(ShellConfig { + program: "fish".to_string(), + args: vec!["-l".to_string()], + }); + let mut profile = crate::core::ssh_profile::SshProfile::new("prod-web"); + profile.host = "10.0.0.5".to_string(); + profile.user = "deploy".to_string(); + profile.port = 2222; + profile.auth = crate::core::ssh_profile::AuthMode::PublicKey; + profile.credential_ref = Some(crate::core::keychain::CredentialRef::password( + "deploy", "10.0.0.5", 2222, + )); + cfg.ssh_profiles = vec![profile.clone()]; + cfg.save(); + + let loaded = Config::load(); + assert_eq!(loaded.font_size, 18.0); + assert_eq!( + loaded.shell.as_ref().map(|s| s.program.as_str()), + Some("fish") + ); + // The SSH profile round-trips byte-for-byte, id included, with no plaintext + // secret anywhere (only the credential *ref*). + assert_eq!(loaded.ssh_profiles, vec![profile]); + + // `shell_command` reads the same on-disk config for the daemon side. + let (program, args) = shell_command().expect("shell override present"); + assert_eq!(program, "fish"); + assert_eq!(args, vec!["-l".to_string()]); + } + + #[test] + fn a_utf8_bom_does_not_silently_reset_the_config() { + let _guard = lock_config_file(); + pin_config_dir(); + let path = Config::path().expect("pinned config dir"); + // Exactly what PowerShell's `>`, `Out-File` and `Set-Content -Encoding + // utf8` leave behind. + let text = "\u{FEFF}{\"font_size\": 21.0, \"restore_session\": false}"; + write_atomic(&path, text.as_bytes()).unwrap(); + + // The failure this guards is silent by construction: `load` turns *any* + // parse error into defaults, so a BOM didn't report a bad config — it + // reported no config, and the user's settings appeared to vanish. + let loaded = Config::load(); + assert_eq!(loaded.font_size, 21.0); + assert!(!loaded.restore_session); + + let _ = std::fs::remove_file(&path); + } + + #[test] + fn strip_bom_only_removes_a_leading_marker() { + assert_eq!(strip_bom("{}"), "{}"); + assert_eq!(strip_bom("\u{FEFF}{}"), "{}"); + // Only the first U+FEFF is a marker. A second one is content, and + // content that happens to be a BOM is still invalid JSON — stripping + // it too would be guessing at a file we can't rescue. + assert_eq!(strip_bom("\u{FEFF}\u{FEFF}{}"), "\u{FEFF}{}"); + // A BOM *inside* the document is data (U+FEFF is a legal string char), + // so it must survive untouched. + let inner = "{\"tab_title\":\"\u{FEFF}\"}"; + assert_eq!(strip_bom(inner), inner); + assert_eq!(strip_bom(""), ""); + } + + #[test] + fn ssh_profiles_default_empty_and_parse_from_json() { + // Absent key → empty (a config predating profiles still loads). + let cfg = Config::default(); + assert!(cfg.ssh_profiles.is_empty()); + let cfg: Config = serde_json::from_str(r#"{"font_size": 15.0}"#).unwrap(); + assert!(cfg.ssh_profiles.is_empty()); + + // A present profile array parses; a bad enum value inside one profile falls + // back leniently instead of failing the whole config parse. + let cfg: Config = serde_json::from_str( + r#"{"ssh_profiles":[{"name":"a","host":"h","auth":"bogus","port":2200}]}"#, + ) + .expect("a bad per-profile enum must not fail the whole config parse"); + assert_eq!(cfg.ssh_profiles.len(), 1); + assert_eq!(cfg.ssh_profiles[0].name, "a"); + assert_eq!(cfg.ssh_profiles[0].port, 2200); + assert_eq!( + cfg.ssh_profiles[0].auth, + crate::core::ssh_profile::AuthMode::Auto + ); + } + + #[test] + fn config_path_resolves_under_the_pinned_dir() { + pin_config_dir(); + let p = config_path("config.json").expect("config path resolves"); + assert!(p.ends_with("config.json")); + // `config_dir_path` returns the same parent the files live under. + assert_eq!(p.parent(), config_dir_path().as_deref()); + } +} diff --git a/src/core/crash.rs b/crates/tty7-core/src/core/crash.rs similarity index 100% rename from src/core/crash.rs rename to crates/tty7-core/src/core/crash.rs diff --git a/crates/tty7-core/src/core/git.rs b/crates/tty7-core/src/core/git.rs new file mode 100644 index 00000000..89457962 --- /dev/null +++ b/crates/tty7-core/src/core/git.rs @@ -0,0 +1,303 @@ +//! Git, the way every part of tty7 reads it: one shell-out per field, always +//! `git -C `, always `GIT_OPTIONAL_LOCKS=0`. +//! +//! This is the shared bottom layer, not a feature: the sidebar's branch/diff +//! line, the diff overlay, and (from the remote-workspace work on) the server +//! side all go through the same [`git`] invocation, so a read tty7 performs can +//! never take `index.lock` and fight a real git command the user is running. +//! Deliberately shell-out simple — blocking, one process per question; callers +//! run it on a background thread so nothing UI-facing waits on a slow repo. +//! +//! The caching layer that fans one probe out to every pane in a repo is *not* +//! here: it is a gpui global, so it stays in the GUI crate +//! (`terminal::git_status::GitStatusCache`). + +use std::io; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; + +use crate::host::{Host, Output}; + +/// A repo's git snapshot: the branch it's on and how much the working tree has +/// changed against `HEAD`. `added`/`removed` sum the per-file line counts from +/// `git diff --numstat HEAD` (tracked staged + unstaged changes); binary files +/// and untracked files don't contribute a line count. +#[derive(Clone, PartialEq, Eq, Debug)] +pub struct GitStatus { + /// The branch name (`main`, `feat/x`), or a short commit sha when the HEAD + /// is detached. Never empty. + pub branch: String, + /// Lines added across the working tree vs `HEAD`. + pub added: u32, + /// Lines removed across the working tree vs `HEAD`. + pub removed: u32, +} + +/// One raw probe result, before it's folded into the cache: which work tree +/// `cwd` belongs to, plus the fields probed there. `counts` is `None` when the +/// `git diff` invocation itself failed (e.g. it raced a concurrent git write) — +/// distinct from a clean tree's `Some((0, 0))`, so the cache can keep the +/// previous numbers instead of pretending the tree went clean. +#[derive(Clone, PartialEq, Eq, Debug)] +pub struct RepoSnapshot { + /// The work tree root (`git rev-parse --show-toplevel`) — the cache key + /// every pane inside this work tree shares. For a linked worktree this is + /// the worktree's own directory, not the main checkout's. + pub root: PathBuf, + /// The *repository* the work tree belongs to: the main checkout's root + /// when `root` is a linked worktree, otherwise `root` itself. The + /// sidebar's grouping key — every worktree of one repo shares it, while + /// branch/diff state stays per work tree under `root`. + pub home: PathBuf, + pub branch: String, + pub counts: Option<(u32, u32)>, +} + +/// Probe the git snapshot for `cwd` on `host`, or `None` when it isn't inside a +/// git work tree (or the path is gone, or the host can't be reached). +/// Blocking — the GUI calls it through `ui::host_ops`, never on the UI thread. +/// +/// Three invocations, and deliberately not fewer. The first `rev-parse` answers +/// every *path* question at once: the work-tree root (which doubles as the "is +/// this a git repo" gate — it fails outside a work tree) plus the +/// git-dir/common-dir pair that tells a linked worktree from a main checkout. +/// Asking those separately cost two process spawns per probe, which mattered +/// once probes stopped being rare: they now also fire on window activation and +/// on an agent's tool calls, across every pane. The branch cannot join them — +/// `symbolic-ref` is what names a branch before the first commit exists, and +/// folding `--abbrev-ref HEAD` into the `rev-parse` would make the whole +/// invocation fail on an unborn branch and lose the paths with it. +/// +/// On a remote host each of the three is a round trip; the throttle and the +/// in-flight dedup in the GUI's `GitStatusCache` are what keep that from being +/// three per pane per trigger. +pub fn probe(host: &dyn Host, cwd: &Path) -> Option { + // No `exists` pre-check: a vanished cwd already fails `Host::git` with + // `NotFound`, which lands as `None` here — the same answer, one round trip + // cheaper. + let paths = git( + host, + cwd, + &[ + "rev-parse", + "--path-format=absolute", + "--show-toplevel", + "--git-dir", + "--git-common-dir", + ], + )?; + let mut lines = paths.lines().map(|l| l.trim_end_matches(['\n', '\r'])); + let root = PathBuf::from(lines.next()?); + // A git old enough to reject `--path-format` fails the whole invocation + // above, so reaching here means the two dirs are present — but degrade to + // "main checkout" rather than trusting that, same as the old code did. + let home = repo_home(&root, lines.next(), lines.next()); + let branch = branch_name(host, cwd)?; + Some(RepoSnapshot { + home, + root, + branch, + counts: diff_numstat(host, cwd), + }) +} + +/// The repository "home" every checkout of one repo shares, from the work-tree +/// `root` and the `--git-dir` / `--git-common-dir` pair: for a linked worktree +/// (its git dir differs from the common git dir) the main work tree's root — +/// the parent of `
/.git`; for the main checkout itself, a submodule, or +/// any failure to tell, the work-tree root unchanged. A bare common dir (no +/// trailing `.git` component, the bare-repo-plus-worktrees layout) anchors on +/// the bare directory itself — still one shared key. +fn repo_home(root: &Path, git_dir: Option<&str>, common_dir: Option<&str>) -> PathBuf { + let (Some(git_dir), Some(common)) = (git_dir, common_dir) else { + return root.to_path_buf(); + }; + if git_dir == common { + return root.to_path_buf(); + } + let common = Path::new(common); + match (common.file_name(), common.parent()) { + (Some(name), Some(parent)) if name == ".git" => parent.to_path_buf(), + _ => common.to_path_buf(), + } +} + +/// The current branch name, or a short sha for a detached HEAD. Shared with +/// `terminal::git_diff` in the GUI crate, which fronts its overlay with the +/// same branch label the sidebar row shows. +pub fn branch_name(host: &dyn Host, cwd: &Path) -> Option { + // On a branch — even before the first commit — `symbolic-ref` names it. + if let Some(out) = git(host, cwd, &["symbolic-ref", "--quiet", "--short", "HEAD"]) { + let name = out.trim(); + if !name.is_empty() { + return Some(name.to_string()); + } + } + // Detached HEAD (or a rebase/bisect): fall back to the short commit sha. + let sha = git(host, cwd, &["rev-parse", "--short", "HEAD"])?; + let sha = sha.trim(); + (!sha.is_empty()).then(|| sha.to_string()) +} + +/// Sum added/removed lines across the working tree vs `HEAD` from +/// `git diff --numstat HEAD`. Binary files (`-\t-`) contribute nothing. +/// `None` when the invocation itself failed — the caller keeps old counts. +fn diff_numstat(host: &dyn Host, cwd: &Path) -> Option<(u32, u32)> { + let out = git(host, cwd, &["diff", "--numstat", "HEAD"])?; + let mut added = 0u32; + let mut removed = 0u32; + for line in out.lines() { + let mut fields = line.split('\t'); + if let Some(n) = fields.next().and_then(|s| s.parse::().ok()) { + added += n; + } + if let Some(n) = fields.next().and_then(|s| s.parse::().ok()) { + removed += n; + } + } + Some((added, removed)) +} + +/// Run `git -C ` on `host` and return stdout on success, `None` on +/// a non-zero exit or a git that never ran. +/// +/// The projection of [`git_output`] the snapshot readers want: they treat "git +/// said no" and "git could not be asked" identically — a failed probe leaves +/// the previous snapshot standing rather than blanking the branch line — where +/// callers like `core::worktree` need the two kept apart, and the stderr with +/// them. Non-UTF-8 stdout is `None`: there is nothing sensible to parse out of +/// it. +/// +/// Which machine's `git` runs is the host's business; the *invariants* of the +/// invocation are [`git_output`]'s, and every implementation of +/// [`Host::git`] owes them. +pub fn git(host: &dyn Host, cwd: &Path, args: &[&str]) -> Option { + let out = host.git(cwd, args).ok()?; + if !out.success() { + return None; + } + String::from_utf8(out.stdout).ok() +} + +/// The full result of `git -C ` — exit code, stdout *and* stderr — +/// under the invariants every git invocation in tty7 shares. +/// +/// This is the bottom layer [`git`] is a projection of, and the one +/// [`crate::host::Host::git`] exposes: a `Host` has to answer for a remote +/// machine's git too, where "non-zero exit" and "git never ran" are genuinely +/// different outcomes and the caller needs stderr to say which. `Ok` means the +/// process ran (the exit code is in [`Output::status`]); `Err` means it could +/// not be run at all. +/// +/// The invariants, all of them load-bearing: +/// +/// - **`-C `**, never `Command::current_dir` — the working directory of +/// this process is not a thing a GUI with many panes can meaningfully set. +/// - **`GIT_OPTIONAL_LOCKS=0`**, so a background read can never take +/// `index.lock` and lose a race against a real git command the user is +/// running. It only suppresses *optional* locks; writes still lock normally. +/// - **stdin nulled**, so a misconfigured credential helper or a prompt-happy +/// subcommand fails immediately instead of hanging a background thread +/// forever on a terminal nobody is attached to. +/// - **`GIT_DIR` / `GIT_WORK_TREE` removed**, so a tty7 launched from inside a +/// git hook (or any shell that exported them) can't have that ambient +/// repository silently override the `-C` we just passed. +/// - **`hide_console` on Windows**, so probing a repo doesn't flash a console +/// window. +/// +/// A `cwd` that doesn't exist is [`io::ErrorKind::NotFound`] rather than git's +/// own exit 128: "the directory is gone" is not a question about the repository, +/// and callers (and the remote `Host` contract) distinguish the two. +pub fn git_output(cwd: &Path, args: &[&str]) -> io::Result { + if !cwd.exists() { + return Err(io::Error::new( + io::ErrorKind::NotFound, + format!("git cwd does not exist: {}", cwd.display()), + )); + } + let mut cmd = Command::new("git"); + cmd.arg("-C") + .arg(cwd) + .args(args) + .env("GIT_OPTIONAL_LOCKS", "0") + .env_remove("GIT_DIR") + .env_remove("GIT_WORK_TREE") + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + let out = crate::core::proc::hide_console(&mut cmd).output()?; + Ok(Output { + status: out.status.code(), + stdout: out.stdout, + stderr: out.stderr, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The host these tests probe through: this machine, which is what the GUI + /// hands `probe` for a local pane. + fn h() -> crate::host::SharedHost { + crate::host::local::LocalHost::new() + } + + /// A tmp path that is not a git repo yields no snapshot (and never panics). + #[test] + fn non_repo_is_none() { + let dir = std::env::temp_dir().join("tty7-git-status-not-a-repo-xyz"); + let _ = std::fs::create_dir_all(&dir); + assert_eq!(probe(&*h(), &dir), None); + } + + /// A path that doesn't exist is `None`, not a panic. + #[test] + fn missing_path_is_none() { + assert_eq!(probe(&*h(), Path::new("/no/such/tty7/path/here")), None); + } + + /// This repo (the crate root is inside the tty7 work tree) reports a branch + /// and a root, exercising the real `git` probe end-to-end. + #[test] + fn own_repo_has_a_branch_and_root() { + let here = env!("CARGO_MANIFEST_DIR"); + if let Some(snap) = probe(&*h(), Path::new(here)) { + assert!(!snap.branch.is_empty()); + assert!(Path::new(here).starts_with(&snap.root)); + } + // If the crate is built outside a work tree (e.g. a vendored tarball), + // `None` is the correct answer and the assertions above are skipped. + } + /// The four shapes `repo_home` has to tell apart, straight from the + /// `--git-dir` / `--git-common-dir` pair the merged `rev-parse` returns. + #[test] + fn repo_home_resolves_worktree_layouts() { + let root = Path::new("/repo/.wt/feat"); + + // A main checkout: the two dirs agree, so the work tree is its own home. + assert_eq!( + repo_home(Path::new("/repo"), Some("/repo/.git"), Some("/repo/.git")), + PathBuf::from("/repo") + ); + // A linked worktree: the common dir is the main checkout's `.git`, so + // the home is that `.git`'s parent — the main work tree. + assert_eq!( + repo_home(root, Some("/repo/.git/worktrees/feat"), Some("/repo/.git")), + PathBuf::from("/repo") + ); + // A bare repo with worktrees hanging off it: no `.git` component to + // strip, so the bare dir itself is the shared key. + assert_eq!( + repo_home(root, Some("/bare.git/worktrees/feat"), Some("/bare.git")), + PathBuf::from("/bare.git") + ); + // A git too old (or too odd) to answer both: degrade to the work tree + // rather than guessing a grouping key. + assert_eq!( + repo_home(root, Some("/repo/.git"), None), + root.to_path_buf() + ); + assert_eq!(repo_home(root, None, None), root.to_path_buf()); + } +} diff --git a/crates/tty7-core/src/core/gitignore.rs b/crates/tty7-core/src/core/gitignore.rs new file mode 100644 index 00000000..8b11df56 --- /dev/null +++ b/crates/tty7-core/src/core/gitignore.rs @@ -0,0 +1,169 @@ +//! The `.gitignore` chain a directory listing is scored against. +//! +//! One matcher is compiled per directory that has a `.gitignore`, cached by +//! that directory's path, and a path is scored by walking the chain from the +//! tree root down to the path's own parent — **the deepest match wins**, so a +//! nested `.gitignore`'s whitelist (`!pattern`) can un-ignore what an ancestor +//! ignored, which is what git itself does. +//! +//! Lives in `tty7-core` rather than beside the file tree because the answer has +//! to be identical on both sides of a remote workspace: the GUI dims ignored +//! entries for a local tree, and the server has to dim exactly the same ones +//! for a remote tree. One implementation, no drift. +//! +//! Compiling is lazy and cached (including the negative case — a directory with +//! no `.gitignore` caches as `None`), so a chain that is carried across +//! listings pays for each directory once. `Arc`, so a chain can be cloned onto +//! a background thread and its compiled matchers shared rather than rebuilt. + +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use ignore::gitignore::Gitignore; + +/// Compiled `.gitignore` matchers, keyed by the directory each came from +/// (`None` = that directory has no `.gitignore`). +#[derive(Default, Clone)] +pub struct GitignoreChain { + matchers: HashMap>>, +} + +impl GitignoreChain { + /// Walk the `.gitignore` chain from `root` down to `path`'s directory and + /// report whether `path` ends up ignored; the deepest match wins + /// (whitelist `!patterns` un-ignore). + /// + /// `is_dir` matters because gitignore patterns can be directory-only + /// (`build/`). Paths outside `root` simply score against nothing. + pub fn is_ignored(&mut self, path: &Path, is_dir: bool, root: &Path) -> bool { + let Some(parent) = path.parent() else { + return false; + }; + let mut state = false; + // Ancestor chain root → parent, in order. + let mut chain: Vec<&Path> = parent + .ancestors() + .take_while(|a| a.starts_with(root)) + .collect(); + chain.reverse(); + for dir in chain { + let gi = self + .matchers + .entry(dir.to_path_buf()) + .or_insert_with(|| { + let file = dir.join(".gitignore"); + file.is_file().then(|| { + let (gi, _err) = Gitignore::new(&file); + Arc::new(gi) + }) + }) + .clone(); + let Some(gi) = gi else { continue }; + let Ok(rel) = path.strip_prefix(dir) else { + continue; + }; + match gi.matched(rel, is_dir) { + ignore::Match::Ignore(_) => state = true, + ignore::Match::Whitelist(_) => state = false, + ignore::Match::None => {} + } + } + state + } + + /// Fold another chain's compiled matchers in — how a background listing + /// hands back the ones it had to compile so the next listing re-uses them. + pub fn absorb(&mut self, other: Self) { + self.matchers.extend(other.matchers); + } + + /// Drop every compiled matcher, so the next scoring recompiles from disk. + /// The invalidation a `.gitignore` edit triggers. + pub fn clear(&mut self) { + self.matchers.clear(); + } + + /// How many directories have been scored (and so cached) so far — the + /// negative entries for directories without a `.gitignore` included. + pub fn len(&self) -> usize { + self.matchers.len() + } + + /// Whether nothing has been compiled or cached yet. + pub fn is_empty(&self) -> bool { + self.matchers.is_empty() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Write a `.gitignore` into `dir` (creating it) with the given patterns. + fn write_ignore(dir: &Path, body: &str) { + std::fs::create_dir_all(dir).unwrap(); + std::fs::write(dir.join(".gitignore"), body).unwrap(); + } + + fn scratch(name: &str) -> PathBuf { + let dir = + std::env::temp_dir().join(format!("tty7-gitignore-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir + } + + /// The deepest `.gitignore` wins, so a nested whitelist un-ignores what the + /// root ignored — the rule the file tree's dimming depends on. + #[test] + fn the_deepest_match_wins() { + let root = scratch("deepest"); + write_ignore(&root, "*.log\n"); + write_ignore(&root.join("keep"), "!important.log\n"); + + let mut chain = GitignoreChain::default(); + assert!(chain.is_ignored(&root.join("a.log"), false, &root)); + assert!(chain.is_ignored(&root.join("keep/other.log"), false, &root)); + assert!(!chain.is_ignored(&root.join("keep/important.log"), false, &root)); + assert!(!chain.is_ignored(&root.join("a.txt"), false, &root)); + + let _ = std::fs::remove_dir_all(&root); + } + + /// A directory-only pattern (`build/`) matches the directory, not a file of + /// the same name — which is why scoring takes `is_dir`. + #[test] + fn directory_only_patterns_need_is_dir() { + let root = scratch("dironly"); + write_ignore(&root, "build/\n"); + + let mut chain = GitignoreChain::default(); + assert!(chain.is_ignored(&root.join("build"), true, &root)); + assert!(!chain.is_ignored(&root.join("build"), false, &root)); + + let _ = std::fs::remove_dir_all(&root); + } + + /// `clear` forces a recompile, so an edited `.gitignore` takes effect; + /// without it the cached matcher would answer from the old patterns. + #[test] + fn clear_lets_an_edited_gitignore_take_effect() { + let root = scratch("clear"); + write_ignore(&root, "*.log\n"); + + let mut chain = GitignoreChain::default(); + assert!(chain.is_ignored(&root.join("a.log"), false, &root)); + + write_ignore(&root, "*.tmp\n"); + assert!( + chain.is_ignored(&root.join("a.log"), false, &root), + "cached" + ); + chain.clear(); + assert!(!chain.is_ignored(&root.join("a.log"), false, &root)); + assert!(chain.is_ignored(&root.join("a.tmp"), false, &root)); + + let _ = std::fs::remove_dir_all(&root); + } +} diff --git a/crates/tty7-core/src/core/keychain.rs b/crates/tty7-core/src/core/keychain.rs new file mode 100644 index 00000000..b0723742 --- /dev/null +++ b/crates/tty7-core/src/core/keychain.rs @@ -0,0 +1,178 @@ +//! The *naming* half of the SSH credential vault: how a keychain entry is +//! addressed, and the secret-free pointer that `config.json` persists. +//! +//! Secrets (passwords, private-key passphrases) live only in the platform secret +//! store — never in `config.json`. A profile persists at most a [`CredentialRef`], +//! which *names* a keychain entry but carries no secret. Per PRD §7.2 entries are +//! keyed by **endpoint**, not by profile: +//! +//! - passwords → service `tty7-ssh`, account `@:` +//! - key passphrases → service `tty7-ssh-key`, account `` +//! +//! Endpoint keying lets a QuickConnect (which has no profile) still "remember" a +//! password, lets several profiles pointing at one endpoint share one credential, +//! and means changing a password touches exactly one entry. +//! +//! **The store itself is not here.** The `CredentialStore` trait, its OS-keychain +//! backend and the in-memory test double live in the GUI crate +//! (`tty7::core::keychain`), because nothing in this crate reads or writes a +//! secret: the daemon receives already-resolved secrets on the wire (see +//! `daemon::protocol`'s `NativeSshSpec`) and the headless `tty7-server` runs on +//! boxes that have no OS keychain at all. Keeping `keyring` out of this crate's +//! manifest is what keeps a static `tty7-server` from linking the whole +//! `zbus`/`secret-service` stack it can never use — see the design doc §11. +//! +//! What has to stay is exactly what `Config` needs to parse `config.json` +//! identically on the server: the account-naming scheme and [`CredentialRef`]. + +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha512}; + +/// Keychain service name for endpoint passwords. +pub const SERVICE_PASSWORD: &str = "tty7-ssh"; +/// Keychain service name for private-key passphrases. +pub const SERVICE_KEY_PASSPHRASE: &str = "tty7-ssh-key"; + +/// Which kind of secret a [`CredentialRef`] points at. The kind selects the +/// keychain *service*; the ref's `account` selects the entry within it. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum CredentialKind { + /// An endpoint password (`tty7-ssh` service, `user@host:port` account). + #[default] + Password, + /// A private-key passphrase (`tty7-ssh-key` service, key-sha512-hex account). + KeyPassphrase, +} + +impl CredentialKind { + /// The keychain service name this kind stores under. + pub fn service(self) -> &'static str { + match self { + CredentialKind::Password => SERVICE_PASSWORD, + CredentialKind::KeyPassphrase => SERVICE_KEY_PASSPHRASE, + } + } +} + +/// A persisted, secret-free pointer to a keychain entry. This is the only +/// credential-related thing that ever lands in `config.json`. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[serde(default)] +pub struct CredentialRef { + /// Whether this names a password or a key passphrase. + #[serde(deserialize_with = "crate::core::config::de_lenient")] + pub kind: CredentialKind, + /// The keychain "account": `user@host:port` for [`CredentialKind::Password`], + /// or the sha512-hex of the key-file contents for + /// [`CredentialKind::KeyPassphrase`]. + pub account: String, +} + +impl Default for CredentialRef { + fn default() -> Self { + Self { + kind: CredentialKind::Password, + account: String::new(), + } + } +} + +impl CredentialRef { + /// Reference the password entry for an endpoint. + pub fn password(user: &str, host: &str, port: u16) -> Self { + Self { + kind: CredentialKind::Password, + account: endpoint_account(user, host, port), + } + } + + /// Reference the passphrase entry for a private key, given the sha512-hex of + /// its file contents (see [`key_account_from_contents`]). + pub fn key_passphrase(key_sha512_hex: impl Into) -> Self { + Self { + kind: CredentialKind::KeyPassphrase, + account: key_sha512_hex.into(), + } + } + + /// The keychain service this ref resolves under. + pub fn service(&self) -> &'static str { + self.kind.service() + } +} + +/// The endpoint account string used to key a password entry: `user@host:port`. +pub fn endpoint_account(user: &str, host: &str, port: u16) -> String { + format!("{user}@{host}:{port}") +} + +/// The account string used to key a private-key passphrase entry: the lowercase +/// sha512-hex digest of the key file's raw contents. Endpoint-independent, so the +/// same encrypted key reused across hosts shares one stored passphrase. +/// +/// Only the GUI calls this — it is the side that reads the key file — but the +/// account *name* is part of the persisted config contract, the same as +/// [`endpoint_account`], so both halves of PRD §7.2's keying scheme stay in one +/// place rather than drifting apart across the crate boundary. +pub fn key_account_from_contents(key_bytes: &[u8]) -> String { + let digest = Sha512::digest(key_bytes); + // Lowercase hex, no separators. + let mut hex = String::with_capacity(digest.len() * 2); + for byte in digest { + use std::fmt::Write as _; + let _ = write!(hex, "{byte:02x}"); + } + hex +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn endpoint_and_key_accounts_are_stable() { + assert_eq!( + endpoint_account("deploy", "10.0.0.5", 22), + "deploy@10.0.0.5:22" + ); + assert_eq!( + endpoint_account("deploy", "10.0.0.5", 2222), + "deploy@10.0.0.5:2222" + ); + + // sha512 hex is 128 chars, lowercase, and deterministic. + let a = key_account_from_contents(b"-----BEGIN OPENSSH PRIVATE KEY-----\n"); + let b = key_account_from_contents(b"-----BEGIN OPENSSH PRIVATE KEY-----\n"); + assert_eq!(a, b); + assert_eq!(a.len(), 128); + assert!( + a.chars() + .all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase()) + ); + assert_ne!(a, key_account_from_contents(b"different")); + } + + #[test] + fn credential_kind_selects_service() { + assert_eq!(CredentialKind::Password.service(), "tty7-ssh"); + assert_eq!(CredentialKind::KeyPassphrase.service(), "tty7-ssh-key"); + } + + #[test] + fn credential_ref_round_trips_and_hides_secret() { + let cref = CredentialRef::password("deploy", "10.0.0.5", 22); + let json = serde_json::to_string(&cref).unwrap(); + // Only kind + account are serialized — never a secret. + assert!(json.contains("deploy@10.0.0.5:22")); + assert!(json.contains("password")); + let back: CredentialRef = serde_json::from_str(&json).unwrap(); + assert_eq!(back, cref); + + // A bad `kind` value falls back to the default rather than failing the parse. + let lenient: CredentialRef = + serde_json::from_str(r#"{"kind":"bogus","account":"x"}"#).unwrap(); + assert_eq!(lenient.kind, CredentialKind::Password); + assert_eq!(lenient.account, "x"); + } +} diff --git a/crates/tty7-core/src/core/logfile.rs b/crates/tty7-core/src/core/logfile.rs new file mode 100644 index 00000000..f0cef49b --- /dev/null +++ b/crates/tty7-core/src/core/logfile.rs @@ -0,0 +1,235 @@ +//! File logger — the `log::` records that otherwise go nowhere. +//! +//! tty7 depends on the `log` facade but shipped no backend, so every +//! `log::info!` / `log::warn!` in the tree was compiled in and then discarded. +//! That is survivable for the GUI, which can put a failure on screen. It is not +//! survivable for the **daemon**: [`crate::daemon::spawn`] detaches it with its +//! stdio pointed at `/dev/null`, so a remote install that refused, a connection +//! that dropped, or a pane that died left no trace anywhere — the only artifact +//! the process could produce was `crash.log`, and only if it panicked. +//! +//! So: one append-only file next to `crash.log`, same size cap and same +//! best-effort discipline. Logging must never be the reason something fails. +//! +//! ## Level +//! +//! `TTY7_LOG` (or `RUST_LOG`) sets it — `off` / `error` / `warn` / `info` / +//! `debug` / `trace`. **Default `off`**: this writes to a user's disk forever, +//! and a terminal that logs by default is a terminal that fills a disk while +//! nobody is watching. Ask for it when diagnosing, which is also the only time +//! the records are worth anything. + +use std::fmt::Write as _; +use std::path::PathBuf; +use std::sync::{Mutex, OnceLock}; + +use log::{LevelFilter, Log, Metadata, Record}; + +/// Rewrite the log once it passes this. Same cap as `crash.log`, larger because +/// a debug session produces many small lines rather than a few big backtraces. +const MAX_BYTES: u64 = 4 * 1024 * 1024; + +struct FileLogger { + role: &'static str, + path: PathBuf, + /// Serializes writes so two threads cannot interleave halves of a line. + /// Contended only while logging is on, which is not the default. + lock: Mutex<()>, +} + +impl Log for FileLogger { + fn enabled(&self, metadata: &Metadata<'_>) -> bool { + metadata.level() <= log::max_level() + } + + fn log(&self, record: &Record<'_>) { + if !self.enabled(record.metadata()) { + return; + } + let mut line = String::new(); + let _ = write!( + line, + "{} {:5} {} [{}] {}\n", + timestamp(), + record.level(), + self.role, + record.target(), + record.args(), + ); + let _guard = self.lock.lock(); + append(&self.path, &line); + } + + fn flush(&self) {} +} + +/// Install the logger for this process, if the environment asks for one. +/// +/// `role` labels the records, since the GUI and the daemon it spawns share one +/// config dir and therefore one log file — the same convention `crash.log` +/// uses, and the reason a line can be attributed at all. +/// +/// Idempotent and silent on failure: a second call, a missing config dir, or a +/// read-only disk all leave the process running with no logger, which is +/// exactly what it had before. +pub fn install(role: &'static str) { + let level = level_from_env(); + if level == LevelFilter::Off { + return; + } + let Some(path) = log_path() else { + return; + }; + // A `static` rather than `set_boxed_logger`, which needs `log`'s `std` + // feature — not enabled here, and not worth enabling for one allocation + // that lives for the whole process anyway. `OnceLock` is also what makes a + // second call harmless. + static LOGGER: OnceLock = OnceLock::new(); + let logger = LOGGER.get_or_init(|| FileLogger { + role, + path, + lock: Mutex::new(()), + }); + if log::set_logger(logger).is_ok() { + log::set_max_level(level); + } +} + +/// `TTY7_LOG` first, then `RUST_LOG` — the former so turning on tty7's logging +/// does not also turn on every library that reads `RUST_LOG`. +/// +/// Only a bare level is understood, not `RUST_LOG`'s per-module syntax: a +/// half-supported filter language is worse than an obvious one, because +/// `TTY7_LOG=tty7_core::daemon=debug` would silently mean "off". +fn level_from_env() -> LevelFilter { + let raw = std::env::var("TTY7_LOG") + .or_else(|_| std::env::var("RUST_LOG")) + .unwrap_or_default(); + parse_level(&raw) +} + +fn parse_level(raw: &str) -> LevelFilter { + match raw.trim().to_ascii_lowercase().as_str() { + "error" => LevelFilter::Error, + "warn" | "warning" => LevelFilter::Warn, + "info" => LevelFilter::Info, + "debug" => LevelFilter::Debug, + "trace" => LevelFilter::Trace, + _ => LevelFilter::Off, + } +} + +fn append(path: &PathBuf, record: &str) { + use std::io::Write as _; + let truncate = std::fs::metadata(path).is_ok_and(|m| m.len() > MAX_BYTES); + let Ok(mut file) = std::fs::OpenOptions::new() + .create(true) + .append(!truncate) + .write(true) + .truncate(truncate) + .open(path) + else { + return; + }; + let _ = file.write_all(record.as_bytes()); + let _ = file.flush(); +} + +fn log_path() -> Option { + crate::core::config::config_path("tty7.log") +} + +/// `HH:MM:SS.mmm` — the time of day, which is what you compare against "I +/// clicked it just now". The date is in `crash.log`'s records and in the file's +/// own mtime; repeating it on every line would cost more than it tells. +fn timestamp() -> String { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default(); + let secs = now.as_secs() % 86_400; + format!( + "{:02}:{:02}:{:02}.{:03}", + secs / 3600, + (secs % 3600) / 60, + secs % 60, + now.subsec_millis() + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use log::Level; + + /// The default has to be `Off`. A terminal that logs to disk unasked fills + /// a disk on a machine nobody is watching — and the daemon outlives every + /// window, so there is no session boundary to bound it. + #[test] + fn logging_is_off_unless_asked_for() { + // Not via the environment: mutating it is `unsafe` in edition 2024 and + // races every other test in the binary. The parser is the whole + // decision, so it is what gets tested. + assert_eq!(parse_level(""), LevelFilter::Off); + assert_eq!(parse_level(" "), LevelFilter::Off); + assert_eq!(parse_level("nonsense"), LevelFilter::Off); + // `RUST_LOG`'s per-module syntax is deliberately *not* half-supported. + assert_eq!(parse_level("tty7_core::daemon=debug"), LevelFilter::Off); + } + + #[test] + fn levels_parse_case_and_space_insensitively() { + assert_eq!(parse_level("debug"), LevelFilter::Debug); + assert_eq!(parse_level(" DEBUG "), LevelFilter::Debug); + assert_eq!(parse_level("Warn"), LevelFilter::Warn); + assert_eq!(parse_level("warning"), LevelFilter::Warn); + assert_eq!(parse_level("TRACE"), LevelFilter::Trace); + } + + /// A run away log must not grow without bound: past the cap the file is + /// rewritten rather than appended to. + #[test] + fn the_file_is_rewritten_once_it_passes_the_cap() { + let path = std::env::temp_dir().join(format!("tty7-logfile-{}.log", std::process::id())); + let _ = std::fs::remove_file(&path); + + append(&path, "first\n"); + append(&path, "second\n"); + let both = std::fs::read_to_string(&path).unwrap(); + assert!(both.contains("first") && both.contains("second"), "appends"); + + std::fs::write(&path, vec![b'x'; (MAX_BYTES + 1) as usize]).unwrap(); + append(&path, "after the cap\n"); + let after = std::fs::read_to_string(&path).unwrap(); + assert_eq!(after, "after the cap\n", "rewritten, not appended"); + + let _ = std::fs::remove_file(&path); + } + + /// Records name which process wrote them: the GUI and the daemon it spawns + /// share one config dir, so an unattributed line is ambiguous exactly when + /// it matters (which side dropped the connection?). + #[test] + fn a_record_names_its_role_and_target() { + let path = std::env::temp_dir().join(format!("tty7-logrec-{}.log", std::process::id())); + let _ = std::fs::remove_file(&path); + let logger = FileLogger { + role: "daemon", + path: path.clone(), + lock: Mutex::new(()), + }; + log::set_max_level(LevelFilter::Info); + logger.log( + &Record::builder() + .args(format_args!("remote build-box: installed tty7-server")) + .level(Level::Info) + .target("tty7_core::daemon::install") + .build(), + ); + let written = std::fs::read_to_string(&path).unwrap(); + assert!(written.contains("daemon"), "{written}"); + assert!(written.contains("tty7_core::daemon::install"), "{written}"); + assert!(written.contains("installed tty7-server"), "{written}"); + assert!(written.contains("INFO"), "{written}"); + let _ = std::fs::remove_file(&path); + } +} diff --git a/crates/tty7-core/src/core/mod.rs b/crates/tty7-core/src/core/mod.rs new file mode 100644 index 00000000..4172f56d --- /dev/null +++ b/crates/tty7-core/src/core/mod.rs @@ -0,0 +1,34 @@ +//! Domain core: the configuration model, session persistence, the streaming +//! OSC tokenizer shared by the daemon- and client-side output scanners, and the +//! shell / agent / git knowledge the daemon and the GUI have to share. +//! +//! These modules are framework-light and depend on neither `ui` nor `terminal` +//! — the dependency arrow always points *inward* to here. That is what let them +//! lift out of the GUI binary into this crate without untangling view code. +//! +//! The GUI crate re-exports this module as `crate::core`, adding its own +//! gpui-facing modules (`actions`, `update`, …) and thin gpui layers over +//! `config`, `session` and `window_state`, so call sites there are unchanged. + +pub mod agent_hooks; +pub mod cli_agent; +pub mod config; +pub mod crash; +pub mod git; +pub mod gitignore; +pub mod logfile; +// SSH connection-manager data layer (WS1). Its public API is consumed by the +// daemon-session, auth, forwarding, and UI workstreams, which land separately — +// so parts of it read as dead code until those merge. +#[allow(dead_code)] +pub mod keychain; +pub mod osc; +pub mod proc; +pub mod session; +pub mod shells; +#[allow(dead_code)] +pub mod ssh_profile; +pub mod threads; +pub mod window_state; +pub mod workspace_store; +pub mod worktree; diff --git a/src/core/osc.rs b/crates/tty7-core/src/core/osc.rs similarity index 100% rename from src/core/osc.rs rename to crates/tty7-core/src/core/osc.rs diff --git a/src/core/proc.rs b/crates/tty7-core/src/core/proc.rs similarity index 100% rename from src/core/proc.rs rename to crates/tty7-core/src/core/proc.rs diff --git a/crates/tty7-core/src/core/session.rs b/crates/tty7-core/src/core/session.rs new file mode 100644 index 00000000..35ee15fa --- /dev/null +++ b/crates/tty7-core/src/core/session.rs @@ -0,0 +1,1672 @@ +//! Session persistence: remember the tab / split-pane layout and each +//! terminal's working directory across restarts, plus a stack of recently +//! closed tabs for "Reopen Closed Tab". +//! +//! The on-disk model mirrors the live `Pane` tree but stays purely +//! serializable (no GPUI entities, no `gpui::Axis` which isn't `Serialize`). +//! It lives at `~/.config/tty7/session.json`, alongside `config.json`. +//! +//! All IO and parsing is best-effort: a missing/corrupt file just means "no +//! session to restore", and write failures are logged rather than fatal — the +//! app must never crash or stall over session bookkeeping. + +use std::path::PathBuf; + +use serde::{Deserialize, Serialize}; + +use crate::daemon::protocol::NativeSshSpec; + +/// Split orientation, mirroring `gpui::Axis` (which isn't `Serialize`). +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +pub enum SessionAxis { + Horizontal, + Vertical, +} + +/// A serializable mirror of one tab's `Pane` tree. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum SessionPane { + /// A single terminal, restored in `cwd` (or the default dir if `None`). + Leaf { + #[serde(default)] + cwd: Option, + /// Daemon pane id this leaf was mirroring. On restore we re-`attach` to + /// it when the daemon still has it alive (process + scrollback intact), + /// else fall back to spawning a fresh shell in `cwd`. `None` for sessions + /// written by an older build (they just spawn fresh). + #[serde(default)] + pane_id: Option, + /// The native-SSH spec this leaf ran, **with secrets stripped** + /// ([`NativeSshSpec::without_secrets`]). Persisted so a *dead* native-SSH + /// pane can be respawned (reconnected) on restore rather than falling back + /// to a local shell — the reconnection UX itself is WS6's. A live pane + /// reattaches for free and needs none of this. `None` for local panes and + /// for sessions written before this field existed. + #[serde(default)] + ssh_spec: Option>, + /// The coding agent this leaf was running at save time, plus its native + /// session id (from the agent's own `session-start` event). When the + /// pane can't re-attach on restore, these drive the cmux-style resume: + /// the fresh shell is handed the agent's resume command + /// (`claude --resume `, …) so the conversation continues. `None` + /// for panes without an agent, agents without hooks, or old sessions. + #[serde(default)] + agent: Option, + #[serde(default)] + agent_session_id: Option, + /// The argv the agent was launched with, as the daemon observed it — + /// lets the resume command carry the user's launch flags + /// (`--dangerously-skip-permissions`, …) instead of resuming bare. + /// `None` for old sessions or when nothing was captured. + #[serde(default)] + agent_launch_argv: Option>, + }, + /// A split of two subtrees along `axis`, with `a` taking `ratio` of space. + Split { + axis: SessionAxis, + #[serde(default = "default_ratio")] + ratio: f32, + a: Box, + b: Box, + }, +} + +fn default_ratio() -> f32 { + 0.5 +} + +/// A serializable mirror of one tab: its pane tree plus an optional user-set +/// name (from "Rename Tab"). A missing `name` falls back to the title-derived +/// label at render time. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SessionTab { + #[serde(default)] + pub name: Option, + pub pane: SessionPane, + /// The tab's last-known sidebar repo group (its repository home — the + /// main checkout's root, shared by all its linked worktrees), so a + /// restored session renders grouped immediately instead of starting flat + /// and reshuffling as git probes land. `None` = Scratch / never resolved. + /// + /// **A bare path, and that is sound.** A path alone cannot say *which* + /// machine it is on, and [`HostId`](crate::host::HostId) — which could — + /// is deliberately not persistable. The qualifier is not missing, it is + /// factored out: a tab always belongs to exactly one [`Workspace`], a + /// workspace names exactly one machine in [`Workspace::host`], and a + /// window shows exactly one workspace (design §2, and §3's "一个窗口里既 + /// 有本地又有远程 —— 这个**永远不做**"). So the fully-qualified group key + /// is `(workspace.host_id(), tab.sidebar_group)`, with the host half + /// stored once per workspace instead of once per tab. Two machines whose + /// repos share a root path can only collide inside one window, which the + /// model does not permit. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub sidebar_group: Option, +} + +/// One workspace's contents: the open tabs and which one was active. +/// +/// This is the unit a single window displays. It used to *be* the whole file +/// (tty7 had exactly one window); it is now nested inside a [`Workspace`], and +/// [`Workspaces`] owns the file-level IO. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(default)] +pub struct Session { + pub active: usize, + pub tabs: Vec, +} + +/// Stable identity for a workspace, minted once when it is first created and +/// carried across restarts. Windows are transient views; *this* is what the +/// workspace picker reopens and what a window handle maps back to. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(transparent)] +pub struct WorkspaceId(uuid::Uuid); + +impl WorkspaceId { + pub fn new() -> Self { + Self(uuid::Uuid::new_v4()) + } + + /// A stable numeric key for gpui element ids, which need something + /// hashable and cheap rather than a freshly formatted string each frame. + pub fn element_key(&self) -> u64 { + self.0.as_u64_pair().0 + } +} + +impl Default for WorkspaceId { + fn default() -> Self { + Self::new() + } +} + +impl std::fmt::Display for WorkspaceId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.0.fmt(f) + } +} + +// --------------------------------------------------------------------------- +// Remote references +// --------------------------------------------------------------------------- + +/// The machine a remote workspace lives on, named the way the user already +/// named it. +/// +/// **This is a pointer, never a configuration.** Design §2 is explicit that a +/// machine is configured once and that remote workspaces reuse what is already +/// there — the profile's keys, its jump host, its `ProxyCommand` — so this type +/// has exactly one job: say *which* existing entry to connect through. The +/// three variants are the three places an SSH target can already have been +/// spelled out in tty7 today. +/// +/// | Variant | Where it came from | Connection key (contract §4.2) | +/// |---|---|---| +/// | [`Profile`](RemoteTarget::Profile) | A saved [`SshProfile`](crate::core::ssh_profile::SshProfile), by its stable uuid | `ssh-profile:` | +/// | [`Alias`](RemoteTarget::Alias) | A `Host` stanza in `~/.ssh/config` | `ssh-alias:` | +/// | [`Direct`](RemoteTarget::Direct) | A typed `user@host:port` (QuickConnect) | `ssh-direct:@:` | +/// | [`Wsl`](RemoteTarget::Wsl) | A WSL distro — **M8**, defined only so the key table has no hole | `wsl:` | +/// +/// Persisted, unlike [`HostId`](crate::host::HostId): this is what survives a +/// restart, and the id is derived from it at connect time. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum RemoteTarget { + /// A saved SSH profile, referenced by [`SshProfile::id`](crate::core::ssh_profile::SshProfile::id). + Profile { id: uuid::Uuid }, + /// A `Host` alias from `~/.ssh/config`. Kept verbatim — OpenSSH matches + /// alias names case-sensitively, so folding case here would point at a + /// different stanza than `ssh ` would. + Alias { alias: String }, + /// A target typed straight in, as `parse_quick_connect` understands it. + Direct { + /// The login user. Empty means "whatever this client's SSH would use", + /// which is a *different* connection key than a spelled-out user — see + /// [`RemoteTarget::connection_key`]. + #[serde(default)] + user: String, + /// Hostname or IP, lowercased (DNS is case-insensitive). + host: String, + #[serde(default = "default_ssh_port")] + port: u16, + }, + /// A WSL distribution. **M8 owns the behaviour**; the variant exists now so + /// that [`connection_key`](RemoteTarget::connection_key) is a total function + /// over contract §4.2's table rather than one that grows a case later. + Wsl { distro: String }, + /// A `tty7-server --stdio` child process on *this* machine — the workspace + /// mirror of [`RouteTarget::LocalStdio`](crate::daemon::router::RouteTarget::LocalStdio), + /// and the only way to exercise a real remote workspace end to end without + /// an sshd. + /// + /// **Never offered by the picker.** It is reachable only when + /// `TTY7_LOCAL_STDIO_SERVER` names a server binary, which is how the + /// end-to-end tests and a developer's `dev-verify` run stand a machine up. + /// It grants no authority the socket did not already have: a pane's + /// `ClientMsg::Spawn` already runs an arbitrary program as this user over + /// that same user-private socket. + LocalStdio { program: String, args: Vec }, +} + +fn default_ssh_port() -> u16 { + 22 +} + +impl RemoteTarget { + /// A `user@host:port` target, normalized. + /// + /// The host is lowercased here *and* in [`connection_key`](Self::connection_key) + /// — here so two equal targets compare equal, there so a hand-edited + /// `session.json` with `Box.Local` still derives the same id as `box.local`. + pub fn direct(user: impl Into, host: impl Into, port: u16) -> RemoteTarget { + RemoteTarget::Direct { + user: user.into(), + host: host.into().to_ascii_lowercase(), + port, + } + } + + /// Parse `[ssh://]user@host[:port]` into a [`Direct`](RemoteTarget::Direct) + /// target. + /// + /// Deliberately delegates to + /// [`parse_quick_connect`](crate::core::ssh_profile::parse_quick_connect) + /// rather than parsing again: "the same string the connection manager + /// already accepts" is the whole promise of this variant, and a second + /// parser would be a second opinion about IPv6 brackets and `@` in + /// usernames. `None` for anything that parser rejects. + pub fn parse_direct(input: &str) -> Option { + let q = crate::core::ssh_profile::parse_quick_connect(input)?; + let port = q.port_or_default(); + Some(RemoteTarget::direct( + q.user.unwrap_or_default(), + q.host, + port, + )) + } + + /// The canonical connection string this target hashes to (contract §4.2). + /// + /// **Contains no workspace id.** Several workspaces on one box share a key, + /// and therefore share a [`HostId`](crate::host::HostId) and the one SSH + /// connection underneath it — the granularity the whole design assumes + /// (design §10). + /// + /// One conservative case worth knowing: `me@box` and a bare `box` are + /// different keys even when the client's SSH would resolve them to the same + /// login. That costs a second connection, never a wrong one; merging them + /// would require resolving `~/.ssh/config` here, and getting *that* wrong + /// would point two machines at one cache. + pub fn connection_key(&self) -> String { + match self { + RemoteTarget::Profile { id } => format!("ssh-profile:{id}"), + RemoteTarget::Alias { alias } => format!("ssh-alias:{alias}"), + RemoteTarget::Direct { user, host, port } => { + format!("ssh-direct:{user}@{}:{port}", host.to_ascii_lowercase()) + } + RemoteTarget::Wsl { distro } => format!("wsl:{distro}"), + RemoteTarget::LocalStdio { program, args } => { + format!("local-stdio:{program} {}", args.join(" ")) + } + } + } + + /// The in-process id this target resolves to. + /// + /// This is the **only** bridge between the persisted world and the runtime + /// one: `RemoteRef` is what survives a restart, `HostId` is what the + /// in-memory tables key on, and this function is how you get from the first + /// to the second. There is deliberately no inverse — an id is a hash, and a + /// structure that wanted to persist "which host" must persist a + /// [`RemoteTarget`]. + pub fn host_id(&self) -> crate::host::HostId { + crate::host::HostId::from_connection_key(&self.connection_key()) + } +} + +impl std::fmt::Display for RemoteTarget { + /// A label for a status bar or a picker row. A profile shows as its uuid + /// because the name lives in the profile store, which this type + /// deliberately does not reach into. + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + RemoteTarget::Profile { id } => write!(f, "{id}"), + RemoteTarget::Alias { alias } => write!(f, "{alias}"), + RemoteTarget::Direct { user, host, port } => { + if !user.is_empty() { + write!(f, "{user}@")?; + } + write!(f, "{host}")?; + if *port != 22 { + write!(f, ":{port}")?; + } + Ok(()) + } + RemoteTarget::Wsl { distro } => write!(f, "wsl:{distro}"), + // The path, not the argv: this is a status-bar label, and the + // arguments are `--stdio` boilerplate that says nothing useful. + RemoteTarget::LocalStdio { program, .. } => { + let name = std::path::Path::new(program) + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_else(|| program.clone()); + write!(f, "local:{name}") + } + } + } +} + +/// A workspace that lives on another machine: which machine, and which +/// workspace over there. +/// +/// The `workspace` id is the **remote's**, minted once and then used as the key +/// into that machine's `~/.local/share/tty7/workspaces.json` +/// ([`crate::core::workspace_store`]). A client-side [`Workspace`] carrying one +/// of these is a *view*, not the record: its `session` is left empty until the +/// layout is pulled from the remote, which owns it (design §10). +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct RemoteRef { + /// Which machine, in terms of a configuration that already exists. + pub target: RemoteTarget, + /// The workspace's id **on that machine**. + pub workspace: WorkspaceId, +} + +impl RemoteRef { + pub fn new(target: RemoteTarget, workspace: WorkspaceId) -> RemoteRef { + RemoteRef { target, workspace } + } + + /// The id of the machine this points at. Two refs to different workspaces + /// on one box answer the same id. + pub fn host_id(&self) -> crate::host::HostId { + self.target.host_id() + } + + /// The remote store's key for this workspace — what + /// [`ControlRequest::WorkspaceGet`](crate::daemon::control::ControlRequest::WorkspaceGet) + /// and friends carry. + pub fn store_key(&self) -> String { + self.workspace.to_string() + } +} + +/// A persistent workspace: a named group of tabs that a window can open, close, +/// and reopen later. Closing its window is a *detach* — the panes keep running +/// in the daemon and the entry stays here with `open: false`, which is what the +/// home-page picker lists. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Workspace { + #[serde(default)] + pub id: WorkspaceId, + /// User-set name from "Rename Workspace". `None` falls back to + /// [`Workspace::display_name`], derived from the tabs' repo/cwd. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub name: Option, + #[serde(default)] + pub session: Session, + /// Geometry this workspace's window last occupied, so reopening it lands + /// where the user left it rather than at the shared default. `None` for a + /// workspace that has never been on screen. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub window: Option, + /// Whether a window was showing this workspace at quit. Launch reopens + /// exactly the `open` ones; the rest wait in the picker. + #[serde(default)] + pub open: bool, + /// Unix seconds when this workspace was last focused, for "2 minutes ago" + /// in the picker and for ordering it. 0 == never recorded. + #[serde(default)] + pub last_active: u64, + /// The machine this workspace's panes and files live on. `None` means this + /// one, **and means it identically to every build that predates the field**: + /// a `session.json` written before this existed decodes with `None` + /// throughout, i.e. all-local, which is the behaviour it had (design §10). + /// + /// A `Some` entry is a *view* of a record that lives over there. Its + /// `session` is empty until the layout is pulled from the remote's own + /// store; `window` and `open` stay here, because they are this client's + /// view state and closing a window at the office must not hide the + /// workspace from the laptop at home. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub host: Option, +} + +impl Default for Workspace { + fn default() -> Self { + Self { + id: WorkspaceId::new(), + name: None, + session: Session::default(), + window: None, + open: true, + last_active: now_secs(), + host: None, + } + } +} + +impl Workspace { + /// Wrap a bare session as a brand-new open workspace. + pub fn from_session(session: Session) -> Self { + Self { + session, + ..Self::default() + } + } + + /// What to show in the picker and the window title: the user-set name if + /// any, else the repository most of its tabs live in, else the first tab's + /// directory, else a generic fallback. Derived rather than stored so a + /// workspace that `cd`s into a project stops being "Untitled" on its own. + pub fn display_name(&self) -> String { + if let Some(name) = self + .name + .as_ref() + .map(|n| n.trim()) + .filter(|n| !n.is_empty()) + { + return name.to_string(); + } + if let Some(repo) = self.dominant_repo() { + if let Some(base) = basename(&repo) { + return base; + } + } + if let Some(cwd) = self.first_cwd() { + if let Some(base) = basename(&cwd) { + return base; + } + } + "Untitled".to_string() + } + + /// The repo root the most tabs belong to — the workspace's centre of + /// gravity for naming. Ties break toward the earliest tab, matching the + /// order the user sees in the sidebar. + pub fn dominant_repo(&self) -> Option { + let mut counts: Vec<(PathBuf, usize)> = Vec::new(); + for group in self + .session + .tabs + .iter() + .filter_map(|t| t.sidebar_group.as_ref()) + { + match counts.iter_mut().find(|(path, _)| path == group) { + Some((_, n)) => *n += 1, + None => counts.push((group.clone(), 1)), + } + } + counts + .into_iter() + .max_by_key(|(_, n)| *n) + .map(|(path, _)| path) + } + + /// The first saved cwd anywhere in the tab tree, used for naming and for + /// the picker's dim subtitle line. + pub fn first_cwd(&self) -> Option { + self.session + .tabs + .iter() + .find_map(|tab| first_leaf_cwd(&tab.pane)) + } + + /// Total leaf terminals across every tab — the picker's "3 panes" count. + pub fn pane_count(&self) -> usize { + self.session.tabs.iter().map(|t| leaf_count(&t.pane)).sum() + } + + /// Every daemon pane id this workspace claims, for the cross-window + /// uniqueness check on restore (two windows attaching one pane would let + /// the second silently steal the first's stream). + pub fn pane_ids(&self) -> Vec { + let mut out = Vec::new(); + for tab in &self.session.tabs { + collect_pane_ids(&tab.pane, &mut out); + } + out + } + + /// Stamp this workspace as just-focused. + pub fn touch(&mut self) { + self.last_active = now_secs(); + } + + // ----- the local / remote split ---------------------------------------- + + /// A client-side entry for a workspace that lives on another machine. + /// + /// The `session` is left empty on purpose: the remote's + /// `~/.local/share/tty7/workspaces.json` is the authority for the layout, + /// and it is pulled on connect. Filling it in from a stale local guess would + /// make the window flash a layout the machine has since moved on from. + pub fn on_remote(host: RemoteRef) -> Workspace { + Workspace { + host: Some(host), + ..Workspace::default() + } + } + + /// Whether this workspace lives on another machine. + pub fn is_remote(&self) -> bool { + self.host.is_some() + } + + /// The id of the machine this workspace's panes are on. + /// + /// This is the qualifier that turns a bare path or a bare `pane_id` into + /// something globally meaningful: `pane_id` is unique only within one remote + /// server, so the client's pane identity is `(host_id, pane_id)`, and a + /// repo root is unique only within one machine, so a sidebar group key is + /// `(host_id, sidebar_group)`. Storing it once per workspace rather than + /// once per pane is exactly what the one-window-one-machine rule buys. + pub fn host_id(&self) -> crate::host::HostId { + match &self.host { + Some(r) => r.host_id(), + None => crate::host::HostId::LOCAL, + } + } + + /// The record the **remote** owns, as the JSON that crosses the wire in a + /// [`WorkspacePut`](crate::daemon::control::ControlRequest::WorkspacePut). + /// + /// Design §10's storage split, executable rather than aspirational: what + /// stays here is `window`, `open` and `host` — this client's view state — + /// and what goes over there is everything that is a fact about the machine. + /// [`REMOTE_OWNED_FIELDS`] pins the split, and a test fails if a new field + /// is added without a decision about which side it belongs to. + pub fn to_remote_json(&self) -> serde_json::Value { + let mut value = serde_json::to_value(self).unwrap_or(serde_json::Value::Null); + if let Some(obj) = value.as_object_mut() { + obj.retain(|k, _| REMOTE_OWNED_FIELDS.contains(&k.as_str())); + } + value + } + + /// Merge an authoritative record pulled from a remote store into this entry. + /// + /// Touches only the remote-owned fields. `id`, `host`, `window` and `open` + /// are left exactly as they were — the first two because the client's entry + /// is the thing being *pointed* by them, the last two because they are this + /// machine's view state and the remote has no opinion about them. + pub fn apply_remote_json(&mut self, value: &serde_json::Value) -> serde_json::Result<()> { + let record: RemoteRecord = serde_json::from_value(value.clone())?; + self.name = record.name; + self.session = record.session; + self.last_active = record.last_active; + Ok(()) + } +} + +/// The `Workspace` fields the **remote** is the authority for (design §10). +/// Everything else is client-side view state and never leaves this machine. +/// +/// A `Workspace` field that is in neither list is a bug: it would be dropped by +/// [`Workspace::to_remote_json`] and silently lost on the next pull. The test +/// `the_storage_split_covers_every_workspace_field` is what makes that a red +/// build rather than a data-loss report. +pub const REMOTE_OWNED_FIELDS: &[&str] = &["id", "name", "session", "last_active"]; + +/// The client-side view state, which stays in this machine's `session.json`. +pub const CLIENT_OWNED_FIELDS: &[&str] = &["window", "open", "host"]; + +/// The remote-owned half of a [`Workspace`], for reading a record back. +/// +/// Every field defaults: a record written by a *newer* client carries fields +/// this build has never heard of (serde ignores them), and one written by an +/// older client is missing fields this build expects. Neither may fail the pull +/// — a workspace that will not decode is a workspace the user cannot open. +#[derive(Deserialize)] +struct RemoteRecord { + #[serde(default)] + name: Option, + #[serde(default)] + session: Session, + #[serde(default)] + last_active: u64, +} + +/// The whole `session.json`: every workspace tty7 knows about, plus which one +/// had focus at quit. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct Workspaces { + /// Note: deliberately *not* `#[serde(default)]` at the struct level — the + /// presence of this key is what distinguishes a new-format file from the + /// legacy flat `{active, tabs}` one. See [`Workspaces::decode`]. + pub workspaces: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub active: Option, +} + +impl Workspaces { + /// Load every saved workspace. Returns `None` when the file is absent or + /// unreadable (normal first run), and `None` with a warning when it fails + /// to parse — never panics. + pub fn load() -> Option { + let path = Self::path()?; + let text = std::fs::read_to_string(&path).ok()?; + match Self::decode(&text) { + Ok(loaded) => Some(loaded), + Err(e) => { + log::warn!( + "failed to parse session at {}: {e}; ignoring", + path.display() + ); + None + } + } + } + + /// Parse either format. A file written by any build with multi-window + /// support has a `workspaces` array; anything else is a pre-multi-window + /// `{active, tabs}` session, which migrates to a single open workspace so + /// upgrading users keep their tabs (and their attached daemon panes). + pub fn decode(text: &str) -> Result { + let value: serde_json::Value = serde_json::from_str(crate::core::config::strip_bom(text))?; + if value.get("workspaces").is_some() { + return serde_json::from_value(value); + } + let legacy: Session = serde_json::from_value(value)?; + Ok(Self::single(Workspace::from_session(legacy))) + } + + /// A one-workspace set, used by the legacy migration and by first run. + pub fn single(workspace: Workspace) -> Self { + Self { + active: Some(workspace.id), + workspaces: vec![workspace], + } + } + + pub fn get(&self, id: WorkspaceId) -> Option<&Workspace> { + self.workspaces.iter().find(|w| w.id == id) + } + + pub fn get_mut(&mut self, id: WorkspaceId) -> Option<&mut Workspace> { + self.workspaces.iter_mut().find(|w| w.id == id) + } + + /// The workspaces to reopen at launch, in their saved order. Empty when + /// the user quit with every window closed — launch then shows one window on + /// the picker rather than guessing. + pub fn open_workspaces(&self) -> impl Iterator { + self.workspaces.iter().filter(|w| w.open) + } + + /// Closed workspaces for the home-page picker, most recently active first. + pub fn closed_workspaces(&self) -> Vec<&Workspace> { + let mut closed: Vec<&Workspace> = self.workspaces.iter().filter(|w| !w.open).collect(); + closed.sort_by(|a, b| b.last_active.cmp(&a.last_active)); + closed + } + + /// Drop pane ids that appear in more than one workspace *on the same + /// machine*, keeping the claim of whichever workspace was active most + /// recently. A duplicate would have two windows attach the same daemon + /// pane, and the daemon's single subscriber means the loser's terminal goes + /// silently dead — so this runs on every load, before any window is built. + /// + /// **Scoped per machine, because a pane id only means anything within one + /// daemon.** Every daemon hands out 1, 2, 3…, so a laptop and a build box + /// both having a pane 1 is the normal case, not a conflict. Deduping + /// globally would make the remote workspace forfeit a claim on a pane that + /// is alive and well on its own machine — orphaning a live session over a + /// collision that never existed. + /// + /// Returns the number of claims dropped (0 in the healthy case). + pub fn dedupe_pane_ids(&mut self) -> usize { + let mut order: Vec<(usize, u64)> = self + .workspaces + .iter() + .enumerate() + .map(|(i, w)| (i, w.last_active)) + .collect(); + // Most recently active first: it keeps its claim, earlier ones yield. + order.sort_by(|a, b| b.1.cmp(&a.1)); + + // One `seen` set per machine. `HostId` is process-local, but this only + // has to be self-consistent within the single pass below. + let mut seen: std::collections::HashMap< + crate::host::HostId, + std::collections::HashSet, + > = std::collections::HashMap::new(); + let mut dropped = 0; + for (index, _) in order { + let workspace = &mut self.workspaces[index]; + let host = workspace.host_id(); + let seen_here = seen.entry(host).or_default(); + for tab in &mut workspace.session.tabs { + dropped += drop_duplicate_pane_ids(&mut tab.pane, seen_here); + } + } + dropped + } + + /// Persist as JSON, creating the parent directory if needed. Any + /// IO/serialization error is logged and swallowed — the app must never + /// crash or stall over session bookkeeping. + pub fn save(&self) { + let Some(path) = Self::path() else { + return; + }; + if let Some(parent) = path.parent() { + if let Err(e) = std::fs::create_dir_all(parent) { + log::warn!("failed to create session dir {}: {e}", parent.display()); + return; + } + } + let json = match serde_json::to_string_pretty(self) { + Ok(j) => j, + Err(e) => { + log::warn!("failed to serialize session: {e}"); + return; + } + }; + if let Err(e) = crate::core::config::write_atomic(&path, json.as_bytes()) { + log::warn!("failed to write session to {}: {e}", path.display()); + } + } + + /// `~/.config/tty7/session.json`, alongside `config.json`. + fn path() -> Option { + crate::core::config::config_path("session.json") + } +} + +/// Seconds since the Unix epoch, or 0 if the clock is before it (which only a +/// badly misconfigured machine reports — "never active" is a fine reading). +fn now_secs() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) +} + +/// Last path component as a display string, skipping a bare `/` or a path that +/// ends in `..`. +fn basename(path: &std::path::Path) -> Option { + path.file_name() + .and_then(|n| n.to_str()) + .map(|s| s.to_string()) + .filter(|s| !s.is_empty()) +} + +fn first_leaf_cwd(pane: &SessionPane) -> Option { + match pane { + SessionPane::Leaf { cwd, .. } => cwd.clone(), + SessionPane::Split { a, b, .. } => first_leaf_cwd(a).or_else(|| first_leaf_cwd(b)), + } +} + +fn leaf_count(pane: &SessionPane) -> usize { + match pane { + SessionPane::Leaf { .. } => 1, + SessionPane::Split { a, b, .. } => leaf_count(a) + leaf_count(b), + } +} + +fn collect_pane_ids(pane: &SessionPane, out: &mut Vec) { + match pane { + SessionPane::Leaf { pane_id, .. } => out.extend(pane_id), + SessionPane::Split { a, b, .. } => { + collect_pane_ids(a, out); + collect_pane_ids(b, out); + } + } +} + +/// Blank any `pane_id` already claimed by an earlier-visited workspace. A +/// blanked leaf still restores — it just spawns a fresh shell in its saved cwd, +/// the same path a session from before the daemon existed takes. +fn drop_duplicate_pane_ids( + pane: &mut SessionPane, + seen: &mut std::collections::HashSet, +) -> usize { + match pane { + SessionPane::Leaf { pane_id, .. } => match *pane_id { + Some(id) if !seen.insert(id) => { + log::warn!("workspace claims pane {id} twice; dropping the duplicate claim"); + *pane_id = None; + 1 + } + _ => 0, + }, + SessionPane::Split { a, b, .. } => { + drop_duplicate_pane_ids(a, seen) + drop_duplicate_pane_ids(b, seen) + } + } +} + +/// Helpers for every test that touches the on-disk `session.json`. The +/// config-dir pin is process-wide (`set_config_dir` is first-call-wins), so +/// the file is process-wide too — any test that reads or writes it must hold +/// [`lock_session_file`] across the whole read/write sequence, or parallel +/// tests clobber each other's session. +#[cfg(test)] +pub(crate) mod test_support { + use std::path::PathBuf; + use std::sync::{Mutex, MutexGuard}; + + static SESSION_FILE: Mutex<()> = Mutex::new(()); + + /// Serialize access to the shared `session.json`. + pub(crate) fn lock_session_file() -> MutexGuard<'static, ()> { + // A poisoned lock just means another test failed mid-sequence; every + // holder rewrites the file from scratch, so the state is still sound. + SESSION_FILE.lock().unwrap_or_else(|e| e.into_inner()) + } + + /// Pin the process config dir at a shared temp location so `save`/`load` + /// (which resolve `session.json` under it) never touch the real `~/.config`. + /// `set_config_dir` is first-call-wins; every caller computes the same path. + pub(crate) fn pin_config_dir() -> PathBuf { + let dir = std::env::temp_dir().join(format!("tty7-covtest-{}", std::process::id())); + std::fs::create_dir_all(&dir).ok(); + crate::core::config::set_config_dir(dir.clone()); + dir + } +} + +#[cfg(test)] +mod tests { + use super::test_support::{lock_session_file, pin_config_dir}; + use super::*; + + #[test] + fn session_json_round_trips_nested_tree() { + let session = Session { + active: 1, + tabs: vec![ + SessionTab { + name: Some("build".into()), + sidebar_group: None, + pane: SessionPane::Leaf { + cwd: Some(PathBuf::from("/work")), + pane_id: Some(7), + ssh_spec: None, + agent: None, + agent_session_id: None, + agent_launch_argv: None, + }, + }, + SessionTab { + name: None, + sidebar_group: None, + pane: SessionPane::Split { + axis: SessionAxis::Vertical, + ratio: 0.3, + a: Box::new(SessionPane::Leaf { + cwd: None, + pane_id: None, + ssh_spec: None, + agent: None, + agent_session_id: None, + agent_launch_argv: None, + }), + b: Box::new(SessionPane::Leaf { + cwd: Some(PathBuf::from("/tmp")), + pane_id: Some(9), + ssh_spec: None, + agent: None, + agent_session_id: None, + agent_launch_argv: None, + }), + }, + }, + ], + }; + let json = serde_json::to_string(&session).unwrap(); + let back: Session = serde_json::from_str(&json).unwrap(); + assert_eq!(back.active, 1); + assert_eq!(back.tabs.len(), 2); + assert!(matches!( + back.tabs[0].pane, + SessionPane::Leaf { + pane_id: Some(7), + .. + } + )); + match &back.tabs[1].pane { + SessionPane::Split { ratio, .. } => assert!((ratio - 0.3).abs() < 1e-6), + _ => panic!("expected a split"), + } + } + + #[test] + fn leaf_agent_resume_fields_round_trip_and_default() { + // Round trip: the agent + native session id survive serialization. + let leaf = SessionPane::Leaf { + cwd: None, + pane_id: None, + ssh_spec: None, + agent: Some(crate::core::cli_agent::CLIAgent::Claude), + agent_session_id: Some("abc-123".into()), + agent_launch_argv: Some(vec![ + "claude".into(), + "--dangerously-skip-permissions".into(), + ]), + }; + let back: SessionPane = + serde_json::from_str(&serde_json::to_string(&leaf).unwrap()).unwrap(); + match back { + SessionPane::Leaf { + agent, + agent_session_id, + agent_launch_argv, + .. + } => { + assert_eq!(agent, Some(crate::core::cli_agent::CLIAgent::Claude)); + assert_eq!(agent_session_id.as_deref(), Some("abc-123")); + assert_eq!( + agent_launch_argv.as_deref(), + Some( + &[ + "claude".to_string(), + "--dangerously-skip-permissions".to_string() + ][..] + ) + ); + } + _ => panic!("expected leaf"), + } + // A session written before these fields existed decodes with `None`s. + let old: SessionPane = + serde_json::from_str(r#"{"Leaf":{"cwd":"/x","pane_id":3}}"#).unwrap(); + assert!(matches!( + old, + SessionPane::Leaf { + agent: None, + agent_session_id: None, + agent_launch_argv: None, + .. + } + )); + } + + #[test] + fn a_utf8_bom_does_not_discard_the_session() { + // `Session::load` treats a parse error as "no session", so a BOM on a + // hand-edited `session.json` doesn't warn — it drops every workspace + // and opens on the home page as if nothing had been saved. + // Legacy `{active, tabs}` shape, so this also covers the migration path. + let decoded = Workspaces::decode( + "\u{FEFF}{\"active\": 0, \"tabs\": [{\"pane\": {\"Leaf\": {\"cwd\": \"/work\"}}}]}", + ) + .expect("a BOM'd session still decodes"); + let tabs = &decoded + .workspaces + .first() + .expect("migrated workspace") + .session + .tabs; + assert_eq!(tabs.len(), 1); + } + + #[test] + fn session_defaults_fill_missing_fields() { + // An empty object → default (active 0, no tabs). + let s: Session = serde_json::from_str("{}").unwrap(); + assert_eq!(s.active, 0); + assert!(s.tabs.is_empty()); + + // A split without a ratio falls back to the 0.5 default, and a leaf + // without cwd/pane_id decodes with `None`s. + let pane: SessionPane = serde_json::from_str( + r#"{"Split":{"axis":"Horizontal","a":{"Leaf":{}},"b":{"Leaf":{}}}}"#, + ) + .unwrap(); + match pane { + SessionPane::Split { ratio, .. } => assert_eq!(ratio, 0.5), + _ => panic!("expected split"), + } + } + + #[test] + fn save_then_load_recovers_the_session() { + let _file = lock_session_file(); + pin_config_dir(); + let session = Session { + active: 0, + tabs: vec![SessionTab { + name: Some("main".into()), + sidebar_group: None, + pane: SessionPane::Leaf { + cwd: Some(PathBuf::from("/home/u")), + pane_id: Some(1), + ssh_spec: None, + agent: None, + agent_session_id: None, + agent_launch_argv: None, + }, + }], + }; + Workspaces::single(Workspace::from_session(session)).save(); + let loaded = Workspaces::load().expect("a saved session should load back"); + let only = &loaded.workspaces[0]; + assert_eq!(only.session.tabs.len(), 1); + assert_eq!(only.session.tabs[0].name.as_deref(), Some("main")); + assert_eq!(loaded.active, Some(only.id)); + } + + // ── Workspace layer ───────────────────────────────────────────────────── + + /// Build a leaf with the given cwd + pane id; the agent/ssh fields are + /// irrelevant to every workspace-layer test. + fn leaf(cwd: Option<&str>, pane_id: Option) -> SessionPane { + SessionPane::Leaf { + cwd: cwd.map(PathBuf::from), + pane_id, + ssh_spec: None, + agent: None, + agent_session_id: None, + agent_launch_argv: None, + } + } + + fn tab(pane: SessionPane, group: Option<&str>) -> SessionTab { + SessionTab { + name: None, + sidebar_group: group.map(PathBuf::from), + pane, + } + } + + fn workspace(tabs: Vec) -> Workspace { + Workspace::from_session(Session { active: 0, tabs }) + } + + #[test] + fn legacy_flat_session_migrates_to_one_open_workspace() { + // Exactly the shape every pre-multi-window build wrote. + let legacy = r#"{"active":1,"tabs":[ + {"name":"build","pane":{"Leaf":{"cwd":"/work","pane_id":7}}}, + {"name":null,"pane":{"Leaf":{"cwd":"/tmp","pane_id":9}}} + ]}"#; + let loaded = Workspaces::decode(legacy).expect("legacy session should migrate"); + assert_eq!(loaded.workspaces.len(), 1); + let only = &loaded.workspaces[0]; + // The tabs — and crucially the pane ids, which are live daemon panes — + // survive the upgrade, so an updating user doesn't lose their shells. + assert_eq!(only.session.active, 1); + assert_eq!(only.session.tabs.len(), 2); + assert_eq!(only.pane_ids(), vec![7, 9]); + // It reopens on the next launch, matching pre-upgrade behavior. + assert!(only.open); + assert_eq!(loaded.active, Some(only.id)); + } + + #[test] + fn empty_and_absent_shapes_decode_without_losing_data() { + // `{}` is the home-page state an older build wrote: zero tabs, still valid. + let empty = Workspaces::decode("{}").expect("empty object decodes"); + assert_eq!(empty.workspaces.len(), 1); + assert!(empty.workspaces[0].session.tabs.is_empty()); + // A new-format file with no workspaces at all stays empty rather than + // being mistaken for a legacy session and gaining a phantom entry. + let none = Workspaces::decode(r#"{"workspaces":[]}"#).expect("new format decodes"); + assert!(none.workspaces.is_empty()); + } + + #[test] + fn new_format_round_trips_through_json() { + let mut ws = workspace(vec![tab(leaf(Some("/work"), Some(3)), Some("/work"))]); + ws.name = Some("api".into()); + ws.open = false; + ws.last_active = 1_700_000_000; + let id = ws.id; + let all = Workspaces { + active: Some(id), + workspaces: vec![ws], + }; + let back = Workspaces::decode(&serde_json::to_string(&all).unwrap()).unwrap(); + let only = &back.workspaces[0]; + assert_eq!(only.id, id, "workspace identity must survive a restart"); + assert_eq!(only.name.as_deref(), Some("api")); + assert!(!only.open); + assert_eq!(only.last_active, 1_700_000_000); + assert_eq!(back.active, Some(id)); + } + + #[test] + fn display_name_prefers_user_name_then_repo_then_cwd() { + // No name, no repo group: fall back to the first leaf's directory. + let ws = workspace(vec![tab(leaf(Some("/home/u/scratch"), None), None)]); + assert_eq!(ws.display_name(), "scratch"); + + // A repo group wins over the cwd — it's the workspace's real subject. + let ws = workspace(vec![tab( + leaf(Some("/repo/tty7/src"), None), + Some("/repo/tty7"), + )]); + assert_eq!(ws.display_name(), "tty7"); + + // The majority repo wins when tabs straddle two checkouts. + let ws = workspace(vec![ + tab(leaf(None, None), Some("/repo/other")), + tab(leaf(None, None), Some("/repo/tty7")), + tab(leaf(None, None), Some("/repo/tty7")), + ]); + assert_eq!(ws.display_name(), "tty7"); + + // An explicit name beats everything derived. + let mut ws = workspace(vec![tab( + leaf(Some("/repo/tty7"), None), + Some("/repo/tty7"), + )]); + ws.name = Some(" Release prep ".into()); + assert_eq!(ws.display_name(), "Release prep"); + + // Nothing to go on at all. + assert_eq!(workspace(vec![]).display_name(), "Untitled"); + // A whitespace-only name is treated as unset rather than rendering blank. + let mut ws = workspace(vec![tab(leaf(Some("/x/proj"), None), None)]); + ws.name = Some(" ".into()); + assert_eq!(ws.display_name(), "proj"); + } + + #[test] + fn pane_and_tab_counts_walk_the_split_tree() { + let ws = workspace(vec![ + tab(leaf(Some("/a"), Some(1)), None), + tab( + SessionPane::Split { + axis: SessionAxis::Vertical, + ratio: 0.5, + a: Box::new(leaf(Some("/b"), Some(2))), + b: Box::new(leaf(None, Some(3))), + }, + None, + ), + ]); + assert_eq!(ws.pane_count(), 3); + assert_eq!(ws.pane_ids(), vec![1, 2, 3]); + assert_eq!(ws.first_cwd(), Some(PathBuf::from("/a"))); + } + + #[test] + fn dedupe_pane_ids_keeps_the_most_recently_active_claim() { + // Two workspaces both claim pane 5 — the crash/hand-edit case. The + // stale one must yield, or its window silently steals the live one's + // stream when both attach (the daemon has a single subscriber). + let mut stale = workspace(vec![tab(leaf(Some("/old"), Some(5)), None)]); + stale.last_active = 100; + let mut fresh = workspace(vec![tab(leaf(Some("/new"), Some(5)), None)]); + fresh.last_active = 200; + let (stale_id, fresh_id) = (stale.id, fresh.id); + + let mut all = Workspaces { + active: Some(fresh_id), + workspaces: vec![stale, fresh], + }; + assert_eq!(all.dedupe_pane_ids(), 1); + + // The recent one keeps pane 5; the stale one drops to a fresh spawn in + // its saved cwd (cwd is preserved — only the id is cleared). + assert_eq!(all.get(fresh_id).unwrap().pane_ids(), vec![5]); + assert!(all.get(stale_id).unwrap().pane_ids().is_empty()); + assert_eq!( + all.get(stale_id).unwrap().first_cwd(), + Some(PathBuf::from("/old")) + ); + } + + /// A pane id is only unique within one daemon, so the same number on two + /// machines is not a collision. Deduping globally would make the remote + /// workspace forfeit a claim on a pane that is alive on its own box — + /// orphaning a live session over a conflict that never existed. + #[test] + fn dedupe_pane_ids_is_scoped_to_one_machine() { + let mut local = workspace(vec![tab(leaf(Some("/local"), Some(1)), None)]); + local.last_active = 200; + let mut remote = workspace(vec![tab(leaf(Some("/remote"), Some(1)), None)]); + remote.last_active = 100; // older, so a global dedupe would drop *this* one + remote.host = Some(RemoteRef { + target: RemoteTarget::Alias { + alias: "build-box".into(), + }, + workspace: WorkspaceId::new(), + }); + let (local_id, remote_id) = (local.id, remote.id); + + let mut all = Workspaces { + active: Some(local_id), + workspaces: vec![local, remote], + }; + assert_eq!(all.dedupe_pane_ids(), 0, "different machines never collide"); + assert_eq!(all.get(local_id).unwrap().pane_ids(), vec![1]); + assert_eq!( + all.get(remote_id).unwrap().pane_ids(), + vec![1], + "the remote keeps its claim on its own daemon's pane 1" + ); + + // …and two workspaces on the *same* remote machine still dedupe. + let host = RemoteRef { + target: RemoteTarget::Alias { + alias: "build-box".into(), + }, + workspace: WorkspaceId::new(), + }; + let mut older = workspace(vec![tab(leaf(Some("/a"), Some(7)), None)]); + older.last_active = 100; + older.host = Some(host.clone()); + let mut newer = workspace(vec![tab(leaf(Some("/b"), Some(7)), None)]); + newer.last_active = 200; + newer.host = Some(host); + let (older_id, newer_id) = (older.id, newer.id); + + let mut same_box = Workspaces { + active: Some(newer_id), + workspaces: vec![older, newer], + }; + assert_eq!(same_box.dedupe_pane_ids(), 1); + assert_eq!(same_box.get(newer_id).unwrap().pane_ids(), vec![7]); + assert!(same_box.get(older_id).unwrap().pane_ids().is_empty()); + } + + #[test] + fn dedupe_pane_ids_is_a_noop_on_healthy_sessions() { + let mut all = Workspaces { + active: None, + workspaces: vec![ + workspace(vec![tab(leaf(Some("/a"), Some(1)), None)]), + workspace(vec![tab(leaf(Some("/b"), Some(2)), None)]), + ], + }; + assert_eq!(all.dedupe_pane_ids(), 0); + assert_eq!(all.workspaces[0].pane_ids(), vec![1]); + assert_eq!(all.workspaces[1].pane_ids(), vec![2]); + } + + #[test] + fn dedupe_pane_ids_catches_a_duplicate_within_one_workspace() { + // Same guarantee inside a single workspace: a split that somehow ended + // up with the same pane in both halves would deadlock the same way. + let mut all = Workspaces { + active: None, + workspaces: vec![workspace(vec![ + tab(leaf(Some("/a"), Some(1)), None), + tab(leaf(Some("/b"), Some(1)), None), + ])], + }; + assert_eq!(all.dedupe_pane_ids(), 1); + assert_eq!(all.workspaces[0].pane_ids(), vec![1]); + } + + // ── Remote workspaces (M5) ────────────────────────────────────────────── + + /// A real-shaped `session.json` from before `host` existed, written by the + /// build that shipped multi-window. **The hard requirement of the whole + /// field**: every workspace in it is local, and every derived answer is + /// exactly what it was — an upgrading user's file must not acquire a + /// meaning it did not have. + const LEGACY_SESSION_JSON: &str = r#"{ + "workspaces": [ + { + "id": "6a8f2a1e-1c1b-4f7a-9d3e-2b5c8e4a7f01", + "name": "tty7", + "session": { + "active": 1, + "tabs": [ + { + "name": "build", + "pane": {"Leaf": {"cwd": "/Users/me/repo/tty7", "pane_id": 41}}, + "sidebar_group": "/Users/me/repo/tty7" + }, + { + "name": null, + "pane": {"Split": { + "axis": "Vertical", + "ratio": 0.35, + "a": {"Leaf": {"cwd": "/Users/me/repo/tty7/src", "pane_id": 42, + "agent": "Claude", "agent_session_id": "s-9"}}, + "b": {"Leaf": {"cwd": "/Users/me/repo/tty7", "pane_id": 43}} + }}, + "sidebar_group": "/Users/me/repo/tty7" + } + ] + }, + "window": {"x": 120.0, "y": 64.0, "width": 1440.0, "height": 900.0}, + "open": true, + "last_active": 1753600000 + }, + { + "id": "7b9e3b2f-2d2c-4a8b-8e4f-3c6d9f5b8a12", + "session": {"active": 0, "tabs": [ + {"pane": {"Leaf": {"cwd": "/Users/me/scratch"}}} + ]}, + "open": false, + "last_active": 1753500000 + } + ], + "active": "6a8f2a1e-1c1b-4f7a-9d3e-2b5c8e4a7f01" + }"#; + + #[test] + fn an_old_session_json_is_all_local_and_behaves_identically() { + let loaded = Workspaces::decode(LEGACY_SESSION_JSON).expect("an old session must decode"); + assert_eq!(loaded.workspaces.len(), 2); + + for ws in &loaded.workspaces { + assert!(ws.host.is_none(), "a file without `host` decodes as local"); + assert!(!ws.is_remote()); + assert_eq!( + ws.host_id(), + crate::host::HostId::LOCAL, + "no `host` must mean this machine, not a derived id" + ); + } + + // Every derived answer is what the pre-`host` build gave. + let first = &loaded.workspaces[0]; + assert_eq!(first.display_name(), "tty7"); + assert_eq!(first.pane_ids(), vec![41, 42, 43]); + assert_eq!(first.pane_count(), 3); + assert_eq!( + first.dominant_repo(), + Some(PathBuf::from("/Users/me/repo/tty7")) + ); + assert!(first.open); + assert_eq!(first.last_active, 1_753_600_000); + assert!(first.window.is_some()); + assert_eq!(loaded.workspaces[1].display_name(), "scratch"); + assert!(!loaded.workspaces[1].open); + assert_eq!(loaded.active, Some(loaded.workspaces[0].id)); + + // And writing it back does not add a `host` key: a local workspace's + // serialization is byte-for-byte what it always was, so downgrading to + // an older build is not a one-way door either. + let text = serde_json::to_string(&loaded).unwrap(); + assert!(!text.contains("\"host\""), "{text}"); + // Re-decoding the round trip changes nothing. + let again = Workspaces::decode(&text).unwrap(); + assert_eq!(again.workspaces[0].pane_ids(), vec![41, 42, 43]); + assert!(again.workspaces.iter().all(|w| !w.is_remote())); + } + + /// The legacy flat `{active, tabs}` shape — two formats older — migrates to + /// a local workspace too, not to one with a phantom host. + #[test] + fn the_pre_multi_window_migration_is_local() { + let loaded = + Workspaces::decode(r#"{"active":0,"tabs":[{"pane":{"Leaf":{"cwd":"/w"}}}]}"#).unwrap(); + assert!(!loaded.workspaces[0].is_remote()); + assert_eq!(loaded.workspaces[0].host_id(), crate::host::HostId::LOCAL); + } + + /// The four key formats of contract §4.2, verbatim. These strings are a + /// wire contract in all but name: change one and every workspace on that + /// machine gets a different `HostId` than the connection pool minted. + #[test] + fn connection_keys_match_the_contract_table() { + let uuid = uuid::Uuid::parse_str("6a8f2a1e-1c1b-4f7a-9d3e-2b5c8e4a7f01").unwrap(); + assert_eq!( + RemoteTarget::Profile { id: uuid }.connection_key(), + "ssh-profile:6a8f2a1e-1c1b-4f7a-9d3e-2b5c8e4a7f01" + ); + assert_eq!( + RemoteTarget::Alias { + alias: "devbox".into() + } + .connection_key(), + "ssh-alias:devbox" + ); + assert_eq!( + RemoteTarget::direct("me", "box.local", 22).connection_key(), + "ssh-direct:me@box.local:22" + ); + assert_eq!( + RemoteTarget::direct("me", "box.local", 2222).connection_key(), + "ssh-direct:me@box.local:2222" + ); + assert_eq!( + RemoteTarget::Wsl { + distro: "Ubuntu".into() + } + .connection_key(), + "wsl:Ubuntu" + ); + } + + #[test] + fn direct_targets_normalize_and_reuse_the_quick_connect_parser() { + // The port defaults to 22, the scheme is optional, and the host folds + // case — all of it the connection manager's existing behaviour. + assert_eq!( + RemoteTarget::parse_direct("ssh://me@Box.Local"), + Some(RemoteTarget::direct("me", "box.local", 22)) + ); + assert_eq!( + RemoteTarget::parse_direct("me@box.local:2222"), + Some(RemoteTarget::direct("me", "box.local", 2222)) + ); + // A hand-edited file with an uppercase host still derives one id. + let shouty = RemoteTarget::Direct { + user: "me".into(), + host: "BOX.LOCAL".into(), + port: 22, + }; + assert_eq!( + shouty.host_id(), + RemoteTarget::direct("me", "box.local", 22).host_id() + ); + // Rejected inputs stay rejected rather than becoming a half-target. + assert_eq!(RemoteTarget::parse_direct(""), None); + assert_eq!(RemoteTarget::parse_direct("me@box:0"), None); + // An alias is *not* case-folded: `ssh Devbox` and `ssh devbox` match + // different stanzas, and so must these. + assert_ne!( + RemoteTarget::Alias { + alias: "Devbox".into() + } + .connection_key(), + RemoteTarget::Alias { + alias: "devbox".into() + } + .connection_key() + ); + } + + /// The dev-only `--stdio` target is a *machine*, not a variation on local: + /// its key is distinct, its id is not [`HostId::LOCAL`], and two different + /// server binaries are two different machines. + /// + /// That last part matters because everything keyed by `HostId` — the + /// connection pool, the git-status cache, the auth queue — would otherwise + /// merge two servers that share nothing. + #[test] + fn a_local_stdio_target_is_its_own_machine() { + let a = RemoteTarget::LocalStdio { + program: "/opt/tty7-server".into(), + args: vec!["--stdio".into()], + }; + let b = RemoteTarget::LocalStdio { + program: "/tmp/other-server".into(), + args: vec!["--stdio".into()], + }; + assert_eq!(a.connection_key(), "local-stdio:/opt/tty7-server --stdio"); + assert_ne!(a.host_id(), b.host_id()); + assert!( + !a.host_id().is_local(), + "a routed target is never the local host" + ); + // The label is the binary's name, not the argv: the flags say nothing a + // status bar can use. + assert_eq!(a.to_string(), "local:tty7-server"); + } + + /// The granularity the connection pool depends on: one box, one id, however + /// many workspaces — and never [`HostId::LOCAL`]. + #[test] + fn workspaces_on_one_box_share_a_host_id() { + let target = RemoteTarget::Alias { + alias: "devbox".into(), + }; + let a = Workspace::on_remote(RemoteRef::new(target.clone(), WorkspaceId::new())); + let b = Workspace::on_remote(RemoteRef::new(target.clone(), WorkspaceId::new())); + assert_ne!( + a.host.as_ref().unwrap().workspace, + b.host.as_ref().unwrap().workspace + ); + assert_eq!(a.host_id(), b.host_id(), "same machine, one HostId"); + assert!(!a.host_id().is_local()); + + // A different machine is a different id. + let other = Workspace::on_remote(RemoteRef::new( + RemoteTarget::Alias { + alias: "other".into(), + }, + WorkspaceId::new(), + )); + assert_ne!(a.host_id(), other.host_id()); + + // And a remote entry starts with no layout: the remote owns it. + assert!(a.session.tabs.is_empty()); + assert_eq!( + a.host.as_ref().unwrap().store_key(), + a.host.as_ref().unwrap().workspace.to_string() + ); + } + + #[test] + fn a_remote_workspace_survives_a_restart() { + let remote_id = WorkspaceId::new(); + let mut ws = Workspace::on_remote(RemoteRef::new( + RemoteTarget::direct("me", "box.local", 2222), + remote_id, + )); + ws.name = Some("api".into()); + ws.open = false; + let all = Workspaces { + active: None, + workspaces: vec![ws], + }; + let text = serde_json::to_string(&all).unwrap(); + let back = Workspaces::decode(&text).unwrap(); + let only = &back.workspaces[0]; + assert!(only.is_remote()); + let host = only.host.as_ref().unwrap(); + assert_eq!(host.workspace, remote_id); + assert_eq!(host.target, RemoteTarget::direct("me", "box.local", 2222)); + assert_eq!(host.target.connection_key(), "ssh-direct:me@box.local:2222"); + } + + /// Every `Workspace` field belongs to exactly one side of design §10's + /// split. A new field that is in neither list would be silently dropped by + /// `to_remote_json` and lost on the next pull, which is data loss that no + /// other test would notice. + #[test] + fn the_storage_split_covers_every_workspace_field() { + let mut ws = workspace(vec![tab(leaf(Some("/w"), Some(1)), Some("/w"))]); + ws.name = Some("named".into()); + ws.window = Some(crate::core::window_state::WindowState { + x: 0.0, + y: 0.0, + width: 800.0, + height: 600.0, + }); + ws.host = Some(RemoteRef::new( + RemoteTarget::Alias { + alias: "devbox".into(), + }, + WorkspaceId::new(), + )); + + let value = serde_json::to_value(&ws).unwrap(); + let mut present: Vec = value + .as_object() + .unwrap() + .keys() + .map(String::from) + .collect(); + present.sort(); + let mut expected: Vec = REMOTE_OWNED_FIELDS + .iter() + .chain(CLIENT_OWNED_FIELDS) + .map(|s| (*s).to_string()) + .collect(); + expected.sort(); + assert_eq!( + present, expected, + "a Workspace field is on neither side of the storage split; decide which \ + machine owns it and add it to REMOTE_OWNED_FIELDS or CLIENT_OWNED_FIELDS" + ); + } + + #[test] + fn the_remote_record_carries_the_layout_and_nothing_local() { + let mut ws = workspace(vec![tab(leaf(Some("/srv/app"), Some(7)), Some("/srv/app"))]); + ws.name = Some("app".into()); + ws.last_active = 1_753_600_000; + ws.open = true; + ws.window = Some(crate::core::window_state::WindowState { + x: 1.0, + y: 2.0, + width: 800.0, + height: 600.0, + }); + ws.host = Some(RemoteRef::new( + RemoteTarget::Alias { + alias: "devbox".into(), + }, + WorkspaceId::new(), + )); + + let record = ws.to_remote_json(); + let obj = record.as_object().unwrap(); + // The machine's facts go over. + assert!(obj.contains_key("session")); + assert_eq!(obj["name"], "app"); + assert_eq!(obj["last_active"], 1_753_600_000u64); + assert_eq!(obj["id"], ws.id.to_string()); + // This client's view state does not — the point of the split. + for k in CLIENT_OWNED_FIELDS { + assert!(!obj.contains_key(*k), "`{k}` must not leave this machine"); + } + + // Pulling it back onto a *different* client's entry updates the layout + // and leaves that client's own view state alone. + let mut mine = Workspace::on_remote(RemoteRef::new( + RemoteTarget::Alias { + alias: "devbox".into(), + }, + ws.id, + )); + mine.open = false; + mine.window = None; + let my_id = mine.id; + mine.apply_remote_json(&record).unwrap(); + assert_eq!(mine.session.tabs.len(), 1); + assert_eq!(mine.name.as_deref(), Some("app")); + assert_eq!(mine.last_active, 1_753_600_000); + assert_eq!( + mine.id, my_id, + "the client's own entry id is not overwritten" + ); + assert!( + !mine.open, + "the remote has no opinion about my open windows" + ); + assert!(mine.window.is_none()); + assert!(mine.is_remote(), "and it is still a remote workspace"); + } + + /// A record from a newer client carries fields this build has never seen, + /// and one from an older client is missing fields it expects. Neither may + /// fail the pull. + #[test] + fn applying_a_record_tolerates_version_skew() { + let mut ws = Workspace::default(); + ws.apply_remote_json(&serde_json::json!({ + "id": "6a8f2a1e-1c1b-4f7a-9d3e-2b5c8e4a7f01", + "session": {"active": 0, "tabs": []}, + "last_active": 5, + "something_from_2027": {"nested": true} + })) + .expect("unknown fields are ignored, not fatal"); + assert_eq!(ws.last_active, 5); + + let mut ws = Workspace { + name: Some("stale".into()), + ..Workspace::default() + }; + ws.apply_remote_json(&serde_json::json!({})) + .expect("a record missing every optional field still applies"); + assert_eq!( + ws.name, None, + "the remote's answer wins, including 'no name'" + ); + assert!(ws.session.tabs.is_empty()); + } + + #[test] + fn open_and_closed_partition_by_flag_and_recency() { + let mut open_one = workspace(vec![]); + open_one.open = true; + let mut older = workspace(vec![]); + older.open = false; + older.last_active = 100; + let mut newer = workspace(vec![]); + newer.open = false; + newer.last_active = 300; + let (open_id, older_id, newer_id) = (open_one.id, older.id, newer.id); + + let all = Workspaces { + active: None, + workspaces: vec![open_one, older, newer], + }; + assert_eq!( + all.open_workspaces().map(|w| w.id).collect::>(), + vec![open_id] + ); + // The picker lists most-recently-active first. + assert_eq!( + all.closed_workspaces() + .iter() + .map(|w| w.id) + .collect::>(), + vec![newer_id, older_id] + ); + } +} diff --git a/src/core/shells.rs b/crates/tty7-core/src/core/shells.rs similarity index 100% rename from src/core/shells.rs rename to crates/tty7-core/src/core/shells.rs diff --git a/src/core/ssh_profile.rs b/crates/tty7-core/src/core/ssh_profile.rs similarity index 100% rename from src/core/ssh_profile.rs rename to crates/tty7-core/src/core/ssh_profile.rs diff --git a/src/core/threads.rs b/crates/tty7-core/src/core/threads.rs similarity index 100% rename from src/core/threads.rs rename to crates/tty7-core/src/core/threads.rs diff --git a/crates/tty7-core/src/core/window_state.rs b/crates/tty7-core/src/core/window_state.rs new file mode 100644 index 00000000..29b07a76 --- /dev/null +++ b/crates/tty7-core/src/core/window_state.rs @@ -0,0 +1,91 @@ +//! Persisted last-window geometry, stored at `window.json` in the config dir +//! (alongside `config.json` / `session.json`). The quit hook in `ui::app` +//! writes the window's final bounds here unconditionally; startup reads it +//! back only when `Config::remember_window_size` is on, so toggling the +//! setting off and on again still restores the most recent quit's geometry. +//! Same durability contract as the other config-dir files: missing/malformed +//! reads fall back to "nothing remembered", writes are atomic. +//! +//! The geometry is four plain `f32`s here rather than a `gpui::Bounds` because +//! [`Workspace`](super::session::Workspace) embeds it and `session.json` has to +//! parse without gpui. Converting to and from `Bounds` is the GUI crate's job — +//! see its `core::window_state::WindowGeometry` extension trait. + +use serde::{Deserialize, Serialize}; + +/// Don't restore a window smaller than this (logical px) — a corrupt or +/// hand-edited file shouldn't reopen tty7 as a sliver. +const MIN_SIZE: f32 = 200.0; + +/// Last known window geometry, in gpui's global coordinate space (logical +/// pixels; origins can be negative or beyond the primary display on +/// multi-monitor setups). For a fullscreen window this records the *restore* +/// bounds, so the next normal launch isn't screen-sized. +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] +pub struct WindowState { + pub x: f32, + pub y: f32, + pub width: f32, + pub height: f32, +} + +impl WindowState { + fn path() -> Option { + crate::core::config::config_path("window.json") + } + + /// Load the remembered geometry; `None` when nothing usable is on disk + /// (never saved, unreadable, malformed, or degenerate values), in which + /// case the caller falls back to the centered default. + pub fn load() -> Option { + let path = Self::path()?; + let text = std::fs::read_to_string(&path).ok()?; + let state: Self = serde_json::from_str(&text) + .map_err(|e| log::warn!("failed to parse {}: {e}; ignoring", path.display())) + .ok()?; + state.is_usable().then_some(state) + } + + /// A geometry worth restoring: all values finite and the size at least + /// [`MIN_SIZE`] each way. + fn is_usable(&self) -> bool { + [self.x, self.y, self.width, self.height] + .iter() + .all(|v| v.is_finite()) + && self.width >= MIN_SIZE + && self.height >= MIN_SIZE + } + + /// Persist the geometry; IO / serialization errors are logged and swallowed + /// (worst case the next launch opens at the default size). + pub fn save(&self) { + let Some(path) = Self::path() else { + return; + }; + let json = match serde_json::to_string_pretty(self) { + Ok(j) => j, + Err(e) => { + log::warn!("failed to serialize window state: {e}"); + return; + } + }; + if let Err(e) = crate::core::config::write_atomic(&path, json.as_bytes()) { + log::warn!("failed to write {}: {e}", path.display()); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rejects_degenerate_geometry() { + let usable = + |json: &str| serde_json::from_str::(json).is_ok_and(|s| s.is_usable()); + assert!(usable(r#"{"x":-120.5,"y":42,"width":1440,"height":900}"#)); + assert!(!usable(r#"{"x":0,"y":0,"width":50,"height":900}"#)); + assert!(!usable(r#"{"x":null,"y":0,"width":1440,"height":900}"#)); + assert!(!usable("not json")); + } +} diff --git a/crates/tty7-core/src/core/workspace_store.rs b/crates/tty7-core/src/core/workspace_store.rs new file mode 100644 index 00000000..4c995760 --- /dev/null +++ b/crates/tty7-core/src/core/workspace_store.rs @@ -0,0 +1,1020 @@ +//! The **remote** side of design §10's storage split: the machine's own +//! `~/.local/share/tty7/workspaces.json`, and the one writer to it. +//! +//! # Which half of the split this is +//! +//! | Lives | Holds | Because | +//! |---|---|---| +//! | **Here**, on the machine the panes run on | The workspace list and names, the tab/pane tree, each pane's cwd / `pane_id` / agent, `last_active` | Connect from another laptop and you must see the same thing. This is a fact about the machine | +//! | The **client**'s `session.json` | Which host's which workspaces this client has opened, window geometry, the `open` flag | It is *this client's* view state. Closing a window at the office must not hide the workspace from the laptop at home | +//! +//! [`Workspace::to_remote_json`](crate::core::session::Workspace::to_remote_json) +//! is the client's half of that contract; this module is the server's. +//! +//! # Records are opaque on purpose +//! +//! A record is a [`serde_json::Value`], not a parsed +//! [`Workspace`](crate::core::session::Workspace). The server is a store, not a +//! participant: the client owns the schema, and a client newer than the server +//! it is talking to is the *normal* case (the server is installed once and then +//! left alone for months, §12's auto-install notwithstanding). Parsing here +//! would mean a field the server has never heard of is dropped on the next +//! write — silent data loss whose only symptom is a setting that will not +//! stick. +//! +//! What the store does insist on is the shape it has to index by: a record is a +//! JSON object, and its `id` agrees with the key it was filed under. Those two +//! are what keep the file's array parseable as +//! [`Workspaces`](crate::core::session::Workspaces) by anything that wants the +//! typed view. +//! +//! # Concurrency +//! +//! Several control connections can be writing at once — two of the user's own +//! machines, or one machine reconnecting while the old link has not yet +//! noticed. One mutex covers the record list *and* the file write, so the +//! on-disk order is the in-memory order and no interleaving can produce a file +//! that never existed as a state. The write is atomic +//! ([`write_atomic`](crate::core::config::write_atomic)), so a crash mid-save +//! leaves the old file rather than half of the new one, and a write that fails +//! rolls the memory back rather than leaving the two out of step. +//! +//! Change notifications ([`WorkspaceStore::subscribe`]) are delivered +//! **outside** the lock, and the server's callback only enqueues — a peer that +//! has stopped reading its socket must not be able to stall another peer's +//! `WorkspacePut`. + +use std::io; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, Mutex}; + +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +/// The file's name under the data directory. +pub const STORE_FILE: &str = "workspaces.json"; + +/// Overrides where the store lives. Set by tests and by a second server on a +/// shared box — the same escape hatch +/// [`CONTROL_SOCK_ENV`](crate::host::server::CONTROL_SOCK_ENV) is for the +/// socket. +pub const DATA_DIR_ENV: &str = "TTY7_DATA_DIR"; + +/// Ceiling on one record. A workspace with a hundred tabs is a few tens of +/// kilobytes; this is four megabytes, so it only ever catches a client that has +/// gone wrong. Without it a single `WorkspacePut` could pin the file — and the +/// memory holding it — at the 64 MiB frame limit. +pub const MAX_RECORD_BYTES: usize = 4 * 1024 * 1024; + +/// Ceiling on records. Same reasoning one level up: a user has tens of +/// workspaces, and a client looping on "create workspace" should hit a named +/// error rather than grow the file until the disk fills. +pub const MAX_WORKSPACES: usize = 1024; + +/// Ceiling on a record key, which is a workspace uuid in every non-hostile +/// case. +const MAX_ID_BYTES: usize = 128; + +// --------------------------------------------------------------------------- +// Attachment bookkeeping (the data half of M6's takeover) +// --------------------------------------------------------------------------- + +/// Who is currently attached to a workspace. +/// +/// **Data only.** Design §10's takeover — push `Preempted { by }` to the old +/// session, close its streams, offer a [抢回] button — is M6's, and none of it +/// is here. What is here is the record that machinery needs to exist before it +/// can be written: the random token that tells two connections from the same +/// client apart, and the hostname that fills in "已在 <主机名> 上打开". Both +/// arrive in the [`ControlHello`](crate::daemon::control::ControlHello). +/// +/// **Never persisted.** An attachment describes a live connection; after a +/// server restart there are none, and a stale one on disk would make M6 report +/// a takeover against a client that no longer exists. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct Attachment { + /// The client's per-session random token, from `ControlHello::client_token`. + pub token: String, + /// The client machine's hostname, shown to the user in the preempted + /// window's status bar. + pub hostname: String, + /// Unix seconds when the attach happened. + pub since: u64, +} + +impl Attachment { + /// An attachment stamped now. + pub fn new(token: impl Into, hostname: impl Into) -> Attachment { + Attachment { + token: token.into(), + hostname: hostname.into(), + since: unix_now(), + } + } +} + +// --------------------------------------------------------------------------- +// Subscriptions +// --------------------------------------------------------------------------- + +/// Identifies one subscriber, so a writer can be told apart from the clients it +/// is notifying. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct SubscriberId(pub u64); + +/// What a subscriber is told: the id of the workspace that changed. +/// +/// Deliberately not the new contents. The event is a hint to refetch, so a +/// client that missed three of them is in the same state as one that saw all +/// three — which is what makes dropping a notification safe when a peer is +/// behind. +pub type Notify = Arc; + +/// A live subscription. Dropping it unsubscribes, so a connection's teardown +/// cannot leave a callback pointing at a sink nobody is reading. +pub struct Subscription { + store: Arc, + id: SubscriberId, +} + +impl Subscription { + /// This subscriber's id — pass it as the `origin` of your own writes so you + /// are not told about changes you made yourself. + pub fn id(&self) -> SubscriberId { + self.id + } +} + +impl Drop for Subscription { + fn drop(&mut self) { + self.store.unsubscribe(self.id); + } +} + +// --------------------------------------------------------------------------- +// The store +// --------------------------------------------------------------------------- + +/// The machine's workspace records, and the file they are persisted to. +pub struct WorkspaceStore { + path: PathBuf, + state: Mutex, + /// Separate from `state` on purpose: attaching is not a change to the + /// layout, does not write the file, and must not queue behind one. + attachments: Mutex>, + subscribers: Mutex>, + next_subscriber: AtomicU64, +} + +struct State { + /// Insertion-ordered `(id, record)`. A `Vec` rather than a `HashMap` + /// because the file's array order is what a client lists, and a hash map + /// would reshuffle the picker on every save for no reason. + records: Vec<(String, Value)>, +} + +impl WorkspaceStore { + /// Open the store at `path`, reading whatever is there. + /// + /// Infallible by design, exactly like + /// [`Workspaces::load`](crate::core::session::Workspaces::load): a machine + /// whose workspace file is missing or unreadable must still serve files and + /// panes. A file that does not parse is copied aside as + /// `workspaces.json.corrupt` before anything can overwrite it, so "the + /// store came up empty" is recoverable by hand rather than terminal. + pub fn open(path: impl Into) -> Arc { + let path = path.into(); + let records = load_records(&path); + Arc::new(WorkspaceStore { + path, + state: Mutex::new(State { records }), + attachments: Mutex::new(Vec::new()), + subscribers: Mutex::new(Vec::new()), + next_subscriber: AtomicU64::new(1), + }) + } + + /// Open the store at [`default_store_path`]. + pub fn shared() -> io::Result> { + Ok(WorkspaceStore::open(default_store_path()?)) + } + + /// Where this store is persisted. + pub fn path(&self) -> &Path { + &self.path + } + + // ----- reads ----------------------------------------------------------- + + /// Every record, in file order. Answers + /// [`WorkspaceList`](crate::daemon::control::ControlRequest::WorkspaceList). + pub fn list(&self) -> Vec { + self.locked() + .records + .iter() + .map(|(_, v)| v.clone()) + .collect() + } + + /// One record. `None` means no such workspace, which the server turns into + /// a `NotFound` — distinguishable from a workspace that exists and is + /// empty, which a `null` payload would not be. + pub fn get(&self, id: &str) -> Option { + self.locked() + .records + .iter() + .find(|(k, _)| k == id) + .map(|(_, v)| v.clone()) + } + + /// How many records are on file. + pub fn len(&self) -> usize { + self.locked().records.len() + } + + pub fn is_empty(&self) -> bool { + self.len() == 0 + } + + // ----- writes ---------------------------------------------------------- + + /// File `record` under `id`, replacing any record already there, and + /// persist. + /// + /// `origin` is the subscriber that asked for the change, so it is not + /// notified of its own write; `None` notifies everyone. + /// + /// The record's `id` field, if present, must agree with `id` — a mismatch + /// would put the file's typed view at odds with the store's key, and the + /// next client to read the array would see a workspace under the wrong + /// identity. When absent it is filled in, so a client that only sent the + /// body still produces a well-formed file. + pub fn put(&self, id: &str, mut record: Value, origin: Option) -> io::Result<()> { + check_id(id)?; + let obj = record.as_object_mut().ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidInput, + "a workspace record must be a JSON object", + ) + })?; + match obj.get("id") { + Some(Value::String(existing)) if existing == id => {} + Some(other) => { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + format!("workspace record carries id {other} but was filed under {id}"), + )); + } + None => { + obj.insert("id".to_string(), Value::String(id.to_string())); + } + } + + let encoded = serde_json::to_vec(&record).map_err(io::Error::other)?; + if encoded.len() > MAX_RECORD_BYTES { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + format!( + "workspace record is {} bytes; the limit is {MAX_RECORD_BYTES}", + encoded.len() + ), + )); + } + + { + let mut st = self.locked(); + let existing = st.records.iter().position(|(k, _)| k == id); + if existing.is_none() && st.records.len() >= MAX_WORKSPACES { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + format!("this machine already holds {MAX_WORKSPACES} workspaces"), + )); + } + + // Mutate, persist, and undo precisely if the disk said no — the + // in-memory state is what every later read answers from, so it must + // never claim something the file does not. + let undo = match existing { + Some(i) => Undo::Restore(i, std::mem::replace(&mut st.records[i].1, record)), + None => { + st.records.push((id.to_string(), record)); + Undo::Remove(st.records.len() - 1) + } + }; + if let Err(e) = self.persist(&st) { + match undo { + Undo::Restore(i, old) => st.records[i].1 = old, + Undo::Remove(i) => { + st.records.remove(i); + } + } + return Err(e); + } + } + + self.notify(id, origin); + Ok(()) + } + + /// Forget a workspace. `false` means there was nothing to forget, which is + /// still success: a delete that raced another client's delete has got what + /// it asked for, and reporting an error would make the client retry + /// something already done. + pub fn delete(&self, id: &str, origin: Option) -> io::Result { + check_id(id)?; + { + let mut st = self.locked(); + let Some(i) = st.records.iter().position(|(k, _)| k == id) else { + return Ok(false); + }; + let removed = st.records.remove(i); + if let Err(e) = self.persist(&st) { + st.records.insert(i, removed); + return Err(e); + } + } + // The attachment goes with it: nothing can be attached to a workspace + // that no longer exists, and leaving the entry would have M6 report a + // takeover against a ghost. + self.attachments_locked().retain(|(k, _)| k != id); + self.notify(id, origin); + Ok(true) + } + + // ----- attachment (M6's data, not M6's behaviour) ---------------------- + + /// Record `who` as the workspace's current session and answer whoever held + /// it before. + /// + /// The previous holder is **the thing M6 acts on**: a `Some` return is + /// exactly the takeover case, and the caller is the one that pushes + /// `Preempted { by }` and closes the old streams. This function does + /// neither — it only makes the fact available. + pub fn attach(&self, workspace: &str, who: Attachment) -> Option { + let mut slots = self.attachments_locked(); + match slots.iter_mut().find(|(k, _)| k == workspace) { + Some((_, current)) => Some(std::mem::replace(current, who)), + None => { + slots.push((workspace.to_string(), who)); + None + } + } + } + + /// Who is attached to `workspace`, if anyone. + pub fn attachment(&self, workspace: &str) -> Option { + self.attachments_locked() + .iter() + .find(|(k, _)| k == workspace) + .map(|(_, a)| a.clone()) + } + + /// Release `workspace`, but **only if `token` still holds it**. + /// + /// The token check is the whole point. A preempted client tears its + /// connection down *after* the new one has attached, and an unconditional + /// release would have that teardown evict the client that just took over — + /// leaving the workspace looking free while a live window is on it. + pub fn detach(&self, workspace: &str, token: &str) -> bool { + let mut slots = self.attachments_locked(); + let before = slots.len(); + slots.retain(|(k, a)| !(k == workspace && a.token == token)); + slots.len() != before + } + + /// Every live attachment, for diagnostics. + pub fn attachments(&self) -> Vec<(String, Attachment)> { + self.attachments_locked().clone() + } + + // ----- change notification --------------------------------------------- + + /// Be told when a record changes. Dropping the returned [`Subscription`] + /// unsubscribes. + /// + /// `f` **must not block**: it runs on the thread of whichever connection + /// made the change, so a callback that waited on a slow peer's socket would + /// let one stalled client hold up everyone else's writes. The control + /// server's callback enqueues onto a bounded channel and returns. + pub fn subscribe(self: &Arc, f: Notify) -> Subscription { + let id = SubscriberId(self.next_subscriber.fetch_add(1, Ordering::Relaxed)); + self.subscribers + .lock() + .unwrap_or_else(|e| e.into_inner()) + .push((id, f)); + Subscription { + store: Arc::clone(self), + id, + } + } + + fn unsubscribe(&self, id: SubscriberId) { + self.subscribers + .lock() + .unwrap_or_else(|e| e.into_inner()) + .retain(|(sid, _)| *sid != id); + } + + /// Fan a change out, skipping the subscriber that caused it. + /// + /// Called with no lock held: a callback is other people's code, and holding + /// the store's mutex across it would make every future write hostage to it. + fn notify(&self, id: &str, origin: Option) { + let subscribers: Vec<(SubscriberId, Notify)> = self + .subscribers + .lock() + .unwrap_or_else(|e| e.into_inner()) + .clone(); + for (sid, f) in subscribers { + if Some(sid) != origin { + f(id); + } + } + } + + // ----- internals ------------------------------------------------------- + + fn locked(&self) -> std::sync::MutexGuard<'_, State> { + // A poisoned lock means a panic between a mutation and its write. The + // in-memory state is still a valid state (the undo path restores it + // before returning) and the file is either the old or the new one, so + // carrying on is strictly better than taking the server down. + self.state.lock().unwrap_or_else(|e| e.into_inner()) + } + + fn attachments_locked(&self) -> std::sync::MutexGuard<'_, Vec<(String, Attachment)>> { + self.attachments.lock().unwrap_or_else(|e| e.into_inner()) + } + + /// Serialize the whole file and replace it atomically. + /// + /// The document is `{"workspaces": [...]}` — the identical shape + /// [`Workspaces`](crate::core::session::Workspaces) parses, so this file is + /// readable by the same code that reads a client's `session.json` and a + /// human can diff the two. + fn persist(&self, st: &State) -> io::Result<()> { + #[derive(Serialize)] + struct Doc<'a> { + workspaces: Vec<&'a Value>, + } + let doc = Doc { + workspaces: st.records.iter().map(|(_, v)| v).collect(), + }; + let bytes = serde_json::to_vec_pretty(&doc).map_err(io::Error::other)?; + if let Some(parent) = self.path.parent() { + std::fs::create_dir_all(parent)?; + } + crate::core::config::write_atomic(&self.path, &bytes) + } +} + +/// How to undo a mutation whose write failed. +enum Undo { + Restore(usize, Value), + Remove(usize), +} + +/// A key has to be something that can key a JSON object and appear in a log +/// line. It is never used to build a path, so this is a sanity check rather +/// than a security boundary. +fn check_id(id: &str) -> io::Result<()> { + if id.is_empty() { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "a workspace id must not be empty", + )); + } + if id.len() > MAX_ID_BYTES { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + format!("a workspace id must be at most {MAX_ID_BYTES} bytes"), + )); + } + if id.chars().any(char::is_control) { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "a workspace id must not contain control characters", + )); + } + Ok(()) +} + +/// Read the file, keeping whatever is well-formed. +/// +/// One unparseable *record* costs that record, not the file: a client that +/// wrote something odd should not make the user's other twelve workspaces +/// disappear. An unparseable *file* is quarantined and the store comes up +/// empty. +fn load_records(path: &Path) -> Vec<(String, Value)> { + let text = match std::fs::read_to_string(path) { + Ok(t) => t, + Err(e) if e.kind() == io::ErrorKind::NotFound => return Vec::new(), + Err(e) => { + log::warn!("could not read {}: {e}; starting empty", path.display()); + return Vec::new(); + } + }; + + let value: Value = match serde_json::from_str(crate::core::config::strip_bom(&text)) { + Ok(v) => v, + Err(e) => { + log::warn!("{} does not parse ({e}); quarantining it", path.display()); + quarantine(path); + return Vec::new(); + } + }; + let Some(array) = value.get("workspaces").and_then(Value::as_array) else { + log::warn!( + "{} has no `workspaces` array; quarantining it", + path.display() + ); + quarantine(path); + return Vec::new(); + }; + + let mut records: Vec<(String, Value)> = Vec::with_capacity(array.len()); + for record in array { + let Some(id) = record.get("id").and_then(Value::as_str) else { + log::warn!("dropping a workspace record with no string `id`"); + continue; + }; + if check_id(id).is_err() { + log::warn!("dropping a workspace record with an unusable id"); + continue; + } + if records.iter().any(|(k, _)| k == id) { + log::warn!("dropping a duplicate record for workspace {id}"); + continue; + } + records.push((id.to_string(), record.clone())); + } + records +} + +/// Copy a file we are about to stop honouring somewhere the user can find it. +/// Best effort: failing to make the backup is not a reason to refuse to start. +fn quarantine(path: &Path) { + let aside = path.with_extension("json.corrupt"); + match std::fs::copy(path, &aside) { + Ok(_) => log::warn!("the previous contents were kept at {}", aside.display()), + Err(e) => log::warn!("could not keep a copy at {}: {e}", aside.display()), + } +} + +/// `/workspaces.json`. +/// +/// | Order | Directory | Why | +/// |---|---|---| +/// | 1 | `$TTY7_DATA_DIR` | Explicit wins; how tests and a second server get their own file | +/// | 2 | `$XDG_DATA_HOME/tty7` | The location the design names, spelled the way XDG spells it | +/// | 3 | `$HOME/.local/share/tty7` | No `XDG_DATA_HOME` — the literal path in design §10 | +/// +/// Deliberately **not** under the config dir. `session.json` there is the +/// *client's* view state, and a box that is both someone's laptop and someone +/// else's remote must keep the two files apart or one role would overwrite the +/// other's idea of which workspaces exist. +pub fn default_store_path() -> io::Result { + Ok(data_dir()?.join(STORE_FILE)) +} + +fn data_dir() -> io::Result { + if let Some(explicit) = std::env::var_os(DATA_DIR_ENV).filter(|v| !v.is_empty()) { + return Ok(PathBuf::from(explicit)); + } + #[cfg(not(windows))] + let base = env_dir("XDG_DATA_HOME") + .or_else(|| env_dir("HOME").map(|h| h.join(".local").join("share"))); + #[cfg(windows)] + let base = env_dir("LOCALAPPDATA") + .or_else(|| env_dir("USERPROFILE").map(|h| h.join(".local").join("share"))); + + base.map(|b| b.join("tty7")).ok_or_else(|| { + io::Error::other(format!( + "no home directory to place {STORE_FILE} in; set {DATA_DIR_ENV}" + )) + }) +} + +fn env_dir(key: &str) -> Option { + std::env::var_os(key) + .filter(|v| !v.is_empty()) + .map(PathBuf::from) +} + +fn unix_now() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::core::session::{Session, Workspace, WorkspaceId, Workspaces}; + use std::sync::atomic::AtomicUsize; + + fn store() -> (Arc, tempfile::TempDir) { + let dir = tempfile::TempDir::new().unwrap(); + let store = WorkspaceStore::open(dir.path().join(STORE_FILE)); + (store, dir) + } + + fn record(id: &str, name: &str) -> Value { + serde_json::json!({ + "id": id, + "name": name, + "session": {"active": 0, "tabs": [ + {"pane": {"Leaf": {"cwd": "/home/me/proj", "pane_id": 7}}} + ]}, + "last_active": 1_753_600_000u64, + }) + } + + // ── The basics ────────────────────────────────────────────────────────── + + #[test] + fn a_missing_file_is_an_empty_store_not_an_error() { + let (store, _dir) = store(); + assert!(store.is_empty()); + assert!(store.list().is_empty()); + assert_eq!(store.get("nope"), None); + // And deleting nothing is success, not an error. + assert!(!store.delete("nope", None).unwrap()); + } + + #[test] + fn put_get_list_delete_round_trip_through_the_file() { + let (store, dir) = store(); + store.put("a", record("a", "api"), None).unwrap(); + store.put("b", record("b", "web"), None).unwrap(); + assert_eq!(store.len(), 2); + assert_eq!(store.get("a").unwrap()["name"], "api"); + + // A second store over the same path sees it: the file is the authority, + // which is the entire reason this lives on the remote. + let reopened = WorkspaceStore::open(dir.path().join(STORE_FILE)); + assert_eq!(reopened.len(), 2); + assert_eq!(reopened.get("b").unwrap()["name"], "web"); + // File order is list order. + let names: Vec = reopened + .list() + .iter() + .map(|v| v["name"].as_str().unwrap().to_string()) + .collect(); + assert_eq!(names, vec!["api", "web"]); + + assert!(store.delete("a", None).unwrap()); + assert_eq!( + WorkspaceStore::open(dir.path().join(STORE_FILE)).len(), + 1, + "a delete has to reach the disk, not just the map" + ); + } + + #[test] + fn replacing_a_record_keeps_its_place_in_the_list() { + let (store, _dir) = store(); + for id in ["a", "b", "c"] { + store.put(id, record(id, id), None).unwrap(); + } + store.put("a", record("a", "renamed"), None).unwrap(); + let ids: Vec = store + .list() + .iter() + .map(|v| v["id"].as_str().unwrap().to_string()) + .collect(); + assert_eq!(ids, vec!["a", "b", "c"], "a rename must not reshuffle"); + assert_eq!(store.get("a").unwrap()["name"], "renamed"); + } + + /// The file the store writes is the one `Workspaces` parses. That is what + /// "换台电脑连过来要看到同一份" means concretely — the record a client puts + /// comes back as the same `Workspace` on the next machine. + #[test] + fn the_file_is_a_workspaces_document() { + let (store, dir) = store(); + let mut ws = Workspace::from_session(Session::default()); + ws.name = Some("api".into()); + ws.last_active = 1_753_600_000; + let id = ws.id.to_string(); + store.put(&id, ws.to_remote_json(), None).unwrap(); + + let text = std::fs::read_to_string(dir.path().join(STORE_FILE)).unwrap(); + let parsed = Workspaces::decode(&text).expect("the store's file is a Workspaces document"); + assert_eq!(parsed.workspaces.len(), 1); + assert_eq!(parsed.workspaces[0].id, ws.id, "the identity survives"); + assert_eq!(parsed.workspaces[0].name.as_deref(), Some("api")); + // The client's view state was never sent, so the remote's copy has the + // defaults rather than another machine's window geometry. + assert!(parsed.workspaces[0].window.is_none()); + assert!(!parsed.workspaces[0].is_remote()); + } + + // ── Validation ────────────────────────────────────────────────────────── + + #[test] + fn a_record_must_be_an_object_whose_id_agrees_with_its_key() { + let (store, _dir) = store(); + let kinds = |e: io::Error| e.kind(); + assert_eq!( + store + .put("a", serde_json::json!([1, 2, 3]), None) + .map_err(kinds), + Err(io::ErrorKind::InvalidInput) + ); + assert_eq!( + store + .put("a", serde_json::json!({"id": "b"}), None) + .map_err(kinds), + Err(io::ErrorKind::InvalidInput), + "filing b's record under a would put the key and the file at odds" + ); + assert_eq!( + store.put("", record("", "x"), None).map_err(kinds), + Err(io::ErrorKind::InvalidInput) + ); + assert!(store.is_empty(), "a rejected put must not be half-applied"); + + // A body with no id is completed rather than refused: the key is the + // authority and the file still ends up well-formed. + store + .put("a", serde_json::json!({"name": "api"}), None) + .unwrap(); + assert_eq!(store.get("a").unwrap()["id"], "a"); + } + + #[test] + fn oversized_and_overnumerous_records_are_refused_by_name() { + let (store, _dir) = store(); + let huge = serde_json::json!({"name": "x".repeat(MAX_RECORD_BYTES + 16)}); + assert_eq!( + store.put("a", huge, None).unwrap_err().kind(), + io::ErrorKind::InvalidInput + ); + assert!(store.is_empty()); + } + + // ── Corruption ────────────────────────────────────────────────────────── + + #[test] + fn a_corrupt_file_is_quarantined_rather_than_overwritten() { + let dir = tempfile::TempDir::new().unwrap(); + let path = dir.path().join(STORE_FILE); + std::fs::write(&path, b"{ this is not json").unwrap(); + + let store = WorkspaceStore::open(&path); + assert!( + store.is_empty(), + "an unparseable file yields an empty store" + ); + // The user's bytes are still recoverable after the store overwrites the + // original. + store.put("a", record("a", "api"), None).unwrap(); + let aside = std::fs::read_to_string(path.with_extension("json.corrupt")).unwrap(); + assert_eq!(aside, "{ this is not json"); + } + + #[test] + fn one_bad_record_does_not_cost_the_others() { + let dir = tempfile::TempDir::new().unwrap(); + let path = dir.path().join(STORE_FILE); + std::fs::write( + &path, + br#"{"workspaces":[ + {"id":"a","name":"api"}, + {"name":"no id at all"}, + {"id":42}, + {"id":"a","name":"duplicate"}, + {"id":"b","name":"web"} + ]}"#, + ) + .unwrap(); + let store = WorkspaceStore::open(&path); + assert_eq!(store.len(), 2); + assert_eq!(store.get("a").unwrap()["name"], "api", "the first wins"); + assert_eq!(store.get("b").unwrap()["name"], "web"); + } + + #[test] + fn a_utf8_bom_does_not_empty_the_store() { + let dir = tempfile::TempDir::new().unwrap(); + let path = dir.path().join(STORE_FILE); + std::fs::write(&path, "\u{FEFF}{\"workspaces\":[{\"id\":\"a\"}]}").unwrap(); + assert_eq!(WorkspaceStore::open(&path).len(), 1); + } + + // ── Notification ──────────────────────────────────────────────────────── + + #[test] + fn a_change_notifies_every_subscriber_but_its_author() { + let (store, _dir) = store(); + let heard_by_a = Arc::new(Mutex::new(Vec::::new())); + let heard_by_b = Arc::new(Mutex::new(Vec::::new())); + let sink = |log: &Arc>>| { + let log = Arc::clone(log); + Arc::new(move |id: &str| log.lock().unwrap().push(id.to_string())) as Notify + }; + let a = store.subscribe(sink(&heard_by_a)); + let _b = store.subscribe(sink(&heard_by_b)); + + // A writes: B hears about it, A does not hear its own change. + store.put("w1", record("w1", "one"), Some(a.id())).unwrap(); + assert!(heard_by_a.lock().unwrap().is_empty()); + assert_eq!(&*heard_by_b.lock().unwrap(), &["w1".to_string()]); + + // A delete is a change too, and a write with no origin reaches all. + store.delete("w1", Some(a.id())).unwrap(); + store.put("w2", record("w2", "two"), None).unwrap(); + assert_eq!(&*heard_by_a.lock().unwrap(), &["w2".to_string()]); + assert_eq!( + &*heard_by_b.lock().unwrap(), + &["w1".to_string(), "w1".to_string(), "w2".to_string()] + ); + + // Deleting nothing changed nothing, so it says nothing. + let before = heard_by_b.lock().unwrap().len(); + assert!(!store.delete("gone", None).unwrap()); + assert_eq!(heard_by_b.lock().unwrap().len(), before); + } + + #[test] + fn dropping_a_subscription_stops_the_notifications() { + let (store, _dir) = store(); + let count = Arc::new(AtomicUsize::new(0)); + let seen = Arc::clone(&count); + let sub = store.subscribe(Arc::new(move |_| { + seen.fetch_add(1, Ordering::SeqCst); + })); + store.put("a", record("a", "x"), None).unwrap(); + assert_eq!(count.load(Ordering::SeqCst), 1); + drop(sub); + store.put("b", record("b", "y"), None).unwrap(); + assert_eq!( + count.load(Ordering::SeqCst), + 1, + "a torn-down connection must not still be written to" + ); + } + + /// A rejected put changed nothing, so it must not claim otherwise. + #[test] + fn a_failed_put_notifies_nobody() { + let (store, _dir) = store(); + let count = Arc::new(AtomicUsize::new(0)); + let seen = Arc::clone(&count); + let _sub = store.subscribe(Arc::new(move |_| { + seen.fetch_add(1, Ordering::SeqCst); + })); + store + .put("a", serde_json::json!("not an object"), None) + .ok(); + assert_eq!(count.load(Ordering::SeqCst), 0); + } + + // ── Concurrency ───────────────────────────────────────────────────────── + + /// Several connections writing at once is the normal case, not the + /// pathological one. Every write must land, and the file must end up as a + /// state that actually existed — not a half-written interleaving. + #[test] + fn concurrent_writers_all_land_and_the_file_stays_whole() { + let dir = tempfile::TempDir::new().unwrap(); + let path = dir.path().join(STORE_FILE); + let store = WorkspaceStore::open(&path); + + let threads: Vec<_> = (0..8) + .map(|t| { + let store = Arc::clone(&store); + std::thread::spawn(move || { + for i in 0..25 { + let id = format!("w{t}-{i}"); + store.put(&id, record(&id, "x"), None).unwrap(); + } + }) + }) + .collect(); + for t in threads { + t.join().unwrap(); + } + + assert_eq!(store.len(), 200); + // And the file on disk agrees, which is the part a torn write would + // fail: it would not parse at all. + let reopened = WorkspaceStore::open(&path); + assert_eq!(reopened.len(), 200); + for t in 0..8 { + assert!(reopened.get(&format!("w{t}-24")).is_some()); + } + } + + /// The same workspace written from two connections: last writer wins, and + /// the loser's record is gone rather than merged into a hybrid neither + /// client asked for. + #[test] + fn concurrent_writes_to_one_record_are_last_writer_wins() { + let (store, _dir) = store(); + let a = Arc::clone(&store); + let b = Arc::clone(&store); + let ta = std::thread::spawn(move || { + for _ in 0..200 { + a.put("w", record("w", "from-a"), None).unwrap(); + } + }); + let tb = std::thread::spawn(move || { + for _ in 0..200 { + b.put("w", record("w", "from-b"), None).unwrap(); + } + }); + ta.join().unwrap(); + tb.join().unwrap(); + assert_eq!(store.len(), 1); + let name = store.get("w").unwrap()["name"] + .as_str() + .unwrap() + .to_string(); + assert!(name == "from-a" || name == "from-b", "{name}"); + } + + // ── Attachment (M6's data) ────────────────────────────────────────────── + + #[test] + fn attaching_reports_the_session_it_displaced() { + let (store, _dir) = store(); + assert_eq!(store.attachment("w"), None); + + let laptop = Attachment::new("tok-1", "laptop"); + assert_eq!( + store.attach("w", laptop.clone()), + None, + "nothing to preempt" + ); + assert_eq!(store.attachment("w"), Some(laptop.clone())); + + // The second client's attach hands back the first — the exact fact M6's + // takeover acts on. + let desktop = Attachment::new("tok-2", "desktop"); + assert_eq!(store.attach("w", desktop.clone()), Some(laptop.clone())); + assert_eq!(store.attachment("w"), Some(desktop)); + + // The preempted client tearing down afterwards must not evict the new + // owner: its token no longer holds the workspace. + assert!(!store.detach("w", &laptop.token)); + assert_eq!(store.attachment("w").unwrap().hostname, "desktop"); + assert!(store.detach("w", "tok-2")); + assert_eq!(store.attachment("w"), None); + } + + #[test] + fn attachments_are_scoped_to_a_workspace_and_die_with_it() { + let (store, _dir) = store(); + store.put("w", record("w", "one"), None).unwrap(); + store.attach("w", Attachment::new("tok", "laptop")); + store.attach("other", Attachment::new("tok", "laptop")); + assert_eq!(store.attachments().len(), 2); + + store.delete("w", None).unwrap(); + assert_eq!(store.attachment("w"), None); + assert!(store.attachment("other").is_some()); + } + + /// Attachments describe live connections, so they must not outlive the + /// process that held them. + #[test] + fn attachments_are_never_written_to_the_file() { + let dir = tempfile::TempDir::new().unwrap(); + let path = dir.path().join(STORE_FILE); + let store = WorkspaceStore::open(&path); + store.put("w", record("w", "one"), None).unwrap(); + store.attach("w", Attachment::new("secret-token", "laptop")); + + let text = std::fs::read_to_string(&path).unwrap(); + assert!(!text.contains("secret-token"), "{text}"); + assert!(!text.contains("laptop"), "{text}"); + assert_eq!( + WorkspaceStore::open(&path).attachment("w"), + None, + "a restarted server has no attached clients" + ); + } + + // ── Path resolution ───────────────────────────────────────────────────── + + #[test] + fn the_store_path_ends_at_the_documented_file() { + // `TTY7_DATA_DIR` is process-global, so this only asserts the shape the + // resolution produces rather than setting the variable under other + // tests running beside it. + let p = WorkspaceStore::open(PathBuf::from("/srv/data/tty7").join(STORE_FILE)); + assert!(p.path().ends_with("tty7/workspaces.json")); + } + + #[test] + fn a_workspace_id_is_a_usable_store_key() { + let (store, _dir) = store(); + let id = WorkspaceId::new().to_string(); + store.put(&id, record(&id, "api"), None).unwrap(); + assert!(store.get(&id).is_some()); + } +} diff --git a/src/core/worktree.rs b/crates/tty7-core/src/core/worktree.rs similarity index 62% rename from src/core/worktree.rs rename to crates/tty7-core/src/core/worktree.rs index e908ff37..fce3a915 100644 --- a/src/core/worktree.rs +++ b/crates/tty7-core/src/core/worktree.rs @@ -4,12 +4,23 @@ //! repository's own `.tty7/worktrees/` (kept out of `git status` by an //! auto-written self-ignoring `.tty7/.gitignore`) — so a coding agent gets an //! isolated checkout on its own branch, physically next to the code it forks. -//! Blocking (spawns `git`); callers run it on the background executor, except -//! [`is_inside_repo`], which is a pure filesystem probe cheap enough for -//! menu-open time. +//! +//! Every filesystem touch and every `git` invocation goes through the [`Host`] +//! the pane belongs to, so a worktree is created on the machine the code +//! actually lives on rather than always on this one. That also means **all of +//! it blocks** — on a remote host every call here is a round trip — so callers +//! run the whole module on the background executor (`ui::host_ops`), never +//! inline while building a menu. +//! +//! Path arithmetic is deliberately the host's too ([`Host::join`], never +//! `PathBuf::join`): a Windows client driving a Linux host would otherwise +//! build `/home/me\.tty7` and create a repository directory with a backslash in +//! its name. use std::path::{Path, PathBuf}; +use crate::host::Host; + /// Word pools for generated branch names (`quiet-otter`). Short, lowercase, /// branch-safe; two pools of 24 give 576 combinations before the numeric /// fallback in [`defaults`] kicks in. @@ -52,32 +63,40 @@ pub struct WorktreeDefaults { pub dir: PathBuf, } -/// Whether `cwd` sits inside a git repository — an upward scan for `.git` -/// (a directory in a primary checkout, a file in a linked worktree or -/// submodule). No subprocess: the tab context menu calls this while opening. -pub fn is_inside_repo(cwd: &Path) -> bool { - cwd.ancestors().any(|d| d.join(".git").exists()) +/// `/.tty7/worktrees` — where this repository's managed checkouts live. +/// Built with [`Host::join`] rather than [`PathBuf::join`] so the separator is +/// the *host's*, not the client's. +fn managed_root(host: &dyn Host, main_root: &Path) -> PathBuf { + host.join(&host.join(main_root, ".tty7"), "worktrees") } -/// Run `git -C `, returning trimmed stdout on success and trimmed -/// stderr as the error otherwise. -fn git(dir: &Path, args: &[&str]) -> Result { - let mut cmd = std::process::Command::new("git"); - cmd.arg("-C").arg(dir).args(args); - let out = crate::core::proc::hide_console(&mut cmd) - .output() - .map_err(|e| format!("failed to run git: {e}"))?; - if out.status.success() { - Ok(String::from_utf8_lossy(&out.stdout).trim().to_string()) - } else { - Err(String::from_utf8_lossy(&out.stderr).trim().to_string()) +/// Run `git -C ` on `host`, returning trimmed stdout on success and +/// trimmed stderr as the error otherwise. +/// +/// The shape is unchanged from when this module ran `git` itself; what changed +/// is that the invocation is now the one every git read in tty7 shares +/// (`core::git::git_output`): `GIT_OPTIONAL_LOCKS=0`, stdin nulled, ambient +/// `GIT_DIR`/`GIT_WORK_TREE` removed. `GIT_OPTIONAL_LOCKS` only suppresses +/// *optional* sub-operations (git's own words) — the locks `worktree add` and +/// `branch -d` need to do their job are not optional and are still taken. +/// +/// The three outcomes stay distinct: `Err` from the host means git never ran +/// (missing binary, vanished cwd, dead connection) and carries the same +/// `failed to run git:` prefix this module has always produced, while a git +/// that ran and failed still reports its own stderr. +fn git(host: &dyn Host, dir: &Path, args: &[&str]) -> Result { + match host.git(dir, args) { + Ok(out) if out.success() => Ok(out.stdout_trimmed()), + Ok(out) => Err(out.stderr_trimmed()), + Err(e) => Err(format!("failed to run git: {e}")), } } /// Whether `name` already exists as a local branch in the repo at `repo_root`. /// A failed probe (`--verify --quiet` exits non-zero) means it's free. -fn branch_exists(repo_root: &Path, name: &str) -> bool { +fn branch_exists(host: &dyn Host, repo_root: &Path, name: &str) -> bool { git( + host, repo_root, &[ "rev-parse", @@ -128,21 +147,20 @@ pub struct ManagedWorktree { /// sits anywhere else. Only checkouts under the main repository's /// `.tty7/worktrees/` count — a user's own linked worktrees are never offered /// for removal. Blocking (spawns `git`). -pub fn managed(cwd: &Path) -> Option { +pub fn managed(host: &dyn Host, cwd: &Path) -> Option { // Canonicalize before the component test: git reports resolved physical // paths (`/private/var/…` on macOS), while `cwd` may arrive through // symlinks — a textual comparison would then never match. The `.tty7/ - // worktrees` ancestor check is a cheap pure-filesystem pre-filter, so the + // worktrees` ancestor check is a cheap pure-textual pre-filter, so the // common case (every ordinary tab close) never spawns git. - let cwd = std::fs::canonicalize(cwd).ok()?; - if !cwd - .ancestors() - .any(|a| a.ends_with(Path::new(".tty7").join("worktrees"))) - { + let cwd = host.canonicalize(cwd).ok()?; + let suffix = host.join(Path::new(".tty7"), "worktrees"); + if !cwd.ancestors().any(|a| a.ends_with(&suffix)) { return None; } - let path = PathBuf::from(git(&cwd, &["rev-parse", "--show-toplevel"]).ok()?); + let path = PathBuf::from(git(host, &cwd, &["rev-parse", "--show-toplevel"]).ok()?); let main_root = git( + host, &path, &["rev-parse", "--path-format=absolute", "--git-common-dir"], ) @@ -152,11 +170,13 @@ pub fn managed(cwd: &Path) -> Option { .to_path_buf(); // The checkout must really sit in *this* repository's managed directory — // both paths come from git, so they compare on equal (physical) footing. - if !path.starts_with(main_root.join(".tty7").join("worktrees")) { + if !path.starts_with(managed_root(host, &main_root)) { return None; } - let branch = git(&path, &["rev-parse", "--abbrev-ref", "HEAD"]).ok()?; - let dirty = !git(&path, &["status", "--porcelain"]).ok()?.is_empty(); + let branch = git(host, &path, &["rev-parse", "--abbrev-ref", "HEAD"]).ok()?; + let dirty = !git(host, &path, &["status", "--porcelain"]) + .ok()? + .is_empty(); Some(ManagedWorktree { path, branch, @@ -172,26 +192,26 @@ pub fn managed(cwd: &Path) -> Option { /// through symlinks, and on Windows canonicalize adds a `\\?\` verbatim prefix /// that git-reported paths lack, so comparing raw would never match. A /// vanished path never counts as occupying. -pub fn occupied(path: &Path, cwds: &[PathBuf]) -> bool { - let Ok(path) = std::fs::canonicalize(path) else { +pub fn occupied(host: &dyn Host, path: &Path, cwds: &[PathBuf]) -> bool { + let Ok(path) = host.canonicalize(path) else { return false; }; cwds.iter() - .any(|c| std::fs::canonicalize(c).is_ok_and(|c| c.starts_with(&path))) + .any(|c| host.canonicalize(c).is_ok_and(|c| c.starts_with(&path))) } /// Remove a managed worktree (`git worktree remove`, `--force` to discard /// uncommitted changes), then best-effort delete its branch with `-d` — so a /// branch carrying unmerged commits survives the cleanup. -pub fn remove(wt: &ManagedWorktree, force: bool) -> Result<(), String> { +pub fn remove(host: &dyn Host, wt: &ManagedWorktree, force: bool) -> Result<(), String> { let path = wt.path.to_str().ok_or("worktree path is not valid UTF-8")?; let mut args = vec!["worktree", "remove"]; if force { args.push("--force"); } args.push(path); - git(&wt.main_root, &args)?; - let _ = git(&wt.main_root, &["branch", "-d", &wt.branch]); + git(host, &wt.main_root, &args)?; + let _ = git(host, &wt.main_root, &["branch", "-d", &wt.branch]); Ok(()) } @@ -200,11 +220,12 @@ pub fn remove(wt: &ManagedWorktree, force: bool) -> Result<(), String> { /// the *main* repository even when `cwd` is itself inside a linked worktree /// (a worktree tab spawning another worktree), so checkouts never nest. The /// common git-dir is `
/.git`, whose parent is the main root. -fn repo_dir(cwd: &Path) -> Result<(PathBuf, PathBuf), String> { - let repo_root = git(cwd, &["rev-parse", "--show-toplevel"]) +fn repo_dir(host: &dyn Host, cwd: &Path) -> Result<(PathBuf, PathBuf), String> { + let repo_root = git(host, cwd, &["rev-parse", "--show-toplevel"]) .map_err(|_| "not inside a git repository".to_string())?; let repo_root = PathBuf::from(repo_root); let main_root = git( + host, cwd, &["rev-parse", "--path-format=absolute", "--git-common-dir"], ) @@ -212,7 +233,7 @@ fn repo_dir(cwd: &Path) -> Result<(PathBuf, PathBuf), String> { .map(PathBuf::from) .and_then(|d| d.parent().map(Path::to_path_buf)) .unwrap_or_else(|| repo_root.clone()); - let dir = main_root.join(".tty7").join("worktrees"); + let dir = managed_root(host, &main_root); Ok((repo_root, dir)) } @@ -220,15 +241,15 @@ fn repo_dir(cwd: &Path) -> Result<(PathBuf, PathBuf), String> { /// (retried until both the branch and the directory are unused, with a /// numeric-suffix fallback so a saturated pool still terminates) and the /// currently checked-out branch as the start point. -pub fn defaults(cwd: &Path) -> Result { - let (repo_root, dir) = repo_dir(cwd)?; +pub fn defaults(host: &dyn Host, cwd: &Path) -> Result { + let (repo_root, dir) = repo_dir(host, cwd)?; let mut state = seed(); let mut name = candidate(&mut state); for attempt in 0..64 { // Both the ref and the directory must be free — a stale directory from a // hand-removed worktree would make `git worktree add` fail either way. - if !branch_exists(&repo_root, &name) && !dir.join(&name).exists() { + if !branch_exists(host, &repo_root, &name) && !host.exists(&host.join(&dir, &name)) { break; } name = if attempt < 32 { @@ -239,7 +260,7 @@ pub fn defaults(cwd: &Path) -> Result { } // Detached HEAD (or an unborn branch) has no abbrev-ref; start from HEAD. - let base = git(&repo_root, &["rev-parse", "--abbrev-ref", "HEAD"]) + let base = git(host, &repo_root, &["rev-parse", "--abbrev-ref", "HEAD"]) .unwrap_or_else(|_| "HEAD".to_string()); Ok(WorktreeDefaults { name, base, dir }) } @@ -248,30 +269,32 @@ pub fn defaults(cwd: &Path) -> Result { /// `/.tty7/worktrees/`, on new branch `branch` starting from /// `base`. Branch and base validity is git's to judge; the directory name only /// has to stay a single path component so it can't escape the managed root. -pub fn create(cwd: &Path, req: &WorktreeRequest) -> Result { +pub fn create(host: &dyn Host, cwd: &Path, req: &WorktreeRequest) -> Result { if req.name.is_empty() || req.name == "." || req.name == ".." || req.name.contains(['/', '\\']) { return Err(format!("invalid worktree name \"{}\"", req.name)); } - let (repo_root, dir) = repo_dir(cwd)?; - std::fs::create_dir_all(&dir).map_err(|e| format!("cannot create {}: {e}", dir.display()))?; + let (repo_root, dir) = repo_dir(host, cwd)?; + host.create_dir(&dir, true) + .map_err(|e| format!("cannot create {}: {e}", dir.display()))?; // A `*` gitignore inside `.tty7/` keeps the whole tree (checkouts included, // the ignore file itself too) out of the repository's `git status`, without // ever editing the repo's own .gitignore. Best-effort: a failed write only // costs status noise, never the worktree. - let ignore = dir - .parent() - .expect(".tty7/worktrees has a parent") - .join(".gitignore"); - if !ignore.exists() { - let _ = std::fs::write(&ignore, "*\n"); + let ignore = host.join( + dir.parent().expect(".tty7/worktrees has a parent"), + ".gitignore", + ); + if !host.exists(&ignore) { + let _ = host.write_file(&ignore, b"*\n"); } - let path = dir.join(&req.name); - if path.exists() { + let path = host.join(&dir, &req.name); + if host.exists(&path) { return Err(format!("{} already exists", path.display())); } git( + host, &repo_root, &[ "worktree", @@ -334,6 +357,13 @@ mod tests { dir } + /// The host every test drives: this machine. `LocalHost` is what the GUI + /// hands these functions today, so testing against it tests the real path; + /// a remote host is covered by the conformance suite instead. + fn h() -> crate::host::SharedHost { + crate::host::local::LocalHost::new() + } + /// The simplest sensible request: directory and branch share `name`, /// starting from HEAD — what the sheet submits when nothing is edited. fn req(name: &str) -> WorktreeRequest { @@ -353,24 +383,13 @@ mod tests { assert!(NOUNS.contains(&n)); } - #[test] - fn is_inside_repo_scans_upward_for_dot_git() { - let repo = temp_repo("probe"); - let sub = repo.join("deep/nested"); - std::fs::create_dir_all(&sub).unwrap(); - assert!(is_inside_repo(&sub)); - let plain = scratch("probe-plain"); - assert!(!is_inside_repo(&plain)); - let _ = std::fs::remove_dir_all(&repo); - let _ = std::fs::remove_dir_all(&plain); - } - #[test] fn defaults_proposes_fresh_name_current_branch_and_target_dir() { + let h = h(); let repo = temp_repo("dflt"); - let d = defaults(&repo).unwrap(); - assert!(!branch_exists(&repo, &d.name)); - let head = git(&repo, &["rev-parse", "--abbrev-ref", "HEAD"]).unwrap(); + let d = defaults(&*h, &repo).unwrap(); + assert!(!branch_exists(&*h, &repo, &d.name)); + let head = git(&*h, &repo, &["rev-parse", "--abbrev-ref", "HEAD"]).unwrap(); assert_eq!(d.base, head); // The target dir is the repo's own `.tty7/worktrees` (git reports the // canonical root: /var → /private/var on macOS). @@ -381,15 +400,16 @@ mod tests { #[test] fn create_makes_worktree_on_new_branch_inside_the_repo() { + let h = h(); let repo = temp_repo("repo"); - let wt = create(&repo, &req("quiet-otter")).unwrap(); + let wt = create(&*h, &repo, &req("quiet-otter")).unwrap(); assert!(wt.path.join("a.txt").exists()); - assert!(branch_exists(&repo, &wt.branch)); + assert!(branch_exists(&*h, &repo, &wt.branch)); // The worktree lands under `/.tty7/worktrees/`… let canon = plain(&std::fs::canonicalize(&repo).unwrap()); assert_eq!(plain(&wt.path), canon.join(".tty7/worktrees/quiet-otter")); // …on the new branch… - let head = git(&wt.path, &["rev-parse", "--abbrev-ref", "HEAD"]).unwrap(); + let head = git(&*h, &wt.path, &["rev-parse", "--abbrev-ref", "HEAD"]).unwrap(); assert_eq!(head, wt.branch); // …and the auto-written `.tty7/.gitignore` keeps the main repo's // status clean despite the checkout living inside it. @@ -397,10 +417,10 @@ mod tests { std::fs::read_to_string(canon.join(".tty7/.gitignore")).unwrap(), "*\n" ); - assert_eq!(git(&repo, &["status", "--porcelain"]).unwrap(), ""); + assert_eq!(git(&*h, &repo, &["status", "--porcelain"]).unwrap(), ""); // A second request colliding on the directory is refused up front. assert!( - create(&repo, &req("quiet-otter")) + create(&*h, &repo, &req("quiet-otter")) .unwrap_err() .contains("already exists") ); @@ -409,6 +429,7 @@ mod tests { #[test] fn create_honors_custom_branch_and_base() { + let h = h(); let repo = temp_repo("base"); // A `stable` branch one commit behind the default branch's HEAD. sh(&repo, &["git", "branch", "stable"]); @@ -416,6 +437,7 @@ mod tests { sh(&repo, &["git", "add", "."]); sh(&repo, &["git", "commit", "-q", "-m", "second"]); let wt = create( + &*h, &repo, &WorktreeRequest { name: "my-dir".into(), @@ -426,7 +448,7 @@ mod tests { .unwrap(); // Directory and branch names diverge as requested… assert_eq!(wt.path.file_name().unwrap().to_str().unwrap(), "my-dir"); - let head = git(&wt.path, &["rev-parse", "--abbrev-ref", "HEAD"]).unwrap(); + let head = git(&*h, &wt.path, &["rev-parse", "--abbrev-ref", "HEAD"]).unwrap(); assert_eq!(head, "feat/my-branch"); // …and the checkout starts from `stable` (no b.txt yet). assert!(wt.path.join("a.txt").exists()); @@ -436,12 +458,13 @@ mod tests { #[test] fn create_rejects_escaping_names() { + let h = h(); let repo = temp_repo("names"); for bad in ["", ".", "..", "a/b", "a\\b"] { let mut r = req("x"); r.name = bad.into(); assert!( - create(&repo, &r) + create(&*h, &repo, &r) .unwrap_err() .contains("invalid worktree name"), "{bad:?} should be rejected" @@ -452,21 +475,23 @@ mod tests { #[test] fn create_from_a_linked_worktree_lands_in_the_main_repo() { + let h = h(); let repo = temp_repo("nest"); - let first = create(&repo, &req("first-wt")).unwrap(); + let first = create(&*h, &repo, &req("first-wt")).unwrap(); // Spawn the second worktree from *inside* the first: it must land in // the main repo's `.tty7/worktrees`, not nest inside the first checkout. - let second = create(&first.path, &req("second-wt")).unwrap(); + let second = create(&*h, &first.path, &req("second-wt")).unwrap(); assert_eq!(second.path.parent().unwrap(), first.path.parent().unwrap()); let _ = std::fs::remove_dir_all(&repo); } #[test] fn managed_resolves_managed_checkouts_and_remove_cleans_up() { + let h = h(); let repo = temp_repo("mg"); - let wt = create(&repo, &req("mg-wt")).unwrap(); + let wt = create(&*h, &repo, &req("mg-wt")).unwrap(); // The repo root itself is never "managed"… - assert!(managed(&repo).is_none()); + assert!(managed(&*h, &repo).is_none()); // …nor is a linked worktree the user made outside `.tty7/worktrees`. let own = scratch("mg-own"); let _ = std::fs::remove_dir_all(&own); @@ -481,47 +506,122 @@ mod tests { own.to_str().unwrap(), ], ); - assert!(managed(&own).is_none()); + assert!(managed(&*h, &own).is_none()); // Any path inside the managed checkout resolves to it, initially clean. let sub = wt.path.join("sub"); std::fs::create_dir_all(&sub).unwrap(); - let m = managed(&sub).unwrap(); + let m = managed(&*h, &sub).unwrap(); assert_eq!(m.branch, wt.branch); assert_eq!(m.path, wt.path); assert!(!m.dirty); // Uncommitted changes flip `dirty` and block a plain remove; --force // discards them. The branch (no unique commits) is deleted with it. std::fs::write(wt.path.join("b.txt"), "b").unwrap(); - let m = managed(&wt.path).unwrap(); + let m = managed(&*h, &wt.path).unwrap(); assert!(m.dirty); - assert!(remove(&m, false).is_err()); - remove(&m, true).unwrap(); + assert!(remove(&*h, &m, false).is_err()); + remove(&*h, &m, true).unwrap(); assert!(!wt.path.exists()); - assert!(!branch_exists(&repo, &wt.branch)); + assert!(!branch_exists(&*h, &repo, &wt.branch)); let _ = std::fs::remove_dir_all(&repo); let _ = std::fs::remove_dir_all(&own); } + /// `Host::git` runs every invocation with `GIT_OPTIONAL_LOCKS=0`, which + /// this module did *not* set when it spawned `git` itself. That variable is + /// the one thing in the unified invocation with any claim to affect a + /// *write*, so the whole create → list → remove → delete-branch path is + /// exercised end to end under it rather than argued about. + /// + /// It is safe by git's own definition — "complete any requested operation + /// without performing any optional sub-operations that require taking a + /// lock" (`git(1)`, GIT_OPTIONAL_LOCKS). `worktree add` and `branch -d` are + /// the *requested* operations, never optional sub-operations, and the locks + /// they need are taken regardless. This test is what keeps that from being + /// a reading of the manual: it fails if a git version ever decides + /// otherwise. + #[test] + fn writes_survive_the_optional_locks_invariant() { + let h = h(); + let repo = temp_repo("locks"); + // That the variable is *set* on every `Host::git` is asserted once, for + // every host, by the conformance suite's `git_optional_locks_env_is_set` + // — not re-derived here. What this test owns is the consequence. + + // add — the write the contract flags as the one to prove. + let wt = create(&*h, &repo, &req("lock-wt")).unwrap(); + assert!(wt.path.join("a.txt").exists()); + + // list — the new checkout is really registered, not merely on disk. + let list = git(&*h, &repo, &["worktree", "list", "--porcelain"]).unwrap(); + assert!( + list.lines() + .any(|l| l.starts_with("branch ") && l.ends_with(&wt.branch)), + "worktree list must show the new checkout: {list}" + ); + + // A commit inside the checkout: an index write, the operation whose + // *optional* index refresh is what the variable suppresses. + std::fs::write(wt.path.join("c.txt"), "c").unwrap(); + git(&*h, &wt.path, &["add", "."]).unwrap(); + git( + &*h, + &wt.path, + &[ + "-c", + "user.email=t@t", + "-c", + "user.name=t", + "commit", + "-qm", + "c", + ], + ) + .unwrap(); + assert_eq!(git(&*h, &wt.path, &["status", "--porcelain"]).unwrap(), ""); + + // remove + `branch -d`: the branch now carries a commit the main branch + // does not, so the best-effort `-d` correctly declines and the branch + // survives — the safety property `remove` documents. + let m = managed(&*h, &wt.path).unwrap(); + remove(&*h, &m, false).unwrap(); + assert!(!wt.path.exists()); + assert!(branch_exists(&*h, &repo, &wt.branch)); + // …and a branch with nothing unique on it is deleted, so the `-d` is + // genuinely running rather than always failing. + let plain_wt = create(&*h, &repo, &req("lock-wt2")).unwrap(); + let m = managed(&*h, &plain_wt.path).unwrap(); + remove(&*h, &m, false).unwrap(); + assert!(!branch_exists(&*h, &repo, &plain_wt.branch)); + + let _ = std::fs::remove_dir_all(&repo); + } + #[test] fn occupied_detects_live_cwds_inside_the_worktree() { + let h = h(); let repo = temp_repo("occ"); - let wt = create(&repo, &req("occ-wt")).unwrap(); + let wt = create(&*h, &repo, &req("occ-wt")).unwrap(); let inside = wt.path.join("deep"); std::fs::create_dir_all(&inside).unwrap(); - assert!(occupied(&wt.path, &[repo.clone(), inside])); + assert!(occupied(&*h, &wt.path, &[repo.clone(), inside])); // Cwds elsewhere in the repo don't count… - assert!(!occupied(&wt.path, &[repo.clone()])); + assert!(!occupied(&*h, &wt.path, std::slice::from_ref(&repo))); // …and neither does a cwd that no longer exists. - assert!(!occupied(&wt.path, &[wt.path.join("gone")])); + assert!(!occupied(&*h, &wt.path, &[wt.path.join("gone")])); let _ = std::fs::remove_dir_all(&repo); } #[test] fn create_outside_a_repo_errors() { + let h = h(); let plain = scratch("plain"); - let err = create(&plain, &req("x")).unwrap_err(); + let err = create(&*h, &plain, &req("x")).unwrap_err(); assert_eq!(err, "not inside a git repository"); - assert_eq!(defaults(&plain).unwrap_err(), "not inside a git repository"); + assert_eq!( + defaults(&*h, &plain).unwrap_err(), + "not inside a git repository" + ); let _ = std::fs::remove_dir_all(&plain); } } diff --git a/crates/tty7-core/src/daemon/control.rs b/crates/tty7-core/src/daemon/control.rs new file mode 100644 index 00000000..dd93ad87 --- /dev/null +++ b/crates/tty7-core/src/daemon/control.rs @@ -0,0 +1,2917 @@ +//! The **control** dialect: a multiplexed request/response channel for +//! filesystem + git operations against a machine that isn't this one. +//! +//! The pane protocol in [`super::protocol`] is deliberately unmultiplexed — one +//! connection carries one PTY, requests have no ids, and a control request is a +//! short-lived connection of its own. That shape stops working the moment the +//! peer is on the other side of an ocean: a file tree expanding a directory +//! must not queue behind a `git status` that takes two seconds. So control gets +//! its own dialect on the same framing, with request ids and out-of-order +//! replies. +//! +//! ## Relationship to [`super::protocol`] +//! +//! | Shared | Separate | +//! |---|---| +//! | Outer frame `[u32 LE payload_len][u8 kind][payload]` | Kind space (control owns **60-63**) | +//! | [`MAX_FRAME`] (64 MiB) | Message enums, payload layout | +//! | `write_frame` / `read_frame` / `take_frame` | Request ids, timeouts, cancellation | +//! +//! Control kinds live in this module's own [`kind`], not `protocol`'s (which is +//! private by design). The two spaces never mix on one connection in a way that +//! could be ambiguous: a peer that speaks control announces it through +//! [`crate::daemon::protocol::PROTOCOL_VERSION`] ≥ 3 plus the +//! [`feature::CONTROL`] capability bit, and 60-63 sit clear of every range the +//! pane protocol has reserved (WS3 auth 15-19, WS4 forwards 20-24, SFTP 30-36) +//! and clear of the **retired** kind 13 (once `SPAWN_MANAGED_SSH`), which is +//! never reused — an old daemon would decode it as a pane spawn and silently do +//! the wrong thing rather than reporting an unknown kind. +//! +//! ## Payload layouts +//! +//! ```text +//! HELLO / HELLO_OK (60) +//! ┌──────────────────────────┐ +//! │ JSON (payload_len bytes) │ +//! └──────────────────────────┘ +//! +//! REQUEST / RESPONSE (61), CANCEL (63, C→S), EVENT (63, S→C) +//! ┌───────────────┬───────────────┬──────────────────┐ +//! │ u64 LE req_id │ u32 LE json_n │ JSON (json_n B) │ +//! └───────────────┴───────────────┴──────────────────┘ +//! payload_len == 12 + json_n +//! +//! REQUEST_BLOB / RESPONSE_BLOB (62) +//! ┌───────────────┬───────────────┬─────────────────┬─────────────────────┐ +//! │ u64 LE req_id │ u32 LE json_n │ JSON (json_n B) │ raw blob (the rest) │ +//! └───────────────┴───────────────┴─────────────────┴─────────────────────┘ +//! blob_len == payload_len - 12 - json_n +//! ``` +//! +//! Every non-`HELLO` frame carries a `req_id` even when it can't have one +//! (events are always 0). The eight redundant bytes buy a single header parser: +//! read `u64`, read `u32`, take the JSON, *then* branch on kind. +//! +//! Bulk payloads keep a JSON head rather than being bare bytes because the +//! bytes alone can't carry their own parameters — `WriteFile` needs the target +//! path beside the content, and `ReadFile`'s reply needs the [`Meta`] the +//! editor would otherwise have to fetch in a second round trip. +//! +//! ## Request ids +//! +//! | | | +//! |---|---| +//! | Allocated by | the client, only; the server never mints one | +//! | Range | starts at 1, `fetch_add(1)`; **0 is reserved for server pushes** | +//! | Matching | out of order — a reply is claimed by id, not by arrival order | +//! | Unknown id in a reply | dropped silently (a timed-out request's reply may still land) | +//! | Shape | strictly one reply per request; no streaming, no continuation frames | + +use std::collections::HashMap; +use std::io::{self, Read, Write}; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::mpsc::{RecvTimeoutError, SyncSender, sync_channel}; +use std::sync::{Arc, Condvar, Mutex}; +use std::time::{Duration, Instant}; + +use serde::{Deserialize, Serialize}; + +use super::protocol::{read_frame, write_frame}; + +/// The control dialect's own version, negotiated in [`ControlHello`] and +/// independent of [`crate::daemon::protocol::PROTOCOL_VERSION`]: the pane +/// protocol and the control dialect evolve on separate clocks, and a remote +/// `tty7-server` speaks control without necessarily serving panes at all. +pub const CONTROL_VERSION: u32 = 1; + +/// Paths coalesced into one [`ControlEvent::Watch`] window before the server +/// gives up on precision and sends [`ControlEvent::WatchOverflow`] instead, +/// which the client answers by invalidating the whole tree. A cap rather than +/// an unbounded batch because `cargo build` in a watched root can touch tens of +/// thousands of paths in a single 100 ms window, and re-listing beats shipping +/// them. +pub const WATCH_BURST_CAP: usize = 1024; + +/// Rolling window the server coalesces filesystem events into. The local +/// implementation uses the same figure on purpose — a watcher that is "helpfully" +/// more responsive locally makes every timing-sensitive behavior diverge between +/// a local and a remote workspace. +pub const WATCH_COALESCE_WINDOW: Duration = Duration::from_millis(100); + +// --------------------------------------------------------------------------- +// Kinds +// --------------------------------------------------------------------------- + +/// Control frame kind bytes. +/// +/// Client→server and server→client are independent spaces (a connection always +/// knows which direction it is reading), so the numeric overlap between, say, +/// [`kind::REQUEST`] and [`kind::RESPONSE`] is deliberate and mirrors how +/// `protocol`'s two spaces already work. +/// +/// 64-69 are held open for later control frames (streaming replies, +/// backpressure signals) so the dialect never has to claim a second range. +pub mod kind { + // ----- client -> server ------------------------------------------------- + + /// Handshake. The only control frame without a `req_id`. + pub const HELLO: u8 = 60; + /// A request whose parameters fit in JSON. + pub const REQUEST: u8 = 61; + /// A request with bulk bytes trailing the JSON (`WriteFile`). + pub const REQUEST_BLOB: u8 = 62; + /// Abandon a request. Best-effort on the server; the client has already + /// given up by the time it sends this. + pub const CANCEL: u8 = 63; + + // ----- server -> client ------------------------------------------------- + + /// Handshake reply. Sent even on a version mismatch (then the server hangs + /// up), so the client can report *which* version it met. + pub const HELLO_OK: u8 = 60; + /// A reply whose payload fits in JSON. + pub const RESPONSE: u8 = 61; + /// A reply with bulk bytes trailing the JSON (`ReadFile`). + pub const RESPONSE_BLOB: u8 = 62; + /// An unsolicited push. `req_id` is always 0. + pub const EVENT: u8 = 63; +} + +/// Capability strings advertised in [`crate::daemon::protocol::DaemonVersion::features`]. +/// +/// These exist so a capability added after protocol v3 doesn't need another +/// version bump: a peer answers "what can you do" with a list rather than a +/// number, and an unknown string is simply a capability this build won't use. +pub mod feature { + /// Speaks the control dialect: kinds 60-63, this module's framing. + pub const CONTROL: &str = "control"; + /// Serves [`super::ControlRequest`]'s filesystem and git methods — i.e. can + /// back a remote `Host`. Distinct from [`CONTROL`] because a peer could + /// speak the dialect while exposing only the workspace store. + pub const HOST_RPC: &str = "host-rpc"; + /// Serves the `Workspace*` requests. + pub const WORKSPACE_STORE: &str = "workspace-store"; + /// Can be launched as `--stdio` and bridge its own stdin/stdout to the + /// machine-local socket (the fallback when `AllowStreamLocalForwarding` is + /// off, the only option under WSL, and how the CI end-to-end test runs). + pub const STDIO_BRIDGE: &str = "stdio-bridge"; +} + +// --------------------------------------------------------------------------- +// Payload types +// --------------------------------------------------------------------------- + +// The shapes that cross the wire in both directions — `Entry`, `Meta`, +// `MTime`, `Output`, `SearchHit` — are the `Host` trait's own types, re-exported +// here rather than mirrored. A parallel "wire" copy would be a standing invitation +// to let the two drift, and every drift between them is a silent +// mistranslation rather than a compile error. +pub use crate::host::{Entry, MTime, Meta, Output, SearchHit}; + +// --------------------------------------------------------------------------- +// Requests +// --------------------------------------------------------------------------- + +/// Everything a client can ask a control peer to do. +/// +/// **Paths are `String`, never `PathBuf`.** `PathBuf`'s serde representation of +/// a non-UTF-8 path is platform-dependent, and the two ends of this connection +/// are routinely different operating systems. Remote paths are UTF-8 POSIX; a +/// non-UTF-8 name on the server is returned lossily by `ReadDir`, matching what +/// the file tree already does locally with `to_string_lossy`. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ControlRequest { + // ----- liveness --------------------------------------------------------- + Ping, + + // ----- filesystem reads ------------------------------------------------- + /// `root` bounds the gitignore chain: rules are evaluated from `root` down + /// to `dir`, deeper wins, `!` re-includes. `None` means nothing is ignored + /// except `.git`. + ReadDir { + dir: String, + root: Option, + }, + Stat { + path: String, + }, + Exists { + path: String, + }, + Canonicalize { + path: String, + }, + /// `max_bytes` is enforced **on the server**: over the limit it answers + /// [`WireErrorKind::FileTooLarge`] rather than shipping the file and having + /// the client throw it away. + ReadFile { + path: String, + max_bytes: u64, + }, + /// Breadth-first substring match over names, run entirely on the server. + /// The local implementation walks up to 2000 directories; doing that a + /// directory at a time over a transcontinental link would be 2000 round + /// trips. + /// + /// `show_hidden` is here rather than left to the client because it also + /// governs *descent*: with it false the walk never enters an ignored or + /// dot-prefixed directory, which is what stops `node_modules` from + /// consuming the whole `max_dirs` budget. Filtering the hits afterwards on + /// the client would give a different — and much slower — answer. + Search { + roots: Vec, + query: String, + limit: u64, + max_dirs: u64, + show_hidden: bool, + }, + + // ----- filesystem writes ------------------------------------------------ + /// Content rides in the blob of a [`kind::REQUEST_BLOB`] frame. + WriteFile { + path: String, + }, + /// Exclusive create; `AlreadyExists` if the path is taken (`File::create_new`). + CreateFileNew { + path: String, + }, + /// `recursive` picks `create_dir_all` over `create_dir`. + CreateDir { + path: String, + recursive: bool, + }, + /// The server guarantees `AlreadyExists` when `to` exists — the client must + /// not probe first, which would be both an extra round trip and a TOCTOU. + Rename { + from: String, + to: String, + }, + /// `recursive` only means anything for directories. + Remove { + path: String, + recursive: bool, + }, + + // ----- git -------------------------------------------------------------- + /// The work-tree root containing `path`: nearest ancestor with a `.git` + /// (directory or linked-worktree file). One server-side walk, not a ladder + /// of round trips. + RepoRoot { + path: String, + }, + /// `git -C `. A non-zero exit is a successful reply carrying + /// that status, **not** an error — see [`WireError`]. + Git { + cwd: String, + args: Vec, + }, + + // ----- watch ------------------------------------------------------------ + /// Open a subscription; the server answers with a [`ReplyOk::WatchId`]. + WatchOpen { + dirs: Vec, + }, + /// Replace a subscription's directory set wholesale; the server diffs. + WatchSet { + id: u64, + dirs: Vec, + }, + WatchClose { + id: u64, + }, + + // ----- workspace store (M5; the slots exist, the server doesn't yet) ----- + WorkspaceList, + WorkspaceGet { + id: String, + }, + WorkspacePut { + id: String, + json: serde_json::Value, + }, + WorkspaceDelete { + id: String, + }, + + // ----- attachment (M6's takeover, design §10) --------------------------- + /// Claim a workspace for this connection's session, taking it over from + /// whoever held it. The server answers + /// [`ReplyOk::Attached`] and pushes [`ControlEvent::Preempted`] to the + /// displaced session. + /// + /// A *request* rather than only the [`ControlHello::workspace`] field + /// because a client holds **one connection per machine** and may have + /// several of that machine's workspaces open at once — a hello can name one + /// workspace, and the second window would otherwise need a second link to + /// the same box. Both paths run the identical server-side claim; the hello + /// field remains the shorthand for a connection dedicated to one workspace, + /// which is what the end-to-end tests use. + WorkspaceAttach { + id: String, + }, + /// Release a workspace this connection holds. Token-checked on the server, + /// so a session that has *already* been preempted cannot evict the client + /// that took over from it by tidying up afterwards. + WorkspaceDetach { + id: String, + }, +} + +impl ControlRequest { + /// How long the client waits before giving up on this request. + /// + /// Per-method rather than one global figure because the spread is real: a + /// `stat` that hasn't answered in five seconds is not going to, while a + /// `git status` on a cold large repo legitimately takes ten. A single + /// conservative timeout would make the fast paths feel broken; a single + /// aggressive one would break the slow paths. + /// + /// A timeout **never drops the connection** (§6.8): the request fails with + /// `TimedOut`, a [`kind::CANCEL`] goes out, and every other in-flight + /// request is untouched. + pub fn deadline(&self) -> Duration { + use ControlRequest::*; + match self { + Ping => Duration::from_secs(5), + ReadDir { .. } + | Stat { .. } + | Exists { .. } + | Canonicalize { .. } + | RepoRoot { .. } + | WatchOpen { .. } + | WatchSet { .. } + | WatchClose { .. } => Duration::from_secs(5), + ReadFile { .. } | WriteFile { .. } => Duration::from_secs(30), + CreateFileNew { .. } | CreateDir { .. } | Rename { .. } | Remove { .. } => { + Duration::from_secs(10) + } + Git { .. } | Search { .. } => Duration::from_secs(20), + WorkspaceList | WorkspaceGet { .. } | WorkspacePut { .. } | WorkspaceDelete { .. } => { + Duration::from_secs(10) + } + // An attach is bookkeeping plus at most one push to a peer that may + // be wedged — the push is `try`-shaped on the server, so this only + // has to cover a slow link, not a slow client. + WorkspaceAttach { .. } | WorkspaceDetach { .. } => Duration::from_secs(10), + } + } + + /// Whether this request carries bulk bytes, i.e. rides + /// [`kind::REQUEST_BLOB`] instead of [`kind::REQUEST`]. + pub fn takes_blob(&self) -> bool { + matches!(self, ControlRequest::WriteFile { .. }) + } + + /// Whether this request's *reply* carries bulk bytes. + pub fn returns_blob(&self) -> bool { + matches!(self, ControlRequest::ReadFile { .. }) + } +} + +// --------------------------------------------------------------------------- +// Replies +// --------------------------------------------------------------------------- + +/// A reply to one request: the operation's value, or why it couldn't run. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum ControlReply { + #[serde(rename = "ok")] + Ok(ReplyOk), + #[serde(rename = "err")] + Err(WireError), +} + +impl ControlReply { + /// Fold into the `io::Result` shape every `Host` method returns. + pub fn into_result(self) -> io::Result { + match self { + ControlReply::Ok(v) => Ok(v), + ControlReply::Err(e) => Err(e.into_io()), + } + } +} + +/// The successful half of a reply. One variant per result *shape*, not per +/// request — several requests answer `Unit`, and `Stat` and `WriteFile` both +/// answer `Meta`. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ReplyOk { + Unit, + Pong, + Entries(Vec), + Meta(Meta), + Bool(bool), + Path(String), + OptPath(Option), + /// `ReadFile`: the content is the frame's blob, this is only its metadata. + FileMeta { + meta: Meta, + }, + Hits(Vec), + Output(Output), + WatchId(u64), + /// The workspace store's payload (M5). + Json(serde_json::Value), + /// [`ControlRequest::WorkspaceAttach`] succeeded. `took_over_from` names the + /// machine whose session was displaced, so the client that *did* the taking + /// can say so — design §10 only specifies the notice going the other way, + /// but a takeover the new client cannot see is one the user cannot explain. + Attached { + took_over_from: Option, + }, +} + +/// An operation that could not be performed. +/// +/// **A non-zero `git` exit is not this.** It is `Ok(Output { status: Some(1) })`. +/// That distinction is what lets `git_status`'s `Option` semantics +/// survive the move behind the `Host` trait unchanged. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct WireError { + pub kind: WireErrorKind, + /// Human-readable, shown to the user verbatim. Carries the path when a path + /// is the point, and nothing else about the server's internals. + pub msg: String, +} + +/// The closed set of error classes that cross the wire. +/// +/// `io::ErrorKind` can't be used directly: it isn't `Serialize`, and it is +/// `#[non_exhaustive]`, so its variant set is not a stable wire contract. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum WireErrorKind { + NotFound, + PermissionDenied, + AlreadyExists, + InvalidInput, + NotADirectory, + IsADirectory, + DirectoryNotEmpty, + /// Past the caller's `max_bytes`. + FileTooLarge, + /// No git on the server, or it wouldn't start. + GitUnavailable, + TimedOut, + /// The control connection died. Synthesized by the client; never sent. + ConnectionReset, + /// Anything else; `msg` carries the original description. + Other, +} + +impl WireError { + pub fn new(kind: WireErrorKind, msg: impl Into) -> Self { + WireError { + kind, + msg: msg.into(), + } + } + + /// Classify an `io::Error` for the wire. The inverse of [`WireError::into_io`] + /// on every kind this table names. + pub fn from_io(e: &io::Error) -> Self { + use io::ErrorKind as K; + let kind = match e.kind() { + K::NotFound => WireErrorKind::NotFound, + K::PermissionDenied => WireErrorKind::PermissionDenied, + K::AlreadyExists => WireErrorKind::AlreadyExists, + K::InvalidInput | K::InvalidFilename => WireErrorKind::InvalidInput, + K::NotADirectory => WireErrorKind::NotADirectory, + K::IsADirectory => WireErrorKind::IsADirectory, + K::DirectoryNotEmpty => WireErrorKind::DirectoryNotEmpty, + K::FileTooLarge => WireErrorKind::FileTooLarge, + K::TimedOut => WireErrorKind::TimedOut, + K::ConnectionReset | K::BrokenPipe | K::UnexpectedEof => WireErrorKind::ConnectionReset, + _ => WireErrorKind::Other, + }; + WireError { + kind, + msg: e.to_string(), + } + } + + /// Rebuild a local `io::Error`. `msg` becomes the error's payload, so a + /// notification shows what the server said rather than a generic class name. + pub fn into_io(self) -> io::Error { + io::Error::new(self.kind.to_io_kind(), self.msg) + } +} + +impl WireErrorKind { + /// The `io::ErrorKind` this class maps back to. + /// + /// `GitUnavailable` lands on `NotFound` — it means the binary isn't there — + /// and `Other` on `Other`, which is why the class is kept beside a `msg`. + pub fn to_io_kind(self) -> io::ErrorKind { + use io::ErrorKind as K; + match self { + WireErrorKind::NotFound | WireErrorKind::GitUnavailable => K::NotFound, + WireErrorKind::PermissionDenied => K::PermissionDenied, + WireErrorKind::AlreadyExists => K::AlreadyExists, + WireErrorKind::InvalidInput => K::InvalidInput, + WireErrorKind::NotADirectory => K::NotADirectory, + WireErrorKind::IsADirectory => K::IsADirectory, + WireErrorKind::DirectoryNotEmpty => K::DirectoryNotEmpty, + WireErrorKind::FileTooLarge => K::FileTooLarge, + WireErrorKind::TimedOut => K::TimedOut, + WireErrorKind::ConnectionReset => K::ConnectionReset, + WireErrorKind::Other => K::Other, + } + } +} + +// --------------------------------------------------------------------------- +// Events +// --------------------------------------------------------------------------- + +/// An unsolicited server push, carried on [`kind::EVENT`] with `req_id == 0`. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ControlEvent { + /// Filesystem changes, coalesced and deduplicated by the server over a + /// [`WATCH_COALESCE_WINDOW`]. + Watch { + id: u64, + paths: Vec, + }, + /// More than [`WATCH_BURST_CAP`] distinct paths changed in one window; + /// the client invalidates the subtree instead of applying a list. + WatchOverflow { + id: u64, + }, + + // ----- reserved for M5/M6; defined so the dialect doesn't need a bump --- + PaneExited { + pane_id: u64, + code: Option, + }, + AgentStatus { + pane_id: u64, + json: serde_json::Value, + }, + /// Design §10's takeover: someone else attached to `workspace`, so this + /// session no longer holds it. + /// + /// **`workspace` is not redundant.** One control connection carries a whole + /// machine and may hold several of its workspaces, so a push that named only + /// the new owner would leave the client unable to tell which of its windows + /// just went read-only. + Preempted { + workspace: String, + by: String, + }, + WorkspaceChanged { + id: String, + }, +} + +/// Where control events that are nobody's *local* business end up. +/// +/// [`RemoteHost`](crate::host::remote::RemoteHost) routes `Watch` and +/// `WatchOverflow` into the subscription that asked for them, because those +/// belong to a caller that is still holding a `WatchSub`. The rest — +/// `Preempted`, `PaneExited`, `AgentStatus`, `WorkspaceChanged` — are about a +/// *window*, and the host layer has no window. +/// +/// A process-wide observer rather than a parameter on `connect_with` because +/// the interested party (the GUI's connection state machine) is one thing, +/// while connections are made in three places that would each have to be taught +/// to thread it through. Last registration wins; `None` drops events, which is +/// what a headless `tty7-server` wants. +pub type EventObserver = Arc; + +static EVENT_OBSERVER: Mutex> = Mutex::new(None); + +/// Install the observer. Idempotent, last-call-wins; the GUI calls it at +/// startup. +pub fn set_event_observer(f: EventObserver) { + if let Ok(mut slot) = EVENT_OBSERVER.lock() { + *slot = Some(f); + } +} + +/// Hand an unrouted event to the observer, if there is one. +/// +/// **Runs on a reader thread**, so the observer must not block — the intended +/// shape is a mailbox push, exactly like the watch forwarders. +pub fn observe_event(host: crate::host::HostId, event: ControlEvent) { + let observer = match EVENT_OBSERVER.lock() { + Ok(slot) => slot.clone(), + Err(_) => None, + }; + match observer { + Some(f) => f(host, event), + None => log::trace!("control event with nobody to hear it: {event:?}"), + } +} + +// --------------------------------------------------------------------------- +// Handshake +// --------------------------------------------------------------------------- + +/// The client's opening frame. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct ControlHello { + /// The control dialect the client speaks. See [`CONTROL_VERSION`]. + pub control_version: u32, + /// The workspace this connection is bound to. `None` uses the connection + /// for host RPC only, which is how the stdio end-to-end test drives it. + pub workspace: Option, + /// Session token and hostname, used later to decide takeover between two + /// clients claiming the same workspace. + pub client_token: String, + pub client_hostname: String, +} + +impl ControlHello { + /// A hello for a connection that only does host RPC. + pub fn host_rpc(client_token: impl Into, client_hostname: impl Into) -> Self { + ControlHello { + control_version: CONTROL_VERSION, + workspace: None, + client_token: client_token.into(), + client_hostname: client_hostname.into(), + } + } +} + +/// The server's answer. Sent **even when the versions don't match** — the +/// server then closes the connection, and this frame is the only way the client +/// learns which version it actually met (a `HELLO` has no `req_id`, so there is +/// no error reply to attach the mismatch to). +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct ControlHelloOk { + pub control_version: u32, + /// The server's [`crate::daemon::protocol::PROTOCOL_VERSION`]. Redundant + /// with the control version; kept for diagnostics. + pub protocol_version: u32, + /// The server binary's version string. Display only. + pub build: String, + /// The server's path separator — this is what backs `Host::separator`, and + /// why a Windows client can hold POSIX paths correctly. + pub separator: char, + /// The server's `$HOME`, so "new workspace" can default to `~` on the + /// remote rather than on the client. + pub home: String, + /// Capability bits; see [`feature`]. + #[serde(default)] + pub features: Vec, +} + +impl ControlHelloOk { + /// Whether the server advertises `name`. + pub fn has_feature(&self, name: &str) -> bool { + self.features.iter().any(|f| f == name) + } +} + +// --------------------------------------------------------------------------- +// Framing +// --------------------------------------------------------------------------- + +/// `u64 req_id` + `u32 json_len`. +const CONTROL_HEADER: usize = 12; + +fn to_json(value: &T) -> io::Result> { + serde_json::to_vec(value).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e)) +} + +fn from_json Deserialize<'de>>(bytes: &[u8]) -> io::Result { + serde_json::from_slice(bytes).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e)) +} + +fn invalid(msg: impl Into) -> io::Error { + io::Error::new(io::ErrorKind::InvalidData, msg.into()) +} + +/// Build a `[req_id][json_len][JSON][blob]` payload. +fn encode_body(req_id: u64, json: &[u8], blob: &[u8]) -> io::Result> { + let json_n = u32::try_from(json.len()) + .map_err(|_| invalid("control JSON exceeds the u32 length prefix"))?; + let mut out = Vec::with_capacity(CONTROL_HEADER + json.len() + blob.len()); + out.extend_from_slice(&req_id.to_le_bytes()); + out.extend_from_slice(&json_n.to_le_bytes()); + out.extend_from_slice(json); + out.extend_from_slice(blob); + Ok(out) +} + +/// Split a payload into `(req_id, json, blob)`. +/// +/// Every bound is checked before a slice is taken: a hostile `json_len` must +/// produce an `InvalidData` error, never a panic and never a read past the +/// payload. +fn decode_body(payload: &[u8]) -> io::Result<(u64, &[u8], &[u8])> { + if payload.len() < CONTROL_HEADER { + return Err(invalid(format!( + "control payload is {} bytes, shorter than the {CONTROL_HEADER}-byte header", + payload.len() + ))); + } + let req_id = u64::from_le_bytes(payload[..8].try_into().unwrap()); + let json_n = u32::from_le_bytes(payload[8..12].try_into().unwrap()) as usize; + let json_end = CONTROL_HEADER + .checked_add(json_n) + .ok_or_else(|| invalid("control json_len overflows the payload offset"))?; + if json_end > payload.len() { + return Err(invalid(format!( + "control json_len {json_n} runs past the {}-byte payload", + payload.len() + ))); + } + Ok(( + req_id, + &payload[CONTROL_HEADER..json_end], + &payload[json_end..], + )) +} + +/// Decode a payload that must not have a trailing blob (kinds 61 and 63). +fn decode_body_exact<'a>(payload: &'a [u8], what: &str) -> io::Result<(u64, &'a [u8])> { + let (req_id, json, blob) = decode_body(payload)?; + if !blob.is_empty() { + return Err(invalid(format!( + "{what} carries {} trailing bytes; only the *_BLOB kinds may", + blob.len() + ))); + } + Ok((req_id, json)) +} + +/// A request id of 0 is reserved for server pushes and is never valid on a +/// request, cancel or reply. +fn require_nonzero(req_id: u64, what: &str) -> io::Result<()> { + if req_id == 0 { + return Err(invalid(format!( + "{what} used req_id 0, which is reserved for server pushes" + ))); + } + Ok(()) +} + +// --------------------------------------------------------------------------- +// Messages +// --------------------------------------------------------------------------- + +/// A control frame travelling client → server. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum ControlClientMsg { + Hello(ControlHello), + Request { + req_id: u64, + req: ControlRequest, + }, + /// A request with bulk bytes; the JSON head still carries the parameters. + RequestBlob { + req_id: u64, + req: ControlRequest, + blob: Vec, + }, + Cancel { + req_id: u64, + }, +} + +impl ControlClientMsg { + /// Encode and write this message as one frame. + pub fn encode(&self, w: &mut W) -> io::Result<()> { + match self { + ControlClientMsg::Hello(hello) => write_frame(w, kind::HELLO, &to_json(hello)?), + ControlClientMsg::Request { req_id, req } => { + require_nonzero(*req_id, "CONTROL_REQUEST")?; + let body = encode_body(*req_id, &to_json(req)?, &[])?; + write_frame(w, kind::REQUEST, &body) + } + ControlClientMsg::RequestBlob { req_id, req, blob } => { + require_nonzero(*req_id, "CONTROL_REQUEST_BLOB")?; + let body = encode_body(*req_id, &to_json(req)?, blob)?; + write_frame(w, kind::REQUEST_BLOB, &body) + } + ControlClientMsg::Cancel { req_id } => { + require_nonzero(*req_id, "CONTROL_CANCEL")?; + let body = encode_body(*req_id, &[], &[])?; + write_frame(w, kind::CANCEL, &body) + } + } + } + + /// Decode one already-read frame. + pub fn from_frame(k: u8, payload: Vec) -> io::Result { + match k { + kind::HELLO => Ok(ControlClientMsg::Hello(from_json(&payload)?)), + kind::REQUEST => { + let (req_id, json) = decode_body_exact(&payload, "CONTROL_REQUEST")?; + require_nonzero(req_id, "CONTROL_REQUEST")?; + Ok(ControlClientMsg::Request { + req_id, + req: from_json(json)?, + }) + } + kind::REQUEST_BLOB => { + let (req_id, json, blob) = decode_body(&payload)?; + require_nonzero(req_id, "CONTROL_REQUEST_BLOB")?; + Ok(ControlClientMsg::RequestBlob { + req_id, + req: from_json(json)?, + blob: blob.to_vec(), + }) + } + kind::CANCEL => { + let (req_id, json) = decode_body_exact(&payload, "CONTROL_CANCEL")?; + require_nonzero(req_id, "CONTROL_CANCEL")?; + if !json.is_empty() { + return Err(invalid("CONTROL_CANCEL must carry an empty JSON head")); + } + Ok(ControlClientMsg::Cancel { req_id }) + } + other => Err(invalid(format!("unknown ControlClientMsg kind {other}"))), + } + } + + /// Read and decode the next client message from `r`. + pub fn read(r: &mut R) -> io::Result { + let (k, payload) = read_frame(r)?; + Self::from_frame(k, payload) + } +} + +/// A control frame travelling server → client. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum ControlServerMsg { + HelloOk(ControlHelloOk), + Response { + req_id: u64, + reply: ControlReply, + }, + /// A reply with bulk bytes; the JSON head carries the metadata. + ResponseBlob { + req_id: u64, + reply: ControlReply, + blob: Vec, + }, + /// A push. Its `req_id` is always 0 on the wire. + Event(ControlEvent), +} + +impl ControlServerMsg { + /// Encode and write this message as one frame. + pub fn encode(&self, w: &mut W) -> io::Result<()> { + match self { + ControlServerMsg::HelloOk(ok) => write_frame(w, kind::HELLO_OK, &to_json(ok)?), + ControlServerMsg::Response { req_id, reply } => { + require_nonzero(*req_id, "CONTROL_RESPONSE")?; + let body = encode_body(*req_id, &to_json(reply)?, &[])?; + write_frame(w, kind::RESPONSE, &body) + } + ControlServerMsg::ResponseBlob { + req_id, + reply, + blob, + } => { + require_nonzero(*req_id, "CONTROL_RESPONSE_BLOB")?; + let body = encode_body(*req_id, &to_json(reply)?, blob)?; + write_frame(w, kind::RESPONSE_BLOB, &body) + } + ControlServerMsg::Event(event) => { + let body = encode_body(0, &to_json(event)?, &[])?; + write_frame(w, kind::EVENT, &body) + } + } + } + + /// Decode one already-read frame. + pub fn from_frame(k: u8, payload: Vec) -> io::Result { + match k { + kind::HELLO_OK => Ok(ControlServerMsg::HelloOk(from_json(&payload)?)), + kind::RESPONSE => { + let (req_id, json) = decode_body_exact(&payload, "CONTROL_RESPONSE")?; + require_nonzero(req_id, "CONTROL_RESPONSE")?; + Ok(ControlServerMsg::Response { + req_id, + reply: from_json(json)?, + }) + } + kind::RESPONSE_BLOB => { + let (req_id, json, blob) = decode_body(&payload)?; + require_nonzero(req_id, "CONTROL_RESPONSE_BLOB")?; + Ok(ControlServerMsg::ResponseBlob { + req_id, + reply: from_json(json)?, + blob: blob.to_vec(), + }) + } + kind::EVENT => { + let (req_id, json) = decode_body_exact(&payload, "CONTROL_EVENT")?; + if req_id != 0 { + return Err(invalid(format!( + "CONTROL_EVENT carried req_id {req_id}; pushes must use 0" + ))); + } + Ok(ControlServerMsg::Event(from_json(json)?)) + } + other => Err(invalid(format!("unknown ControlServerMsg kind {other}"))), + } + } + + /// Read and decode the next server message from `r`. + pub fn read(r: &mut R) -> io::Result { + let (k, payload) = read_frame(r)?; + Self::from_frame(k, payload) + } +} + +// --------------------------------------------------------------------------- +// Client +// --------------------------------------------------------------------------- + +/// How often the client pings, and only when the link has gone quiet — a busy +/// connection proves itself. +pub const KEEPALIVE_PING_INTERVAL: Duration = Duration::from_secs(15); +/// Silence that has to elapse before a ping is worth sending. +pub const KEEPALIVE_IDLE_BEFORE_PING: Duration = Duration::from_secs(30); +/// Silence after which the link counts as dead and the workspace goes to +/// `Reconnecting`. Three ping intervals: two may be lost without a false +/// positive. +pub const KEEPALIVE_DEAD_AFTER: Duration = Duration::from_secs(45); + +/// How long [`ControlClient::close`] waits for its reader thread before +/// detaching it. +/// +/// Generous enough that a reader woken by a real shutdown is always reaped +/// (that takes microseconds), short enough that a client built without a +/// [`LinkShutdown`] still closes promptly instead of hanging its caller. +pub const CLOSE_GRACE: Duration = Duration::from_millis(500); + +/// A reply as the caller receives it: the value, plus the blob if the frame +/// carried one. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ControlResponse { + pub reply: ReplyOk, + pub blob: Vec, +} + +/// Where server pushes go. Called on the reader thread, so it must not block — +/// the intended shape is a channel send. +pub type EventSink = Box; + +/// Whatever can force a parked reader out of a blocking `read`. +/// +/// **This is not optional politeness; without it a client cannot be closed.** +/// The reader thread spends its whole life inside `read_frame`, which blocks +/// until the peer sends something. Setting a "we're closed now" flag does not +/// wake it, because nothing is looking at the flag — the thread is inside a +/// syscall. And the peer has no reason to send anything: it is waiting for the +/// next request. Both ends wait for the other forever. +/// +/// So closing has to act on the file descriptor itself. Every transport has +/// *some* way to do that, but they share no trait in std — a socket has +/// `shutdown`, a child process has `kill`, an SSH channel has `close` — hence +/// this one-method abstraction rather than a bound on the stream type. +/// +/// **Note for the server side** (contract §7.4's `Duplex`): the same problem +/// exists there in mirror image, so `Duplex` will want the same capability. +/// Aligning is a matter of `Duplex` either requiring `LinkShutdown` as a +/// supertrait or exposing an equivalent method; this trait is deliberately +/// minimal so it can be adopted rather than duplicated. +pub trait LinkShutdown: Send + Sync + 'static { + /// Force the read half to return. Called at most once, and may be called + /// while the reader is blocked inside `read`. + fn shutdown_link(&self) -> io::Result<()>; +} + +impl LinkShutdown for std::net::TcpStream { + fn shutdown_link(&self) -> io::Result<()> { + self.shutdown(std::net::Shutdown::Both) + } +} + +#[cfg(unix)] +impl LinkShutdown for std::os::unix::net::UnixStream { + fn shutdown_link(&self) -> io::Result<()> { + self.shutdown(std::net::Shutdown::Both) + } +} + +/// The client half of a control connection: one writer, one reader thread, and +/// a table of outstanding requests keyed by id. +/// +/// **Out-of-order matching is the whole point.** Every call blocks its own +/// caller and nobody else's: a twenty-second `git` and a five-millisecond +/// `read_dir` issued in that order return in the opposite order, because the +/// reader claims each reply from the table by id rather than assuming replies +/// arrive as requests were sent. Without that, expanding a directory in the +/// file tree would queue behind whatever slow thing the status bar last asked +/// for. +/// +/// Cloneable and `Sync`: `Arc` is the intended way to share it, +/// and concurrent `call`s from many threads are the normal case. +pub struct ControlClient { + inner: Arc, + reader: Mutex>>, +} + +struct ClientInner { + writer: Mutex>, + next_req_id: AtomicU64, + pending: Mutex>>, + /// Blobs arrive on the same frame as their reply, so they ride a side table + /// keyed by the same id rather than widening the channel's item type. + blobs: Mutex>>, + connected: AtomicBool, + last_inbound: Mutex, + hello: ControlHelloOk, + /// How to force the reader out of its blocking read. `None` when the caller + /// supplied raw halves with no way to close them — then [`ControlClient::close`] + /// detaches the reader instead of waiting for it. + shutdown: Option>, + /// Set by the reader as it exits, so `close` can wait for it *bounded* + /// rather than joining a thread that may never return. + reader_done: Mutex, + reader_exit: Condvar, +} + +impl ControlClient { + /// Perform the handshake over an already-connected stream, then start the + /// reader thread. + /// + /// `r` and `w` are the two halves of one duplex link — a `try_clone`d + /// socket, or a child process's stdout and stdin. Taking them separately + /// rather than behind a trait keeps this usable for both without this + /// module having an opinion about how a stream splits. + pub fn connect( + r: R, + w: W, + hello: &ControlHello, + events: EventSink, + ) -> io::Result + where + R: Read + Send + 'static, + W: Write + Send + 'static, + { + Self::connect_with(r, w, None, hello, events) + } + + /// [`ControlClient::connect`] over a TCP socket, with shutdown wired up. + /// + /// Prefer this to `connect` whenever the transport *has* a shutdown: a + /// client built without one cannot wake its own reader, so closing it costs + /// a [`CLOSE_GRACE`] wait and leaks the reader thread until the peer + /// happens to hang up. + pub fn over_tcp( + sock: std::net::TcpStream, + hello: &ControlHello, + events: EventSink, + ) -> io::Result { + let r = sock.try_clone()?; + let closer: Arc = Arc::new(sock.try_clone()?); + Self::connect_with(r, sock, Some(closer), hello, events) + } + + /// [`ControlClient::connect`] over a Unix-domain socket, with shutdown + /// wired up. + #[cfg(unix)] + pub fn over_unix( + sock: std::os::unix::net::UnixStream, + hello: &ControlHello, + events: EventSink, + ) -> io::Result { + let r = sock.try_clone()?; + let closer: Arc = Arc::new(sock.try_clone()?); + Self::connect_with(r, sock, Some(closer), hello, events) + } + + /// The full form: `shutdown` is what [`ControlClient::close`] uses to force + /// the reader out of its blocking read. See [`LinkShutdown`] for why a flag + /// alone cannot do it. + pub fn connect_with( + mut r: R, + mut w: W, + shutdown: Option>, + hello: &ControlHello, + events: EventSink, + ) -> io::Result + where + R: Read + Send + 'static, + W: Write + Send + 'static, + { + ControlClientMsg::Hello(hello.clone()).encode(&mut w)?; + w.flush()?; + + let ok = match ControlServerMsg::read(&mut r)? { + ControlServerMsg::HelloOk(ok) => ok, + other => { + return Err(invalid(format!( + "control peer answered the handshake with {other:?} instead of HELLO_OK" + ))); + } + }; + if ok.control_version != hello.control_version { + // The peer has already closed by now; the frame existed purely so + // this message can name both versions instead of saying "the + // connection dropped". + return Err(io::Error::new( + io::ErrorKind::Unsupported, + format!( + "control peer (build {}) speaks control v{}, this build speaks v{}", + ok.build, ok.control_version, hello.control_version + ), + )); + } + + let inner = Arc::new(ClientInner { + writer: Mutex::new(Box::new(w)), + next_req_id: AtomicU64::new(1), + pending: Mutex::new(HashMap::new()), + blobs: Mutex::new(HashMap::new()), + connected: AtomicBool::new(true), + last_inbound: Mutex::new(Instant::now()), + hello: ok, + shutdown, + reader_done: Mutex::new(false), + reader_exit: Condvar::new(), + }); + + let reader_inner = Arc::clone(&inner); + let reader = std::thread::Builder::new() + .name("tty7-control-reader".into()) + .spawn(move || reader_loop(reader_inner, r, events))?; + + Ok(ControlClient { + inner, + reader: Mutex::new(Some(reader)), + }) + } + + /// What the peer said about itself. + pub fn hello(&self) -> &ControlHelloOk { + &self.inner.hello + } + + /// Whether the link is still up. Once false it never returns to true — a + /// reconnect builds a new `ControlClient`. + pub fn is_connected(&self) -> bool { + self.inner.connected.load(Ordering::Acquire) + } + + /// How long the link has been silent. The caller's keepalive policy reads + /// this against [`KEEPALIVE_IDLE_BEFORE_PING`] and + /// [`KEEPALIVE_DEAD_AFTER`]. + pub fn idle_for(&self) -> Duration { + self.inner + .last_inbound + .lock() + .map(|t| t.elapsed()) + .unwrap_or_default() + } + + /// Issue a request and block until its reply, `deadline` elapses, or the + /// link drops. + /// + /// Blocking is deliberate (contract §1): `Host` is a blocking, object-safe + /// trait, and every caller is already on a background thread. Blocking here + /// blocks exactly one of them. + pub fn call(&self, req: ControlRequest) -> io::Result { + self.call_full(req, &[]).map(|r| r.reply) + } + + /// [`ControlClient::call`] with bulk bytes attached to the request. + pub fn call_with_blob(&self, req: ControlRequest, blob: &[u8]) -> io::Result { + self.call_full(req, blob).map(|r| r.reply) + } + + /// [`ControlClient::call`], keeping the reply's blob. + pub fn call_full(&self, req: ControlRequest, blob: &[u8]) -> io::Result { + let deadline = req.deadline(); + self.call_with_deadline(req, blob, deadline) + } + + /// The full form, for callers that want a deadline other than the method's + /// default (the conformance suite's tighter bounds, mainly). + pub fn call_with_deadline( + &self, + req: ControlRequest, + blob: &[u8], + deadline: Duration, + ) -> io::Result { + if !self.is_connected() { + return Err(io::Error::new( + io::ErrorKind::ConnectionReset, + "control connection is down", + )); + } + + let req_id = self.inner.next_req_id.fetch_add(1, Ordering::Relaxed); + // Every request the client makes, named. A remote workspace's link is + // invisible from the outside — the traffic is inside an SSH channel — + // so without this the only evidence of *who* is talking is packet + // lengths in russh's own trace, which is not evidence. Cheap: `Off` by + // default, and the format cost only runs when it isn't. + log::debug!(target: "tty7::control", "#{req_id} {req:?}"); + // Capacity 1: the reader must never block handing off a reply, and + // there is only ever one reply per id. + let (tx, rx) = sync_channel(1); + self.inner.pending()?.insert(req_id, tx); + + let msg = if blob.is_empty() && !req.takes_blob() { + ControlClientMsg::Request { req_id, req } + } else { + ControlClientMsg::RequestBlob { + req_id, + req, + blob: blob.to_vec(), + } + }; + + if let Err(e) = self.inner.send(&msg) { + self.inner.forget(req_id); + return Err(e); + } + + match rx.recv_timeout(deadline) { + Ok(reply) => { + let blob = self.inner.take_blob(req_id); + reply + .into_result() + .map(|reply| ControlResponse { reply, blob }) + } + Err(RecvTimeoutError::Timeout) => { + // Drop the slot first: a late reply then finds no entry and is + // discarded, per the unknown-id rule. + self.inner.forget(req_id); + let _ = self.inner.send(&ControlClientMsg::Cancel { req_id }); + Err(io::Error::new( + io::ErrorKind::TimedOut, + format!("control request {req_id} timed out after {deadline:?}"), + )) + } + // The sender is gone: the reader shut down and cleared the table. + Err(RecvTimeoutError::Disconnected) => { + self.inner.forget(req_id); + Err(io::Error::new( + io::ErrorKind::ConnectionReset, + "control connection closed while the request was in flight", + )) + } + } + } + + /// Send a keepalive `Ping`, ignoring the answer's content. + pub fn ping(&self) -> io::Result<()> { + self.call(ControlRequest::Ping).map(|_| ()) + } + + /// Tear the link down: fail every waiter, force the reader out of its + /// blocking read, and reap it. + /// + /// The order matters and so does the bound. Failing the waiters first means + /// nobody is left holding a deadline against a socket that is about to go + /// away. Shutting the link down is what actually wakes the reader — + /// `fail_all` only touches this side's bookkeeping, and a reader parked in + /// `read_frame` is inside a syscall where no flag can reach it. + /// + /// The join is bounded by [`CLOSE_GRACE`] because it must be. When no + /// [`LinkShutdown`] was supplied there is nothing that *can* wake the + /// reader, and an unbounded join would hang whichever thread dropped the + /// client — in practice the UI thread closing a remote workspace. A + /// detached reader thread is a far smaller problem than a frozen window: it + /// exits on its own as soon as the peer hangs up, and it holds nothing but + /// an `Arc` whose waiters have all been failed already. + pub fn close(&self) { + self.inner + .fail_all("control connection closed by this client"); + if let Some(closer) = &self.inner.shutdown + && let Err(e) = closer.shutdown_link() + { + // Already closed, or never opened. Either way the reader is on its + // way out and there is nothing better to do. + log::trace!("control link shutdown reported {e}"); + } + + let Ok(mut slot) = self.reader.lock() else { + return; + }; + let Some(handle) = slot.take() else { return }; + + let reaped = match self.inner.reader_done.lock() { + Ok(done) => self + .inner + .reader_exit + .wait_timeout_while(done, CLOSE_GRACE, |done| !*done) + .map(|(done, _)| *done) + .unwrap_or(false), + Err(_) => false, + }; + if reaped { + // The reader has already returned, so this join is immediate. + let _ = handle.join(); + } else { + log::debug!( + "control reader did not exit within {CLOSE_GRACE:?}; detaching it \ + rather than blocking the caller" + ); + drop(handle); + } + } +} + +impl Drop for ControlClient { + fn drop(&mut self) { + self.close(); + } +} + +/// Hand-written because the writer is a trait object and the pending table +/// holds channel senders — neither of which can derive it, and neither of which +/// is what a reader of a log line wants anyway. What matters is who the peer is +/// and whether the link is alive. +impl std::fmt::Debug for ControlClient { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let in_flight = self.inner.pending.lock().map(|p| p.len()).unwrap_or(0); + f.debug_struct("ControlClient") + .field("build", &self.inner.hello.build) + .field("control_version", &self.inner.hello.control_version) + .field("connected", &self.is_connected()) + .field("in_flight", &in_flight) + .finish() + } +} + +impl ClientInner { + fn pending( + &self, + ) -> io::Result>>> { + self.pending + .lock() + .map_err(|_| io::Error::other("control request table was poisoned")) + } + + fn send(&self, msg: &ControlClientMsg) -> io::Result<()> { + let mut w = self + .writer + .lock() + .map_err(|_| io::Error::other("control writer was poisoned"))?; + let r = msg.encode(&mut *w).and_then(|()| w.flush()); + if r.is_err() { + self.connected.store(false, Ordering::Release); + } + r + } + + fn forget(&self, req_id: u64) { + if let Ok(mut p) = self.pending.lock() { + p.remove(&req_id); + } + if let Ok(mut b) = self.blobs.lock() { + b.remove(&req_id); + } + } + + fn take_blob(&self, req_id: u64) -> Vec { + self.blobs + .lock() + .ok() + .and_then(|mut b| b.remove(&req_id)) + .unwrap_or_default() + } + + /// Deliver a reply to whoever is waiting on `req_id`. A reply for an id + /// nobody is waiting on is dropped without complaint: it is the expected + /// tail of a request that already timed out. + fn deliver(&self, req_id: u64, reply: ControlReply, blob: Vec) { + let Ok(mut pending) = self.pending.lock() else { + return; + }; + let Some(tx) = pending.remove(&req_id) else { + log::trace!("control reply for unknown req_id {req_id}; dropping"); + return; + }; + drop(pending); + if !blob.is_empty() + && let Ok(mut blobs) = self.blobs.lock() + { + blobs.insert(req_id, blob); + } + // Capacity 1 and one reply per id, so this cannot block; it can only + // fail if the caller already gave up between the table lookup and here. + let _ = tx.try_send(reply); + } + + /// Fail every outstanding request. Called when the link dies, so callers + /// get `ConnectionReset` immediately instead of each waiting out its own + /// deadline. + fn fail_all(&self, why: &str) { + self.connected.store(false, Ordering::Release); + let drained: Vec<_> = match self.pending.lock() { + Ok(mut p) => p.drain().collect(), + Err(_) => return, + }; + for (req_id, tx) in drained { + let _ = tx.try_send(ControlReply::Err(WireError::new( + WireErrorKind::ConnectionReset, + format!("{why} (request {req_id})"), + ))); + } + } +} + +fn reader_loop(inner: Arc, r: R, events: EventSink) { + read_until_closed(&inner, r, events); + // Whatever ended the loop, `close` is entitled to stop waiting. + if let Ok(mut done) = inner.reader_done.lock() { + *done = true; + } + inner.reader_exit.notify_all(); +} + +fn read_until_closed(inner: &Arc, mut r: R, events: EventSink) { + loop { + let frame = match read_frame(&mut r) { + Ok(f) => f, + Err(e) => { + log::debug!("control reader stopping: {e}"); + inner.fail_all("control connection lost"); + return; + } + }; + if let Ok(mut t) = inner.last_inbound.lock() { + *t = Instant::now(); + } + match ControlServerMsg::from_frame(frame.0, frame.1) { + Ok(ControlServerMsg::Response { req_id, reply }) => { + inner.deliver(req_id, reply, Vec::new()); + } + Ok(ControlServerMsg::ResponseBlob { + req_id, + reply, + blob, + }) => { + inner.deliver(req_id, reply, blob); + } + Ok(ControlServerMsg::Event(event)) => events(event), + Ok(ControlServerMsg::HelloOk(_)) => { + // A second handshake mid-stream is a desync, not a greeting. + log::warn!("control peer sent a second HELLO_OK; dropping the connection"); + inner.fail_all("control peer re-sent its handshake"); + return; + } + Err(e) => { + // A frame we cannot parse means the stream position is no + // longer trustworthy — same verdict the pane protocol reaches. + log::warn!("control decode error: {e}"); + inner.fail_all("control stream desynchronized"); + return; + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::daemon::protocol::MAX_FRAME; + use std::io::Cursor; + use std::path::PathBuf; + + fn meta() -> Meta { + Meta { + is_dir: false, + is_symlink: false, + len: 4096, + mtime: Some(MTime { + secs: 1_769_000_000, + nanos: 123_456_789, + }), + readonly: false, + } + } + + fn every_request() -> Vec { + vec![ + ControlRequest::Ping, + ControlRequest::ReadDir { + dir: "/home/me/proj/src".into(), + root: Some("/home/me/proj".into()), + }, + ControlRequest::ReadDir { + dir: "/tmp".into(), + root: None, + }, + ControlRequest::Stat { + path: "/home/me/.zshrc".into(), + }, + ControlRequest::Exists { + path: "/nope".into(), + }, + ControlRequest::Canonicalize { + path: "/a/../b".into(), + }, + ControlRequest::ReadFile { + path: "/home/me/proj/Cargo.toml".into(), + max_bytes: 10 * 1024 * 1024, + }, + ControlRequest::Search { + roots: vec!["/home/me/proj".into(), "/srv".into()], + query: "widget".into(), + limit: 200, + max_dirs: 2000, + show_hidden: false, + }, + ControlRequest::WriteFile { + path: "/home/me/proj/src/main.rs".into(), + }, + ControlRequest::CreateFileNew { + path: "/home/me/new.txt".into(), + }, + ControlRequest::CreateDir { + path: "/home/me/a/b/c".into(), + recursive: true, + }, + ControlRequest::Rename { + from: "/a".into(), + to: "/b".into(), + }, + ControlRequest::Remove { + path: "/home/me/tmp".into(), + recursive: true, + }, + ControlRequest::RepoRoot { + path: "/home/me/proj/src/deep".into(), + }, + ControlRequest::Git { + cwd: "/home/me/proj".into(), + args: vec!["status".into(), "--porcelain".into()], + }, + ControlRequest::WatchOpen { + dirs: vec!["/home/me/proj".into()], + }, + ControlRequest::WatchSet { + id: 7, + dirs: vec!["/home/me/proj".into(), "/home/me/proj/src".into()], + }, + ControlRequest::WatchClose { id: 7 }, + ControlRequest::WorkspaceList, + ControlRequest::WorkspaceGet { id: "w1".into() }, + ControlRequest::WorkspacePut { + id: "w1".into(), + json: serde_json::json!({ "tabs": [1, 2, 3] }), + }, + ControlRequest::WorkspaceDelete { id: "w1".into() }, + ] + } + + fn every_reply() -> Vec { + vec![ + ControlReply::Ok(ReplyOk::Unit), + ControlReply::Ok(ReplyOk::Pong), + ControlReply::Ok(ReplyOk::Entries(vec![ + Entry { + name: "src".into(), + is_dir: true, + is_symlink: false, + ignored: false, + }, + Entry { + name: "target".into(), + is_dir: true, + is_symlink: false, + ignored: true, + }, + Entry { + name: "link".into(), + is_dir: false, + is_symlink: true, + ignored: false, + }, + ])), + ControlReply::Ok(ReplyOk::Meta(meta())), + ControlReply::Ok(ReplyOk::Bool(true)), + ControlReply::Ok(ReplyOk::Bool(false)), + ControlReply::Ok(ReplyOk::Path("/home/me/proj".into())), + ControlReply::Ok(ReplyOk::OptPath(Some("/home/me/proj".into()))), + ControlReply::Ok(ReplyOk::OptPath(None)), + ControlReply::Ok(ReplyOk::FileMeta { meta: meta() }), + ControlReply::Ok(ReplyOk::Hits(vec![SearchHit { + name: "widget.rs".into(), + path: PathBuf::from("/home/me/proj/src/widget.rs"), + is_dir: false, + ignored: false, + }])), + ControlReply::Ok(ReplyOk::Output(Output { + status: Some(1), + stdout: b"?? src/new.rs\n".to_vec(), + stderr: vec![0x00, 0xff, 0xfe, b'\n'], + })), + ControlReply::Ok(ReplyOk::WatchId(42)), + ControlReply::Ok(ReplyOk::Json(serde_json::json!({ "a": [1, null] }))), + ControlReply::Err(WireError::new(WireErrorKind::NotFound, "no such file")), + ControlReply::Err(WireError::new( + WireErrorKind::PermissionDenied, + "permission denied", + )), + ControlReply::Err(WireError::new( + WireErrorKind::AlreadyExists, + "already exists", + )), + ControlReply::Err(WireError::new(WireErrorKind::InvalidInput, "bad path")), + ControlReply::Err(WireError::new(WireErrorKind::NotADirectory, "not a dir")), + ControlReply::Err(WireError::new(WireErrorKind::IsADirectory, "is a dir")), + ControlReply::Err(WireError::new( + WireErrorKind::DirectoryNotEmpty, + "not empty", + )), + ControlReply::Err(WireError::new(WireErrorKind::FileTooLarge, "too large")), + ControlReply::Err(WireError::new(WireErrorKind::GitUnavailable, "no git")), + ControlReply::Err(WireError::new(WireErrorKind::TimedOut, "timed out")), + ControlReply::Err(WireError::new(WireErrorKind::ConnectionReset, "reset")), + ControlReply::Err(WireError::new(WireErrorKind::Other, "something else")), + ] + } + + fn every_event() -> Vec { + vec![ + ControlEvent::Watch { + id: 3, + paths: vec!["/home/me/proj/src/main.rs".into()], + }, + ControlEvent::WatchOverflow { id: 3 }, + ControlEvent::PaneExited { + pane_id: 9, + code: Some(0), + }, + ControlEvent::PaneExited { + pane_id: 9, + code: None, + }, + ControlEvent::AgentStatus { + pane_id: 9, + json: serde_json::json!({ "state": "thinking" }), + }, + ControlEvent::Preempted { + workspace: "w1".into(), + by: "other-laptop".into(), + }, + ControlEvent::WorkspaceChanged { id: "w1".into() }, + ] + } + + fn hello() -> ControlHello { + ControlHello { + control_version: CONTROL_VERSION, + workspace: Some("w1".into()), + client_token: "tok".into(), + client_hostname: "laptop".into(), + } + } + + fn hello_ok() -> ControlHelloOk { + ControlHelloOk { + control_version: CONTROL_VERSION, + protocol_version: crate::daemon::protocol::PROTOCOL_VERSION, + build: "26.7.5".into(), + separator: '/', + home: "/home/me".into(), + features: vec![feature::CONTROL.into(), feature::HOST_RPC.into()], + } + } + + /// Every client-direction message survives encode → read, including one + /// `Request` per `ControlRequest` variant. + #[test] + fn client_messages_round_trip() { + let mut msgs = vec![ + ControlClientMsg::Hello(hello()), + ControlClientMsg::Hello(ControlHello::host_rpc("tok", "laptop")), + ControlClientMsg::Cancel { req_id: 1 }, + ControlClientMsg::Cancel { req_id: u64::MAX }, + ControlClientMsg::RequestBlob { + req_id: 5, + req: ControlRequest::WriteFile { + path: "/tmp/x".into(), + }, + blob: vec![0x00, 0xff, b'h', b'i'], + }, + ControlClientMsg::RequestBlob { + req_id: 6, + req: ControlRequest::WriteFile { + path: "/tmp/empty".into(), + }, + blob: Vec::new(), + }, + ]; + for (i, req) in every_request().into_iter().enumerate() { + msgs.push(ControlClientMsg::Request { + req_id: i as u64 + 1, + req, + }); + } + + for msg in &msgs { + let mut buf = Vec::new(); + msg.encode(&mut buf).unwrap(); + let got = ControlClientMsg::read(&mut Cursor::new(&buf)).unwrap(); + assert_eq!(&got, msg, "client message did not survive the round trip"); + } + } + + /// Every server-direction message survives encode → read, including one + /// `Response` per `ControlReply` variant and one `Event` per + /// `ControlEvent`. + #[test] + fn server_messages_round_trip() { + let mut msgs = vec![ + ControlServerMsg::HelloOk(hello_ok()), + ControlServerMsg::ResponseBlob { + req_id: 9, + reply: ControlReply::Ok(ReplyOk::FileMeta { meta: meta() }), + blob: b"file contents\0\xff".to_vec(), + }, + ControlServerMsg::ResponseBlob { + req_id: 10, + reply: ControlReply::Ok(ReplyOk::FileMeta { meta: meta() }), + blob: Vec::new(), + }, + ]; + for (i, reply) in every_reply().into_iter().enumerate() { + msgs.push(ControlServerMsg::Response { + req_id: i as u64 + 1, + reply, + }); + } + for event in every_event() { + msgs.push(ControlServerMsg::Event(event)); + } + + for msg in &msgs { + let mut buf = Vec::new(); + msg.encode(&mut buf).unwrap(); + let got = ControlServerMsg::read(&mut Cursor::new(&buf)).unwrap(); + assert_eq!(&got, msg, "server message did not survive the round trip"); + } + } + + /// The byte layout is the contract's, checked by hand rather than by + /// round-tripping through our own decoder — a symmetric bug in both halves + /// would pass every round-trip test and still be unreadable to the other + /// implementation. + #[test] + fn frame_layout_matches_the_contract() { + let mut buf = Vec::new(); + ControlClientMsg::RequestBlob { + req_id: 0x0102_0304_0506_0708, + req: ControlRequest::WriteFile { path: "/x".into() }, + blob: vec![0xaa, 0xbb], + } + .encode(&mut buf) + .unwrap(); + + // Outer frame: [u32 LE payload_len][u8 kind][payload] + let payload_len = u32::from_le_bytes(buf[..4].try_into().unwrap()) as usize; + assert_eq!(buf[4], 62, "REQUEST_BLOB is kind 62"); + assert_eq!( + buf.len(), + 5 + payload_len, + "frame length covers the payload" + ); + + let payload = &buf[5..]; + assert_eq!( + &payload[..8], + &0x0102_0304_0506_0708u64.to_le_bytes(), + "req_id is 8 bytes, little-endian, first" + ); + let json_n = u32::from_le_bytes(payload[8..12].try_into().unwrap()) as usize; + let json = &payload[12..12 + json_n]; + assert_eq!( + serde_json::from_slice::(json).unwrap(), + ControlRequest::WriteFile { path: "/x".into() } + ); + assert_eq!(&payload[12 + json_n..], &[0xaa, 0xbb], "blob is the tail"); + assert_eq!( + payload_len, + 12 + json_n + 2, + "payload_len == 12 + json_n + blob_len" + ); + } + + /// A non-blob frame's `payload_len` is exactly `12 + json_n`, with nothing + /// trailing. + #[test] + fn non_blob_frames_have_no_tail() { + let mut buf = Vec::new(); + ControlClientMsg::Request { + req_id: 1, + req: ControlRequest::Ping, + } + .encode(&mut buf) + .unwrap(); + let payload_len = u32::from_le_bytes(buf[..4].try_into().unwrap()) as usize; + let json_n = u32::from_le_bytes(buf[13..17].try_into().unwrap()) as usize; + assert_eq!(payload_len, 12 + json_n); + } + + /// `CONTROL_CANCEL` is a bare `[req_id][json_len = 0]` — twelve bytes, no + /// JSON at all. + #[test] + fn cancel_is_twelve_bytes_with_an_empty_json_head() { + let mut buf = Vec::new(); + ControlClientMsg::Cancel { req_id: 77 } + .encode(&mut buf) + .unwrap(); + assert_eq!(u32::from_le_bytes(buf[..4].try_into().unwrap()), 12); + assert_eq!(buf[4], 63); + assert_eq!(&buf[5..13], &77u64.to_le_bytes()); + assert_eq!(&buf[13..17], &0u32.to_le_bytes()); + assert_eq!(buf.len(), 17); + } + + /// An event's `req_id` is on the wire and is zero — the eight redundant + /// bytes that let one header parser serve every non-`HELLO` kind. + #[test] + fn events_carry_a_zero_req_id() { + let mut buf = Vec::new(); + ControlServerMsg::Event(ControlEvent::WatchOverflow { id: 1 }) + .encode(&mut buf) + .unwrap(); + assert_eq!(buf[4], 63); + assert_eq!(&buf[5..13], &0u64.to_le_bytes()); + } + + // ---- hostile / malformed input ----------------------------------------- + + /// A frame shorter than the 12-byte control header errors instead of + /// slicing out of bounds. + #[test] + fn short_payload_is_invalid_data() { + for len in 0..CONTROL_HEADER { + let payload = vec![0u8; len]; + for k in [kind::REQUEST, kind::REQUEST_BLOB, kind::CANCEL] { + let e = ControlClientMsg::from_frame(k, payload.clone()).unwrap_err(); + assert_eq!(e.kind(), io::ErrorKind::InvalidData, "kind {k}, len {len}"); + } + for k in [kind::RESPONSE, kind::RESPONSE_BLOB, kind::EVENT] { + let e = ControlServerMsg::from_frame(k, payload.clone()).unwrap_err(); + assert_eq!(e.kind(), io::ErrorKind::InvalidData, "kind {k}, len {len}"); + } + } + } + + /// A `json_len` that runs past the payload — including `u32::MAX` — errors + /// rather than panicking on the slice. This is the one field a hostile peer + /// controls that indexes memory. + #[test] + fn oversized_json_len_is_invalid_data_not_a_panic() { + for json_n in [13u32, 1_000_000, u32::MAX] { + let mut payload = Vec::new(); + payload.extend_from_slice(&1u64.to_le_bytes()); + payload.extend_from_slice(&json_n.to_le_bytes()); + payload.extend_from_slice(b"{}"); // only 2 bytes actually present + let e = ControlClientMsg::from_frame(kind::REQUEST, payload.clone()).unwrap_err(); + assert_eq!(e.kind(), io::ErrorKind::InvalidData, "json_n {json_n}"); + let e = ControlServerMsg::from_frame(kind::RESPONSE_BLOB, payload).unwrap_err(); + assert_eq!(e.kind(), io::ErrorKind::InvalidData, "json_n {json_n}"); + } + } + + /// A non-blob kind carrying trailing bytes is a desync, not something to + /// tolerate: `12 + json_n == payload_len` is required for 61 and 63. + #[test] + fn trailing_bytes_on_a_non_blob_kind_are_invalid_data() { + let json = serde_json::to_vec(&ControlRequest::Ping).unwrap(); + let mut payload = encode_body(1, &json, b"extra").unwrap(); + let e = ControlClientMsg::from_frame(kind::REQUEST, payload.clone()).unwrap_err(); + assert_eq!(e.kind(), io::ErrorKind::InvalidData); + + payload = encode_body(1, &[], b"extra").unwrap(); + let e = ControlClientMsg::from_frame(kind::CANCEL, payload).unwrap_err(); + assert_eq!(e.kind(), io::ErrorKind::InvalidData); + + let json = serde_json::to_vec(&ControlEvent::WatchOverflow { id: 1 }).unwrap(); + let payload = encode_body(0, &json, b"extra").unwrap(); + let e = ControlServerMsg::from_frame(kind::EVENT, payload).unwrap_err(); + assert_eq!(e.kind(), io::ErrorKind::InvalidData); + } + + /// An event with a non-zero `req_id` is a protocol error — a push has no + /// request to belong to, and letting it through would let a server steal a + /// pending reply's slot. + #[test] + fn event_with_a_nonzero_req_id_is_invalid_data() { + let json = serde_json::to_vec(&ControlEvent::WatchOverflow { id: 1 }).unwrap(); + let payload = encode_body(9, &json, &[]).unwrap(); + let e = ControlServerMsg::from_frame(kind::EVENT, payload).unwrap_err(); + assert_eq!(e.kind(), io::ErrorKind::InvalidData); + } + + /// Conversely, req_id 0 on anything that is *not* an event is a protocol + /// error: 0 belongs to pushes. + #[test] + fn zero_req_id_is_rejected_on_requests_and_responses() { + let json = serde_json::to_vec(&ControlRequest::Ping).unwrap(); + let payload = encode_body(0, &json, &[]).unwrap(); + for k in [kind::REQUEST, kind::CANCEL] { + let e = ControlClientMsg::from_frame(k, payload.clone()).unwrap_err(); + assert_eq!(e.kind(), io::ErrorKind::InvalidData, "kind {k}"); + } + let e = ControlClientMsg::from_frame(kind::REQUEST_BLOB, payload).unwrap_err(); + assert_eq!(e.kind(), io::ErrorKind::InvalidData); + + let json = serde_json::to_vec(&ControlReply::Ok(ReplyOk::Unit)).unwrap(); + let payload = encode_body(0, &json, &[]).unwrap(); + for k in [kind::RESPONSE, kind::RESPONSE_BLOB] { + let e = ControlServerMsg::from_frame(k, payload.clone()).unwrap_err(); + assert_eq!(e.kind(), io::ErrorKind::InvalidData, "kind {k}"); + } + + // And the encoder refuses to mint one in the first place. + let mut buf = Vec::new(); + assert!( + ControlClientMsg::Request { + req_id: 0, + req: ControlRequest::Ping + } + .encode(&mut buf) + .is_err() + ); + } + + /// Unknown kinds are errors, not skips — the same verdict the pane protocol + /// reaches, and the reason control chose 60-63 rather than reusing the + /// retired 13. + #[test] + fn unknown_kinds_are_invalid_data() { + for k in [0u8, 1, 13, 40, 50, 59, 64, 69, 200, 255] { + let e = ControlClientMsg::from_frame(k, vec![0; 32]).unwrap_err(); + assert_eq!(e.kind(), io::ErrorKind::InvalidData, "client kind {k}"); + let e = ControlServerMsg::from_frame(k, vec![0; 32]).unwrap_err(); + assert_eq!(e.kind(), io::ErrorKind::InvalidData, "server kind {k}"); + } + } + + /// Control's kinds do not collide with anything the pane protocol uses or + /// has reserved, and never touch the retired 13. + #[test] + fn control_kinds_sit_in_their_own_range() { + let ours = [ + kind::HELLO, + kind::REQUEST, + kind::REQUEST_BLOB, + kind::CANCEL, + kind::HELLO_OK, + kind::RESPONSE, + kind::RESPONSE_BLOB, + kind::EVENT, + ]; + for k in ours { + assert!((60..=63).contains(&k), "control kind {k} left its range"); + assert_ne!(k, 13, "13 is retired and must never be reused"); + } + // Every kind the pane protocol uses or reserves, per protocol.rs. + let taken: Vec = (1..=24).chain(30..=36).chain([40, 50]).collect(); + for k in ours { + assert!( + !taken.contains(&k), + "control kind {k} collides with protocol" + ); + } + } + + /// Malformed JSON inside a well-formed frame is `InvalidData`, not a panic. + #[test] + fn malformed_json_is_invalid_data() { + let payload = encode_body(1, b"{not json", &[]).unwrap(); + let e = ControlClientMsg::from_frame(kind::REQUEST, payload).unwrap_err(); + assert_eq!(e.kind(), io::ErrorKind::InvalidData); + + let e = ControlClientMsg::from_frame(kind::HELLO, b"nope".to_vec()).unwrap_err(); + assert_eq!(e.kind(), io::ErrorKind::InvalidData); + } + + /// A frame past `MAX_FRAME` is refused at encode time rather than shipped + /// for the peer to refuse — the writer is where the allocation already is. + #[test] + fn oversize_blob_is_refused_at_encode() { + let blob = vec![0u8; MAX_FRAME]; + let mut sink = Vec::new(); + let e = ControlClientMsg::RequestBlob { + req_id: 1, + req: ControlRequest::WriteFile { + path: "/tmp/huge".into(), + }, + blob, + } + .encode(&mut sink) + .unwrap_err(); + assert_eq!(e.kind(), io::ErrorKind::InvalidData); + } + + /// And a *claimed* length past `MAX_FRAME` is refused at decode without + /// allocating it. + #[test] + fn oversize_declared_length_is_refused_at_decode() { + let mut buf = Vec::new(); + buf.extend_from_slice(&((MAX_FRAME + 1) as u32).to_le_bytes()); + buf.push(kind::REQUEST); + let e = ControlClientMsg::read(&mut Cursor::new(&buf)).unwrap_err(); + assert_eq!(e.kind(), io::ErrorKind::InvalidData); + } + + /// A frame exactly at `MAX_FRAME` is legal, so the boundary is inclusive on + /// both sides and a 64 MiB read isn't silently one byte short. + #[test] + fn a_frame_exactly_at_max_frame_survives() { + let json = serde_json::to_vec(&ControlRequest::WriteFile { + path: "/tmp/big".into(), + }) + .unwrap(); + let blob = vec![0x5a; MAX_FRAME - CONTROL_HEADER - json.len()]; + let msg = ControlClientMsg::RequestBlob { + req_id: 1, + req: ControlRequest::WriteFile { + path: "/tmp/big".into(), + }, + blob, + }; + let mut buf = Vec::new(); + msg.encode(&mut buf).unwrap(); + assert_eq!( + u32::from_le_bytes(buf[..4].try_into().unwrap()) as usize, + MAX_FRAME + ); + assert_eq!(ControlClientMsg::read(&mut Cursor::new(&buf)).unwrap(), msg); + } + + // ---- error mapping ------------------------------------------------------ + + /// The `io::ErrorKind` ↔ `WireErrorKind` tables are inverses on every kind + /// they name. If they are not, a `NotFound` from the server becomes an + /// `Other` on the client and every "file is missing" path in the GUI stops + /// recognizing itself. + #[test] + fn error_kinds_round_trip_through_io() { + use io::ErrorKind as K; + let pairs = [ + (K::NotFound, WireErrorKind::NotFound), + (K::PermissionDenied, WireErrorKind::PermissionDenied), + (K::AlreadyExists, WireErrorKind::AlreadyExists), + (K::InvalidInput, WireErrorKind::InvalidInput), + (K::NotADirectory, WireErrorKind::NotADirectory), + (K::IsADirectory, WireErrorKind::IsADirectory), + (K::DirectoryNotEmpty, WireErrorKind::DirectoryNotEmpty), + (K::FileTooLarge, WireErrorKind::FileTooLarge), + (K::TimedOut, WireErrorKind::TimedOut), + (K::ConnectionReset, WireErrorKind::ConnectionReset), + ]; + for (io_kind, wire_kind) in pairs { + let wire = WireError::from_io(&io::Error::new(io_kind, "boom")); + assert_eq!(wire.kind, wire_kind, "io {io_kind:?} -> wire"); + assert_eq!(wire.into_io().kind(), io_kind, "wire {wire_kind:?} -> io"); + } + } + + /// The kinds that collapse on the way out stay collapsed, deliberately. + #[test] + fn error_kinds_that_collapse_do_so_predictably() { + use io::ErrorKind as K; + for k in [K::BrokenPipe, K::UnexpectedEof, K::ConnectionReset] { + assert_eq!( + WireError::from_io(&io::Error::new(k, "x")).kind, + WireErrorKind::ConnectionReset + ); + } + assert_eq!( + WireError::from_io(&io::Error::new(K::InvalidFilename, "x")).kind, + WireErrorKind::InvalidInput + ); + assert_eq!( + WireError::from_io(&io::Error::new(K::WouldBlock, "x")).kind, + WireErrorKind::Other + ); + // GitUnavailable has no io kind of its own; it reads as "not found", + // which is what a missing binary is. + assert_eq!(WireErrorKind::GitUnavailable.to_io_kind(), K::NotFound); + } + + /// The server's message survives into the local `io::Error`, because that + /// string is what the user is shown. + #[test] + fn wire_error_message_survives_into_io() { + let e = WireError::new(WireErrorKind::NotFound, "/home/me/gone: no such file").into_io(); + assert_eq!(e.kind(), io::ErrorKind::NotFound); + assert!(e.to_string().contains("/home/me/gone")); + } + + /// A non-zero git exit is a *successful* reply. This is the invariant that + /// keeps `git_status`'s `Option` semantics intact behind the trait. + #[test] + fn a_nonzero_git_exit_is_ok_not_err() { + let reply = ControlReply::Ok(ReplyOk::Output(Output { + status: Some(128), + stdout: Vec::new(), + stderr: b"not a git repository".to_vec(), + })); + let mut buf = Vec::new(); + ControlServerMsg::Response { req_id: 1, reply } + .encode(&mut buf) + .unwrap(); + match ControlServerMsg::read(&mut Cursor::new(&buf)).unwrap() { + ControlServerMsg::Response { reply, .. } => { + let ok = reply + .into_result() + .expect("a non-zero exit is not an error"); + match ok { + ReplyOk::Output(o) => { + assert_eq!(o.status, Some(128)); + assert!(!o.success()); + assert_eq!(o.stderr_trimmed(), "not a git repository"); + } + other => panic!("expected Output, got {other:?}"), + } + } + other => panic!("expected Response, got {other:?}"), + } + } + + /// `Output`'s byte fields ride as base64 strings, not JSON number arrays. + /// The difference is ~1.33× versus ~4×; on a megabyte of `git diff` that is + /// the difference between a snappy panel and a stalled one. + #[test] + fn output_bytes_ride_as_base64_not_a_number_array() { + let out = Output { + status: Some(0), + stdout: vec![0u8; 3000], + stderr: Vec::new(), + }; + let json = serde_json::to_string(&out).unwrap(); + assert!( + json.contains("\"stdout\":\"AAAA"), + "stdout should be a base64 string, got: {}", + &json[..60.min(json.len())] + ); + assert!(!json.contains("[0,0,0"), "must not be a number array"); + // 3000 bytes -> 4000 base64 chars, versus ~6000 for a number array. + assert!(json.len() < 4200, "base64 payload is {} bytes", json.len()); + assert_eq!(serde_json::from_str::(&json).unwrap(), out); + } + + // ---- timeouts ----------------------------------------------------------- + + /// Every request has a deadline, and the classes keep the contract's shape: + /// metadata is quick, content is patient, git and search sit between. + #[test] + fn deadlines_match_the_contract_table() { + use ControlRequest as R; + let s = Duration::from_secs; + let cases: Vec<(R, Duration)> = vec![ + (R::Ping, s(5)), + ( + R::ReadDir { + dir: "/".into(), + root: None, + }, + s(5), + ), + ( + R::ReadDir { + dir: "/".into(), + root: Some("/".into()), + }, + s(5), + ), + (R::Stat { path: "/".into() }, s(5)), + (R::Exists { path: "/".into() }, s(5)), + (R::Canonicalize { path: "/".into() }, s(5)), + (R::RepoRoot { path: "/".into() }, s(5)), + (R::WatchOpen { dirs: vec![] }, s(5)), + ( + R::WatchSet { + id: 1, + dirs: vec![], + }, + s(5), + ), + (R::WatchClose { id: 1 }, s(5)), + ( + R::ReadFile { + path: "/".into(), + max_bytes: 1, + }, + s(30), + ), + (R::WriteFile { path: "/".into() }, s(30)), + (R::CreateFileNew { path: "/".into() }, s(10)), + ( + R::CreateDir { + path: "/".into(), + recursive: false, + }, + s(10), + ), + ( + R::Rename { + from: "/".into(), + to: "/".into(), + }, + s(10), + ), + ( + R::Remove { + path: "/".into(), + recursive: false, + }, + s(10), + ), + ( + R::Git { + cwd: "/".into(), + args: vec![], + }, + s(20), + ), + ( + R::Search { + roots: vec![], + query: String::new(), + limit: 1, + max_dirs: 1, + show_hidden: true, + }, + s(20), + ), + (R::WorkspaceList, s(10)), + (R::WorkspaceGet { id: "w".into() }, s(10)), + ( + R::WorkspacePut { + id: "w".into(), + json: serde_json::Value::Null, + }, + s(10), + ), + (R::WorkspaceDelete { id: "w".into() }, s(10)), + ]; + assert_eq!( + cases.len(), + every_request().len(), + "every request variant needs a deadline case" + ); + for (req, want) in cases { + assert_eq!(req.deadline(), want, "deadline for {req:?}"); + } + } + + /// Only `WriteFile` takes a request blob; only `ReadFile` returns one. The + /// client picks the frame kind from these, so a wrong answer here silently + /// changes the wire. + #[test] + fn blob_shape_is_known_per_request() { + for req in every_request() { + let takes = matches!(req, ControlRequest::WriteFile { .. }); + let returns = matches!(req, ControlRequest::ReadFile { .. }); + assert_eq!(req.takes_blob(), takes, "takes_blob for {req:?}"); + assert_eq!(req.returns_blob(), returns, "returns_blob for {req:?}"); + } + } + + /// The handshake carries the server's separator and home, which is how a + /// Windows client ends up doing POSIX path arithmetic for a Linux host. + #[test] + fn hello_ok_round_trips_with_features() { + let mut buf = Vec::new(); + ControlServerMsg::HelloOk(hello_ok()) + .encode(&mut buf) + .unwrap(); + match ControlServerMsg::read(&mut Cursor::new(&buf)).unwrap() { + ControlServerMsg::HelloOk(ok) => { + assert_eq!(ok.separator, '/'); + assert_eq!(ok.home, "/home/me"); + assert!(ok.has_feature(feature::CONTROL)); + assert!(ok.has_feature(feature::HOST_RPC)); + assert!(!ok.has_feature(feature::WORKSPACE_STORE)); + } + other => panic!("expected HelloOk, got {other:?}"), + } + } + + /// `features` is `#[serde(default)]`, so a peer that predates the field + /// still decodes — the whole reason capabilities are a list rather than + /// another version number. + #[test] + fn hello_ok_without_features_still_decodes() { + let json = br#"{"control_version":1,"protocol_version":3,"build":"old", + "separator":"/","home":"/root"}"#; + let ok: ControlHelloOk = serde_json::from_slice(json).unwrap(); + assert!(ok.features.is_empty()); + assert!(!ok.has_feature(feature::CONTROL)); + } + + // ---- ControlClient over a real duplex stream ---------------------------- + // + // These drive a genuine loopback TCP connection with a scripted peer on the + // far thread, rather than a `Cursor`. A `Cursor` can prove the codec is + // symmetric; it cannot prove that a reply is claimed by the right waiter, + // that a slow request doesn't hold up a fast one, or that a dead socket + // wakes everyone. Those are properties of the *client*, and they only exist + // once there are two threads and a real stream between them. + + use std::net::{TcpListener, TcpStream}; + use std::sync::mpsc; + use std::thread; + + /// Stand up a scripted peer on loopback and connect a client to it. + /// + /// `serve` receives the peer's own socket after the handshake has been + /// answered, and drives whatever exchange the test needs. + fn client_with_peer(events: EventSink, serve: F) -> ControlClient + where + F: FnOnce(TcpStream) + Send + 'static, + { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + thread::spawn(move || { + let (mut sock, _) = listener.accept().unwrap(); + match ControlClientMsg::read(&mut sock).unwrap() { + ControlClientMsg::Hello(h) => assert_eq!(h.control_version, CONTROL_VERSION), + other => panic!("expected Hello, got {other:?}"), + } + ControlServerMsg::HelloOk(hello_ok()) + .encode(&mut sock) + .unwrap(); + sock.flush().unwrap(); + serve(sock); + }); + let sock = TcpStream::connect(addr).unwrap(); + ControlClient::over_tcp(sock, &hello(), events).unwrap() + } + + fn no_events() -> EventSink { + Box::new(|_| {}) + } + + fn reply_to(w: &mut W, req_id: u64, reply: ReplyOk) { + ControlServerMsg::Response { + req_id, + reply: ControlReply::Ok(reply), + } + .encode(w) + .unwrap(); + w.flush().unwrap(); + } + + /// The handshake completes, a request goes out, and its reply comes back — + /// over loopback TCP, with the client's reader thread doing the decoding. + #[test] + fn a_request_and_its_reply_cross_a_real_duplex_stream() { + let client = client_with_peer(no_events(), |mut sock| { + for _ in 0..3 { + match ControlClientMsg::read(&mut sock) { + Ok(ControlClientMsg::Request { req_id, req }) => { + let reply = match req { + ControlRequest::Ping => ReplyOk::Pong, + ControlRequest::Stat { .. } => ReplyOk::Meta(meta()), + ControlRequest::Exists { .. } => ReplyOk::Bool(true), + other => panic!("unexpected request {other:?}"), + }; + reply_to(&mut sock, req_id, reply); + } + other => panic!("unexpected message {other:?}"), + } + } + }); + + assert_eq!(client.hello().separator, '/'); + assert_eq!(client.hello().home, "/home/me"); + assert!(client.is_connected()); + + assert_eq!(client.call(ControlRequest::Ping).unwrap(), ReplyOk::Pong); + assert_eq!( + client + .call(ControlRequest::Stat { + path: "/etc/hosts".into() + }) + .unwrap(), + ReplyOk::Meta(meta()) + ); + assert_eq!( + client + .call(ControlRequest::Exists { + path: "/etc".into() + }) + .unwrap(), + ReplyOk::Bool(true) + ); + } + + /// **The reason this layer exists.** A slow request must not hold up a fast + /// one issued behind it. + /// + /// The peer here deliberately answers out of order: it parks a `Git` that + /// arrived first, answers a `ReadDir` that arrived second, and only then + /// goes back to the `Git`. If replies were matched by arrival order — or if + /// one shared lock serialized callers — the `ReadDir` would sit behind the + /// `Git` and the file tree would freeze every time the status bar asked a + /// slow question. The completion order recorded below is the proof it does + /// not. + #[test] + fn a_slow_request_does_not_block_a_fast_one_behind_it() { + let client = Arc::new(client_with_peer(no_events(), |mut sock| { + let mut parked_git = None; + loop { + match ControlClientMsg::read(&mut sock).unwrap() { + ControlClientMsg::Request { req_id, req } => match req { + ControlRequest::Git { .. } => parked_git = Some(req_id), + ControlRequest::ReadDir { .. } => { + reply_to(&mut sock, req_id, ReplyOk::Entries(vec![])); + // Only now, well after the fast reply, does the + // slow one land. + thread::sleep(Duration::from_millis(250)); + reply_to( + &mut sock, + parked_git.expect("git arrived first"), + ReplyOk::Output(Output { + status: Some(0), + stdout: b"clean".to_vec(), + stderr: Vec::new(), + }), + ); + return; + } + other => panic!("unexpected request {other:?}"), + }, + other => panic!("unexpected message {other:?}"), + } + } + })); + + let (done_tx, done_rx) = mpsc::channel::<&'static str>(); + + let slow_client = Arc::clone(&client); + let slow_done = done_tx.clone(); + let slow = thread::spawn(move || { + let out = slow_client + .call(ControlRequest::Git { + cwd: "/repo".into(), + args: vec!["status".into()], + }) + .unwrap(); + slow_done.send("git").unwrap(); + out + }); + + // Let the slow request reach the peer first, so it is unambiguously + // *ahead* of the fast one in both issue order and req_id. + thread::sleep(Duration::from_millis(100)); + let entries = client + .call(ControlRequest::ReadDir { + dir: "/repo".into(), + root: None, + }) + .unwrap(); + done_tx.send("read_dir").unwrap(); + + assert_eq!(entries, ReplyOk::Entries(vec![])); + assert_eq!( + done_rx.recv().unwrap(), + "read_dir", + "the fast request must finish first even though it was issued second" + ); + assert_eq!(done_rx.recv().unwrap(), "git"); + assert!(matches!( + slow.join().unwrap(), + ReplyOk::Output(o) if o.stdout_trimmed() == "clean" + )); + } + + /// Many concurrent callers, each answered with a value only they asked for, + /// with the peer replying in reverse order. Proves the pending table keys + /// on `req_id` rather than on anything positional. + #[test] + fn concurrent_callers_each_get_their_own_reply() { + const N: u64 = 16; + let client = Arc::new(client_with_peer(no_events(), |mut sock| { + let mut seen = Vec::new(); + while (seen.len() as u64) < N { + match ControlClientMsg::read(&mut sock).unwrap() { + ControlClientMsg::Request { req_id, req } => seen.push((req_id, req)), + other => panic!("unexpected message {other:?}"), + } + } + // Reverse order, and each reply echoes the *request's* own path so + // a mismatched delivery is visible rather than merely plausible. + for (req_id, req) in seen.into_iter().rev() { + let path = match req { + ControlRequest::Canonicalize { path } => path, + other => panic!("unexpected request {other:?}"), + }; + reply_to(&mut sock, req_id, ReplyOk::Path(path)); + } + })); + + let handles: Vec<_> = (0..N) + .map(|i| { + let c = Arc::clone(&client); + thread::spawn(move || { + let want = format!("/p/{i}"); + let got = c + .call(ControlRequest::Canonicalize { path: want.clone() }) + .unwrap(); + assert_eq!( + got, + ReplyOk::Path(want), + "caller {i} received another caller's reply" + ); + }) + }) + .collect(); + for h in handles { + h.join().unwrap(); + } + } + + /// A request that outruns its deadline fails with `TimedOut`, sends a + /// `CONTROL_CANCEL`, and — critically — **does not drop the connection**: + /// the next request on the same client still works. + #[test] + fn a_timeout_cancels_that_request_and_leaves_the_connection_usable() { + let (saw_cancel_tx, saw_cancel_rx) = mpsc::channel(); + let client = client_with_peer(no_events(), move |mut sock| { + // Swallow the first request without answering it. + match ControlClientMsg::read(&mut sock).unwrap() { + ControlClientMsg::Request { .. } => {} + other => panic!("unexpected message {other:?}"), + } + // The client should now give up and cancel. + match ControlClientMsg::read(&mut sock).unwrap() { + ControlClientMsg::Cancel { req_id } => saw_cancel_tx.send(req_id).unwrap(), + other => panic!("expected Cancel, got {other:?}"), + } + // A late reply to the abandoned request: it must be discarded, not + // handed to whoever asks next. + reply_to(&mut sock, 1, ReplyOk::Path("/late".into())); + match ControlClientMsg::read(&mut sock).unwrap() { + ControlClientMsg::Request { req_id, .. } => { + reply_to(&mut sock, req_id, ReplyOk::Pong) + } + other => panic!("unexpected message {other:?}"), + } + }); + + let e = client + .call_with_deadline( + ControlRequest::Canonicalize { + path: "/slow".into(), + }, + &[], + Duration::from_millis(150), + ) + .unwrap_err(); + assert_eq!(e.kind(), io::ErrorKind::TimedOut); + assert_eq!(saw_cancel_rx.recv().unwrap(), 1, "cancel names the request"); + + assert!(client.is_connected(), "a timeout must not kill the link"); + assert_eq!( + client.call(ControlRequest::Ping).unwrap(), + ReplyOk::Pong, + "the late reply to the abandoned request must not have been \ + mistaken for this one's" + ); + } + + /// When the link dies, every waiting caller is woken with + /// `ConnectionReset` immediately — not left to serve out its own deadline, + /// which for a `ReadFile` would be thirty seconds of a frozen editor. + #[test] + fn losing_the_link_fails_in_flight_requests_at_once() { + let client = Arc::new(client_with_peer(no_events(), |mut sock| { + // Take one request, then hang up without answering. + let _ = ControlClientMsg::read(&mut sock); + drop(sock); + })); + + let started = Instant::now(); + let e = client + .call(ControlRequest::ReadFile { + path: "/big".into(), + max_bytes: u64::MAX, + }) + .unwrap_err(); + assert_eq!(e.kind(), io::ErrorKind::ConnectionReset); + assert!( + started.elapsed() < Duration::from_secs(5), + "should fail on the hangup, not wait out the 30s ReadFile deadline" + ); + assert!(!client.is_connected()); + + // And a request issued afterwards fails immediately rather than + // queueing against a dead socket. + let e = client.call(ControlRequest::Ping).unwrap_err(); + assert_eq!(e.kind(), io::ErrorKind::ConnectionReset); + } + + /// Bulk bytes ride both directions: content up with `WriteFile`, content + /// down with `ReadFile`, each beside a JSON head carrying the parameters + /// that a bare-blob frame could not have held. + #[test] + fn blobs_ride_in_both_directions() { + let content: Vec = (0..=255u8).cycle().take(300_000).collect(); + let echoed = content.clone(); + let client = client_with_peer(no_events(), move |mut sock| { + match ControlClientMsg::read(&mut sock).unwrap() { + ControlClientMsg::RequestBlob { req_id, req, blob } => { + assert_eq!( + req, + ControlRequest::WriteFile { + path: "/tmp/f".into() + } + ); + assert_eq!(blob, echoed); + reply_to(&mut sock, req_id, ReplyOk::Meta(meta())); + } + other => panic!("expected RequestBlob, got {other:?}"), + } + match ControlClientMsg::read(&mut sock).unwrap() { + ControlClientMsg::Request { req_id, .. } => { + ControlServerMsg::ResponseBlob { + req_id, + reply: ControlReply::Ok(ReplyOk::FileMeta { meta: meta() }), + blob: echoed.clone(), + } + .encode(&mut sock) + .unwrap(); + sock.flush().unwrap(); + } + other => panic!("expected Request, got {other:?}"), + } + }); + + let wrote = client + .call_with_blob( + ControlRequest::WriteFile { + path: "/tmp/f".into(), + }, + &content, + ) + .unwrap(); + assert_eq!(wrote, ReplyOk::Meta(meta())); + + let read = client + .call_full( + ControlRequest::ReadFile { + path: "/tmp/f".into(), + max_bytes: u64::MAX, + }, + &[], + ) + .unwrap(); + assert_eq!(read.reply, ReplyOk::FileMeta { meta: meta() }); + assert_eq!(read.blob, content, "the blob is the file's content"); + } + + /// An error reply becomes an `io::Error` of the matching kind, carrying the + /// server's message — this is the path every "file is gone" notification in + /// the GUI takes. + #[test] + fn an_error_reply_becomes_a_matching_io_error() { + let client = client_with_peer(no_events(), |mut sock| { + match ControlClientMsg::read(&mut sock).unwrap() { + ControlClientMsg::Request { req_id, .. } => { + ControlServerMsg::Response { + req_id, + reply: ControlReply::Err(WireError::new( + WireErrorKind::FileTooLarge, + "/big is 900 MB, over the 10 MB limit", + )), + } + .encode(&mut sock) + .unwrap(); + sock.flush().unwrap(); + } + other => panic!("unexpected message {other:?}"), + } + }); + + let e = client + .call(ControlRequest::ReadFile { + path: "/big".into(), + max_bytes: 10 * 1024 * 1024, + }) + .unwrap_err(); + assert_eq!(e.kind(), io::ErrorKind::FileTooLarge); + assert!(e.to_string().contains("900 MB")); + } + + /// Pushes reach the sink without any request to hang them on, and interleave + /// freely with replies rather than being serialized behind them. + #[test] + fn events_reach_the_sink_interleaved_with_replies() { + let (tx, rx) = mpsc::channel(); + let sink: EventSink = Box::new(move |e| tx.send(e).unwrap()); + let client = client_with_peer(sink, |mut sock| { + ControlServerMsg::Event(ControlEvent::Watch { + id: 1, + paths: vec!["/a".into()], + }) + .encode(&mut sock) + .unwrap(); + match ControlClientMsg::read(&mut sock).unwrap() { + ControlClientMsg::Request { req_id, .. } => { + ControlServerMsg::Event(ControlEvent::WatchOverflow { id: 1 }) + .encode(&mut sock) + .unwrap(); + reply_to(&mut sock, req_id, ReplyOk::Pong); + } + other => panic!("unexpected message {other:?}"), + } + ControlServerMsg::Event(ControlEvent::Preempted { + workspace: "w1".into(), + by: "other".into(), + }) + .encode(&mut sock) + .unwrap(); + sock.flush().unwrap(); + }); + + assert_eq!(client.call(ControlRequest::Ping).unwrap(), ReplyOk::Pong); + let mut got = Vec::new(); + for _ in 0..3 { + got.push(rx.recv_timeout(Duration::from_secs(5)).unwrap()); + } + assert_eq!( + got, + vec![ + ControlEvent::Watch { + id: 1, + paths: vec!["/a".into()] + }, + ControlEvent::WatchOverflow { id: 1 }, + ControlEvent::Preempted { + workspace: "w1".into(), + by: "other".into() + }, + ] + ); + } + + /// Request ids start at 1 and never reissue 0, which is what keeps a push + /// from ever being mistaken for a reply. + #[test] + fn request_ids_start_at_one_and_increase() { + let (tx, rx) = mpsc::channel(); + let client = client_with_peer(no_events(), move |mut sock| { + for _ in 0..4 { + match ControlClientMsg::read(&mut sock).unwrap() { + ControlClientMsg::Request { req_id, .. } => { + tx.send(req_id).unwrap(); + reply_to(&mut sock, req_id, ReplyOk::Pong); + } + other => panic!("unexpected message {other:?}"), + } + } + }); + for _ in 0..4 { + client.call(ControlRequest::Ping).unwrap(); + } + let ids: Vec = (0..4).map(|_| rx.recv().unwrap()).collect(); + assert_eq!(ids, vec![1, 2, 3, 4]); + } + + /// A peer speaking a different control dialect is reported as exactly that, + /// naming both versions. The peer answers the handshake and *then* hangs up + /// — a `HELLO` has no `req_id`, so there is no error reply to carry the + /// mismatch, and without this frame the client would only see a closed + /// socket. + #[test] + fn a_control_version_mismatch_names_both_versions() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + thread::spawn(move || { + let (mut sock, _) = listener.accept().unwrap(); + let _ = ControlClientMsg::read(&mut sock); + let mut ok = hello_ok(); + ok.control_version = CONTROL_VERSION + 7; + ok.build = "from-the-future".into(); + ControlServerMsg::HelloOk(ok).encode(&mut sock).unwrap(); + sock.flush().unwrap(); + }); + + let sock = TcpStream::connect(addr).unwrap(); + let e = ControlClient::over_tcp(sock, &hello(), no_events()).unwrap_err(); + assert_eq!(e.kind(), io::ErrorKind::Unsupported); + let msg = e.to_string(); + assert!(msg.contains("from-the-future"), "names the peer: {msg}"); + assert!( + msg.contains(&format!("v{}", CONTROL_VERSION + 7)), + "names their version: {msg}" + ); + assert!( + msg.contains(&format!("v{CONTROL_VERSION}")), + "names ours: {msg}" + ); + } + + /// A peer that answers the handshake with something other than `HELLO_OK` + /// is a desync, not a greeting to be tolerated. + #[test] + fn a_handshake_answered_with_the_wrong_frame_is_invalid_data() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + thread::spawn(move || { + let (mut sock, _) = listener.accept().unwrap(); + let _ = ControlClientMsg::read(&mut sock); + ControlServerMsg::Event(ControlEvent::WatchOverflow { id: 1 }) + .encode(&mut sock) + .unwrap(); + sock.flush().unwrap(); + }); + let sock = TcpStream::connect(addr).unwrap(); + let e = ControlClient::over_tcp(sock, &hello(), no_events()).unwrap_err(); + assert_eq!(e.kind(), io::ErrorKind::InvalidData); + } + + /// **Regression: closing an idle client must not hang.** + /// + /// The reader spends its life blocked in `read_frame`, and an idle peer has + /// no reason to send anything. Failing the pending table does not wake a + /// thread that is inside a syscall, so a `close` that merely set a flag and + /// then joined would wait for a frame neither side will ever send — and + /// because `Drop` calls `close`, *every* dropped client would freeze + /// whichever thread dropped it. For a user shutting a remote workspace that + /// is the UI thread. + #[test] + fn closing_an_idle_client_returns_promptly() { + let client = client_with_peer(no_events(), |mut sock| { + // A peer that answers nothing and never hangs up: exactly the + // situation a healthy, quiet connection is in. + let _ = ControlClientMsg::read(&mut sock); + thread::sleep(Duration::from_secs(30)); + }); + assert_eq!(client.call(ControlRequest::Ping).unwrap_err().kind(), { + // The peer never replies, so this times out — leaving the reader + // parked exactly where the bug needs it. + io::ErrorKind::TimedOut + }); + + let started = Instant::now(); + client.close(); + assert!( + started.elapsed() < CLOSE_GRACE * 2, + "close() took {:?}; it must not wait on a peer that will never speak", + started.elapsed() + ); + } + + /// And the same guarantee through `Drop`, which is how it is actually + /// reached in production — nobody calls `close` by hand. + #[test] + fn dropping_an_idle_client_returns_promptly() { + let client = client_with_peer(no_events(), |mut sock| { + let _ = ControlClientMsg::read(&mut sock); + thread::sleep(Duration::from_secs(30)); + }); + let started = Instant::now(); + drop(client); + assert!( + started.elapsed() < CLOSE_GRACE * 2, + "drop took {:?}", + started.elapsed() + ); + } + + /// With a shutdown wired up the reader is genuinely reaped, not merely + /// abandoned — the fast path, and the one that keeps threads from piling up + /// across a session's worth of reconnects. + #[test] + fn close_reaps_the_reader_when_the_link_can_be_shut_down() { + let client = client_with_peer(no_events(), |mut sock| { + let _ = ControlClientMsg::read(&mut sock); + thread::sleep(Duration::from_secs(30)); + }); + let started = Instant::now(); + client.close(); + // A shut-down socket wakes the reader in microseconds; anything near + // the grace period would mean it was detached rather than reaped. + assert!( + started.elapsed() < CLOSE_GRACE, + "reader should have been reaped, not waited out: {:?}", + started.elapsed() + ); + assert!(!client.is_connected()); + } + + /// A frame the client cannot parse drops the connection rather than being + /// skipped: once the stream's position is in doubt, nothing after it can be + /// trusted. Waiting callers are woken, not left hanging. + #[test] + fn a_malformed_frame_tears_the_connection_down() { + let client = client_with_peer(no_events(), |mut sock| { + let _ = ControlClientMsg::read(&mut sock); + // A well-framed frame of an unknown control kind. + write_frame(&mut sock, 64, &[0u8; 16]).unwrap(); + sock.flush().unwrap(); + thread::sleep(Duration::from_secs(2)); + }); + let e = client.call(ControlRequest::Ping).unwrap_err(); + assert_eq!(e.kind(), io::ErrorKind::ConnectionReset); + assert!(!client.is_connected()); + } +} diff --git a/crates/tty7-core/src/daemon/duplex.rs b/crates/tty7-core/src/daemon/duplex.rs new file mode 100644 index 00000000..bae2fd13 --- /dev/null +++ b/crates/tty7-core/src/daemon/duplex.rs @@ -0,0 +1,345 @@ +//! [`Duplex`] — one bidirectional link, as the *server* side sees it. +//! +//! The client half of a control connection takes its two halves separately +//! ([`ControlClient::connect`](crate::daemon::control::ControlClient::connect)): +//! it is always the side that opened the link, so it already holds whatever it +//! opened. The server is handed things — an accepted socket, or a process it was +//! `exec`'d into — and has to get two halves *out* of them. That is all this +//! trait does. +//! +//! # Why not `try_clone` +//! +//! Every existing server path in the tree splits with `try_clone` +//! (`daemon::server::handle_conn`), which works because a socket is one object +//! with two directions. Under `tty7-server --stdio` it is not: the read half is +//! file descriptor 0 and the write half is file descriptor 1, two unrelated +//! pipes to two different places. There is nothing to clone. So the split +//! happens once, at construction, and the trait says so. +//! +//! # Shutdown is [`LinkShutdown`], not a second abstraction +//! +//! A server thread parked in `read` cannot be woken by a flag — it is inside a +//! syscall, and the peer has no reason to send anything, because it is waiting +//! for a reply. This is precisely the deadlock +//! [`LinkShutdown`](crate::daemon::control::LinkShutdown) exists to break on the +//! client side, and it is the same deadlock here. +//! +//! So [`Halves::shutdown`] **is** a `LinkShutdown`, reusing that trait rather +//! than mirroring it. Two shutdown abstractions that mean the same thing would +//! be two places to forget a transport, and the socket impls would have to be +//! written twice; adopting the existing one costs nothing and keeps a single +//! answer to "how do I force this link to end". +//! +//! It is also non-optional. Every transport a server can be handed *has* an +//! answer — for a socket it is `shutdown(2)`, for stdio it is closing the write +//! half — and making the field an `Option` would only mean the question could be +//! skipped, which is how the client-side deadlock happened in the first place. + +use std::io; +use std::sync::{Arc, Mutex}; + +use crate::daemon::control::LinkShutdown; + +/// The two halves of a link, plus the handle that can force the read half to +/// return. +pub struct Halves { + /// Inbound bytes. Read on the connection's own thread. + pub read: R, + /// Outbound bytes. Shared by every worker replying on this connection, so + /// the server keeps it behind a mutex and writes one whole frame per lock. + pub write: W, + /// How to end the link from another thread. See the module docs. + pub shutdown: Arc, +} + +/// A bidirectional link a server can serve one connection over. +/// +/// Implemented for the accepted-socket types on both platforms and for +/// [`StdioDuplex`]; anything else a future transport brings (an SSH channel, +/// say) implements it the same way. +pub trait Duplex: Send + 'static { + /// The inbound half. + type Read: io::Read + Send + 'static; + /// The outbound half. + type Write: io::Write + Send + 'static; + + /// Consume the link and yield its halves. Consuming rather than borrowing is + /// what lets stdio participate: its halves were never one object to begin + /// with. + fn split(self) -> io::Result>; + + /// A short label for logs and diagnostics, so "the link dropped" can say + /// *which kind* of link. + fn kind_label(&self) -> &'static str; +} + +#[cfg(unix)] +impl Duplex for std::os::unix::net::UnixStream { + type Read = std::os::unix::net::UnixStream; + type Write = std::os::unix::net::UnixStream; + + fn split(self) -> io::Result> { + let read = self.try_clone()?; + let shutdown: Arc = Arc::new(self.try_clone()?); + Ok(Halves { + read, + write: self, + shutdown, + }) + } + + fn kind_label(&self) -> &'static str { + "unix" + } +} + +impl Duplex for std::net::TcpStream { + type Read = std::net::TcpStream; + type Write = std::net::TcpStream; + + fn split(self) -> io::Result> { + let read = self.try_clone()?; + let shutdown: Arc = Arc::new(self.try_clone()?); + Ok(Halves { + read, + write: self, + shutdown, + }) + } + + fn kind_label(&self) -> &'static str { + "tcp" + } +} + +// --------------------------------------------------------------------------- +// stdio +// --------------------------------------------------------------------------- + +/// The process's own stdin and stdout, as one link. +/// +/// This is how `tty7-server --stdio --serve` is reached: whatever spawned it +/// (`ssh host tty7-server --stdio`, `wsl.exe -- tty7-server --stdio`, or a test +/// harness) talks to it down the pipes it was born with. There is no socket, no +/// port and no filesystem rendezvous — which is the entire point, because that +/// is what makes the path work under `AllowStreamLocalForwarding no`, under WSL, +/// and in CI on a box with no sshd. +/// +/// Unix only. A Windows `tty7-server` is reached over its own loopback +/// transport; nothing in the tree spawns one down a pipe, and the handle +/// surgery below has no portable equivalent worth carrying unused. +#[cfg(unix)] +pub struct StdioDuplex { + read: std::fs::File, + write: StdioWriter, +} + +#[cfg(unix)] +impl StdioDuplex { + /// Take exclusive ownership of the process's stdin and stdout. + /// + /// **This is a hijack, deliberately.** Both descriptors are duplicated, and + /// the originals are then pointed at `/dev/null`. After this call `println!`, + /// a library's stray progress bar, and anything else that reaches for fd 1 + /// write into the void instead of into the middle of a control frame. A + /// protocol carried on stdout cannot share stdout, and "nothing in this + /// process ever prints" is not an invariant that survives a dependency + /// bump — so it is enforced here rather than assumed. + /// + /// Diagnostics still work: stderr is untouched, and it is where the stdio + /// server logs. + pub fn take() -> io::Result { + use std::os::fd::FromRawFd as _; + + // Duplicate first, redirect second: if the redirect failed after a + // successful dup we would still hold a working link, whereas the other + // order could leave the process with no stdout at all. + let stdin_fd = dup_fd(libc::STDIN_FILENO)?; + let stdout_fd = dup_fd(libc::STDOUT_FILENO)?; + redirect_to_null(libc::STDIN_FILENO)?; + redirect_to_null(libc::STDOUT_FILENO)?; + + // SAFETY: both fds came from `dup(2)` above, are owned by nobody else, + // and are handed to `File` exactly once. + let read = unsafe { std::fs::File::from_raw_fd(stdin_fd) }; + let write = unsafe { std::fs::File::from_raw_fd(stdout_fd) }; + Ok(StdioDuplex { + read, + write: StdioWriter { + inner: Arc::new(Mutex::new(Some(write))), + }, + }) + } +} + +#[cfg(unix)] +impl Duplex for StdioDuplex { + type Read = std::fs::File; + type Write = StdioWriter; + + fn split(self) -> io::Result> { + let shutdown: Arc = Arc::new(self.write.clone()); + Ok(Halves { + read: self.read, + write: self.write, + shutdown, + }) + } + + fn kind_label(&self) -> &'static str { + "stdio" + } +} + +/// The write half of a [`StdioDuplex`]: a closable stdout. +/// +/// Closing it is the whole reason this type exists rather than a bare `File`. +/// The peer of a stdio server has exactly one way to learn the server is +/// finished — reading EOF on the pipe — and the only way to produce that EOF is +/// to drop the last descriptor on our end. So the file lives behind a shared +/// slot that [`LinkShutdown::shutdown_link`] can empty from any thread. +/// +/// The read half is deliberately *not* closable the same way. Closing a pipe +/// descriptor another thread is already blocked reading on does not wake it (the +/// open file description outlives the descriptor), so pretending otherwise would +/// be a shutdown that silently does nothing. What actually ends the read is the +/// peer hanging up — which closing our write half is exactly what provokes. It +/// is the same half-close a TCP `shutdown(Write)` performs, for the same reason. +#[cfg(unix)] +#[derive(Clone)] +pub struct StdioWriter { + inner: Arc>>, +} + +#[cfg(unix)] +impl io::Write for StdioWriter { + fn write(&mut self, buf: &[u8]) -> io::Result { + let mut slot = self.inner.lock().unwrap_or_else(|e| e.into_inner()); + match slot.as_mut() { + Some(f) => f.write(buf), + None => Err(io::Error::new( + io::ErrorKind::BrokenPipe, + "stdio link was shut down", + )), + } + } + + fn flush(&mut self) -> io::Result<()> { + let mut slot = self.inner.lock().unwrap_or_else(|e| e.into_inner()); + match slot.as_mut() { + Some(f) => f.flush(), + // Nothing buffered and nothing to flush it to: the shutdown already + // happened, and reporting an error here would only turn an orderly + // close into a logged failure. + None => Ok(()), + } + } +} + +#[cfg(unix)] +impl LinkShutdown for StdioWriter { + fn shutdown_link(&self) -> io::Result<()> { + let taken = self + .inner + .lock() + .unwrap_or_else(|e| e.into_inner()) + .take() + .is_some(); + if !taken { + // Already closed. Idempotent on purpose: `close` runs from both the + // teardown path and a `Drop`, and neither should have to know + // whether the other went first. + return Ok(()); + } + Ok(()) + } +} + +#[cfg(unix)] +fn dup_fd(fd: libc::c_int) -> io::Result { + // SAFETY: `fd` is one of the standard descriptors; `dup` either returns a + // fresh owned descriptor or -1 with errno set. + let new = unsafe { libc::dup(fd) }; + if new < 0 { + return Err(io::Error::last_os_error()); + } + Ok(new) +} + +#[cfg(unix)] +fn redirect_to_null(fd: libc::c_int) -> io::Result<()> { + let null = std::fs::OpenOptions::new() + .read(true) + .write(true) + .open("/dev/null")?; + use std::os::fd::AsRawFd as _; + // SAFETY: both descriptors are valid and owned here; `dup2` closes `fd` + // and re-points it at `/dev/null` atomically. + let rc = unsafe { libc::dup2(null.as_raw_fd(), fd) }; + if rc < 0 { + return Err(io::Error::last_os_error()); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::{Read as _, Write as _}; + + /// The socket impls hand back halves that really are the same link, and a + /// shutdown from a third handle ends a parked read — the property the whole + /// trait exists for. + #[cfg(unix)] + #[test] + fn a_unix_stream_splits_into_working_halves() { + use std::os::unix::net::UnixStream; + let (a, b) = UnixStream::pair().unwrap(); + let Halves { + mut read, + mut write, + shutdown, + } = a.split().unwrap(); + + let mut peer = b; + write.write_all(b"ping").unwrap(); + write.flush().unwrap(); + let mut got = [0u8; 4]; + peer.read_exact(&mut got).unwrap(); + assert_eq!(&got, b"ping"); + + peer.write_all(b"pong").unwrap(); + let mut got = [0u8; 4]; + read.read_exact(&mut got).unwrap(); + assert_eq!(&got, b"pong"); + + // The parked reader has to come back, not hang. + let reader = std::thread::spawn(move || { + let mut sink = Vec::new(); + read.read_to_end(&mut sink).map(|_| ()) + }); + shutdown.shutdown_link().unwrap(); + let _ = reader.join().unwrap(); + } + + /// Shutting a stdio writer down closes it for good: a later write fails + /// rather than quietly succeeding into a descriptor the peer no longer has. + #[cfg(unix)] + #[test] + fn a_shut_stdio_writer_refuses_further_writes() { + let tmp = tempfile::NamedTempFile::new().unwrap(); + let file = std::fs::File::create(tmp.path()).unwrap(); + let mut w = StdioWriter { + inner: Arc::new(Mutex::new(Some(file))), + }; + w.write_all(b"before").unwrap(); + w.shutdown_link().unwrap(); + assert_eq!( + w.write(b"after").unwrap_err().kind(), + io::ErrorKind::BrokenPipe + ); + // Idempotent: teardown and `Drop` both call it. + w.shutdown_link().unwrap(); + assert_eq!(std::fs::read(tmp.path()).unwrap(), b"before"); + } +} diff --git a/crates/tty7-core/src/daemon/install/asset.rs b/crates/tty7-core/src/daemon/install/asset.rs new file mode 100644 index 00000000..8828a95b --- /dev/null +++ b/crates/tty7-core/src/daemon/install/asset.rs @@ -0,0 +1,429 @@ +//! The pure half of the installer: `uname -sm` → release asset, client version → +//! release tag → download URL, and the remote paths a server binary lives at. +//! +//! Everything here is a total function of its arguments — no network, no SFTP, no +//! clock — which is the point: [`docs/remote-server-assets.md`] is a *literal* +//! contract with the release workflow, and a contract is only worth having if +//! both sides can be tested without standing up the other one. +//! +//! [`docs/remote-server-assets.md`]: ../../../../../docs/remote-server-assets.md + +use std::fmt; + +/// The release asset for a 64-bit x86 Linux box. +pub const ASSET_X86_64: &str = "tty7-server-x86_64-unknown-linux-musl"; +/// The release asset for a 64-bit ARM Linux box. +pub const ASSET_AARCH64: &str = "tty7-server-aarch64-unknown-linux-musl"; +/// The sha256 manifest published beside every asset in a release. +pub const CHECKSUMS_ASSET: &str = "checksums.txt"; + +/// Where release assets are downloaded from. The tag and asset name are appended +/// (`{RELEASE_BASE}/{tag}/{asset}`); HTTPS to github.com is the trust anchor for +/// the checksum file itself (§16). +pub const RELEASE_BASE: &str = "https://github.com/l0ng-ai/tty7/releases/download"; + +/// The `XDG_DATA_HOME`-shaped directory tty7 owns on a remote machine, relative +/// to `$HOME`. Split into components because the installer has to `mkdir` each +/// level (SFTP has no `mkdir -p`) and because joining is `/`-only regardless of +/// the *client's* OS — a Windows client must not produce `.local\share` +/// (contract §4.3). +pub const INSTALL_DIR_COMPONENTS: [&str; 4] = [".local", "share", "tty7", "bin"]; + +/// Why a machine cannot be served a `tty7-server`. +/// +/// Both variants carry the raw `uname -sm` output: the whole value of refusing +/// instead of guessing is that the user can read the string we refused and either +/// recognise their box or paste it into an issue. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum UnsupportedTarget { + /// `uname -s` is not `Linux`. A remote tty7-server is a Linux binary; there + /// is no macOS/BSD/Solaris asset to fall back to. + NotLinux { raw: String }, + /// `uname -s` is `Linux` but `uname -m` is not one we publish for — 32-bit + /// arm, i686, riscv64, or something we have never seen. + UnknownMachine { raw: String }, + /// `uname -sm` did not produce the two whitespace-separated words it is + /// specified to. Almost always means the command did not run at all (a login + /// shell that printed a banner, a restricted shell) rather than a real + /// answer, so it gets its own variant with the raw text. + Unparseable { raw: String }, +} + +impl UnsupportedTarget { + /// The `uname -sm` text this refusal is about, as the remote printed it. + pub fn raw(&self) -> &str { + match self { + Self::NotLinux { raw } | Self::UnknownMachine { raw } | Self::Unparseable { raw } => { + raw + } + } + } +} + +impl fmt::Display for UnsupportedTarget { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::NotLinux { raw } => write!( + f, + "a remote tty7 workspace needs a Linux host; this machine reports `uname -sm` = {raw:?}" + ), + Self::UnknownMachine { raw } => write!( + f, + "no tty7-server is published for this architecture (`uname -sm` = {raw:?}); \ + supported: x86_64/amd64 and aarch64/arm64" + ), + Self::Unparseable { raw } => write!( + f, + "`uname -sm` did not answer with a system and machine name (got {raw:?})" + ), + } + } +} + +impl std::error::Error for UnsupportedTarget {} + +/// Map raw `uname -sm` output to the release asset that runs on that machine. +/// +/// **Exact string match, then fail.** No prefix matching, no "starts with `arm` +/// so it is probably aarch64" heuristic. Guessing wrong here installs a binary +/// that dies with `Exec format error` at first exec — an error with no visible +/// connection to the architecture detection that caused it, on a machine the user +/// may not be able to inspect. An unknown machine string is a clean, explainable +/// refusal that names itself (`docs/remote-server-assets.md`). +/// +/// `amd64` / `arm64` are accepted alongside the values Linux actually reports +/// because some container images and BSD-flavoured userlands normalise to them. +pub fn asset_for_uname(uname_sm: &str) -> Result<&'static str, UnsupportedTarget> { + let raw = uname_sm.trim().to_string(); + let mut words = raw.split_whitespace(); + let (Some(system), Some(machine), None) = (words.next(), words.next(), words.next()) else { + return Err(UnsupportedTarget::Unparseable { raw }); + }; + if system != "Linux" { + return Err(UnsupportedTarget::NotLinux { raw }); + } + match machine { + "x86_64" | "amd64" => Ok(ASSET_X86_64), + "aarch64" | "arm64" | "armv8l" | "armv8b" => Ok(ASSET_AARCH64), + _ => Err(UnsupportedTarget::UnknownMachine { raw }), + } +} + +/// Resolve an asset name that arrived over a wire back to a `&'static str`. +/// +/// [`super::InstallRequest::asset`] is `&'static str` because on the producing +/// side it is always one of the two consts above. A decoder cannot promise that, +/// and the relay in `daemon::router` has to rebuild the request a *different +/// process* raised — so the two known names map to themselves, and anything else +/// (a client older or newer than the daemon that named it) is leaked. +/// +/// Leaking is bounded in the way that matters: the value comes from tty7's own +/// daemon naming one of its own release assets, and a session sees at most a +/// handful of distinct machines. It is preferred to guessing one of the two +/// consts, which would show the user a prompt naming the wrong architecture. +pub fn interned(name: &str) -> &'static str { + if name == ASSET_X86_64 { + ASSET_X86_64 + } else if name == ASSET_AARCH64 { + ASSET_AARCH64 + } else { + Box::leak(name.to_string().into_boxed_str()) + } +} + +/// The release tag whose assets a client of `version` must download. +/// +/// The nightly channel republishes a single rolling `nightly` tag every night, so +/// a nightly client must not ask for `v26.7.6-nightly.20260727` — that tag does +/// not exist and never will. Rule: the version contains `-nightly.` → `nightly`; +/// otherwise `v` + version. +pub fn release_tag(version: &str) -> String { + if version.contains("-nightly.") { + "nightly".to_string() + } else { + format!("v{version}") + } +} + +/// The download URL for one asset of one release. +pub fn download_url(tag: &str, asset: &str) -> String { + format!("{RELEASE_BASE}/{tag}/{asset}") +} + +/// Absolute remote paths for one client version's server binary. +/// +/// Built with explicit `/` joins from an absolute `$HOME` the remote resolved for +/// us (SFTP does not expand `~`, and `PathBuf::join` would emit `\` on a Windows +/// client — contract §4.3). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RemotePaths { + /// `$HOME/.local/share/tty7/bin`. + pub bin_dir: String, + /// `$HOME/.local/share/tty7/bin/tty7-server-` — the atomically + /// published binary. The version is *in the path* so two clients of different + /// versions can coexist on one machine; only the running daemon is singular. + pub binary: String, + /// `$HOME/.local/share/tty7/bin/.tty7-server-.tmp` — where the bytes + /// land before `chmod` + `rename`. + /// + /// A dotfile, so a half-written upload is not mistaken for an installed + /// server by anything globbing the directory, and a *fixed* name per version + /// so an install killed mid-upload leaves one reusable file behind instead of + /// accumulating random-suffixed litter on someone else's disk. + pub temp: String, + /// Every directory that must exist before the upload, outermost first. SFTP + /// has no recursive mkdir, so the installer walks this. + pub dir_chain: Vec, +} + +/// Build the remote paths for `version` under an absolute remote `home`. +pub fn remote_paths(home: &str, version: &str) -> RemotePaths { + let home = home.trim_end_matches('/'); + let mut dir_chain = Vec::with_capacity(INSTALL_DIR_COMPONENTS.len()); + let mut cursor = home.to_string(); + for part in INSTALL_DIR_COMPONENTS { + cursor = format!("{cursor}/{part}"); + dir_chain.push(cursor.clone()); + } + let bin_dir = cursor; + RemotePaths { + binary: format!("{bin_dir}/{}", binary_name(version)), + temp: format!("{bin_dir}/.tty7-server-{version}.tmp"), + dir_chain, + bin_dir, + } +} + +/// The filename a `version`'s server binary is installed under. +pub fn binary_name(version: &str) -> String { + format!("tty7-server-{version}") +} + +/// The version encoded in an installed binary's *path*, if it is one of ours. +/// +/// This is how the running daemon's build is identified without asking it: the +/// install path carries the version by construction, so `readlink /proc//exe` +/// on the remote answers "which tty7-server is serving this machine" for every +/// build we have ever shipped — including ones older than any handshake we could +/// send them. +pub fn version_from_path(path: &str) -> Option { + let name = path.rsplit('/').next()?; + let rest = name.strip_prefix("tty7-server-")?; + if rest.is_empty() { + return None; + } + Some(rest.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The contract's mapping table, row for row. This test *is* the client half + /// of `docs/remote-server-assets.md`: if the release workflow ever renames an + /// asset, this is where the two sides stop agreeing. + #[test] + fn uname_maps_to_the_published_assets() { + for raw in ["Linux x86_64", "Linux amd64"] { + assert_eq!(asset_for_uname(raw).unwrap(), ASSET_X86_64, "{raw}"); + } + for raw in [ + "Linux aarch64", + "Linux arm64", + "Linux armv8l", + "Linux armv8b", + ] { + assert_eq!(asset_for_uname(raw).unwrap(), ASSET_AARCH64, "{raw}"); + } + } + + /// Real `uname` output ends in a newline, and a shell may pad it. Trimming is + /// the only normalisation allowed — the *words* are matched exactly. + #[test] + fn uname_output_is_trimmed_before_matching() { + assert_eq!(asset_for_uname("Linux x86_64\n").unwrap(), ASSET_X86_64); + assert_eq!( + asset_for_uname(" Linux x86_64 \r\n").unwrap(), + ASSET_X86_64 + ); + } + + /// The refusal path, which is the whole reason this function exists. Every + /// one of these would be a plausible prefix/fuzzy match — `x86_64-v2` starts + /// with `x86_64`, `armv7l` starts with `arm`, `Linux` appears inside + /// `GNU/Linux` — and each would install a binary that cannot exec. + #[test] + fn unknown_machines_are_refused_not_guessed() { + for raw in [ + "Linux i686", + "Linux i386", + "Linux armv7l", + "Linux armv6l", + "Linux riscv64", + "Linux ppc64le", + "Linux s390x", + "Linux x86_64-v2", + "Linux aarch64_be", + "Linux ARM64", + "Linux X86_64", + ] { + let err = asset_for_uname(raw).unwrap_err(); + assert!( + matches!(err, UnsupportedTarget::UnknownMachine { .. }), + "{raw} must be refused as an unknown machine, got {err:?}" + ); + assert_eq!(err.raw(), raw, "the refusal must quote what it refused"); + } + } + + /// A non-Linux host is refused with its own variant so the message can say + /// "needs Linux" rather than "unknown architecture" — the user's next step is + /// completely different. + #[test] + fn non_linux_systems_are_refused() { + for raw in [ + "Darwin arm64", + "FreeBSD amd64", + "SunOS i86pc", + "linux x86_64", + ] { + assert!( + matches!( + asset_for_uname(raw).unwrap_err(), + UnsupportedTarget::NotLinux { .. } + ), + "{raw}" + ); + } + } + + /// Anything that is not exactly two words never reaches the mapping. In + /// practice this catches the common failure where the command did not run and + /// we got a shell banner, an error message, or nothing at all — and it is the + /// guard that keeps a three-word string from silently matching on its first + /// two words. + #[test] + fn output_that_is_not_two_words_is_unparseable() { + for raw in [ + "", + " ", + "Linux", + "x86_64", + "Linux x86_64 GNU/Linux", + "bash: uname: command not found", + ] { + assert!( + matches!( + asset_for_uname(raw).unwrap_err(), + UnsupportedTarget::Unparseable { .. } + ), + "{raw:?}" + ); + } + } + + /// Stable releases resolve to their own tag; nightlies resolve to the single + /// rolling `nightly` tag, because per-night tags are never created. + #[test] + fn release_tag_sends_nightlies_to_the_rolling_tag() { + assert_eq!(release_tag("26.7.5"), "v26.7.5"); + assert_eq!(release_tag("0.1.0"), "v0.1.0"); + assert_eq!(release_tag("26.7.6-nightly.20260727"), "nightly"); + // A pre-release that is *not* a nightly keeps its own tag: only the + // nightly channel republishes under a rolling name. + assert_eq!(release_tag("26.8.0-rc.1"), "v26.8.0-rc.1"); + } + + #[test] + fn download_urls_point_at_the_release_the_tag_names() { + assert_eq!( + download_url(&release_tag("26.7.5"), ASSET_X86_64), + "https://github.com/l0ng-ai/tty7/releases/download/v26.7.5/tty7-server-x86_64-unknown-linux-musl" + ); + assert_eq!( + download_url(&release_tag("26.7.6-nightly.20260727"), CHECKSUMS_ASSET), + "https://github.com/l0ng-ai/tty7/releases/download/nightly/checksums.txt" + ); + } + + /// Path construction, including the `mkdir` chain. Asserted literally: these + /// strings are what an SFTP server sees, and a `\` in any of them (which is + /// what `PathBuf::join` would produce on a Windows client) would create a file + /// named `.local\share\tty7\bin` in the remote home directory. + #[test] + fn remote_paths_are_posix_and_versioned() { + let p = remote_paths("/home/me", "26.7.5"); + assert_eq!(p.bin_dir, "/home/me/.local/share/tty7/bin"); + assert_eq!( + p.binary, + "/home/me/.local/share/tty7/bin/tty7-server-26.7.5" + ); + assert_eq!( + p.temp, + "/home/me/.local/share/tty7/bin/.tty7-server-26.7.5.tmp" + ); + assert_eq!( + p.dir_chain, + vec![ + "/home/me/.local", + "/home/me/.local/share", + "/home/me/.local/share/tty7", + "/home/me/.local/share/tty7/bin", + ] + ); + assert!( + !p.temp.contains('\\') && !p.binary.contains('\\'), + "remote paths are POSIX regardless of the client's OS" + ); + } + + /// The temp name is a sibling dotfile of the target, so the finishing rename + /// is same-directory (same filesystem → atomic) and a partial upload is not + /// mistaken for an installed server. + #[test] + fn temp_path_is_a_hidden_sibling_of_the_binary() { + let p = remote_paths("/home/me", "26.7.5"); + let dir = |s: &str| s.rsplit_once('/').unwrap().0.to_string(); + assert_eq!(dir(&p.temp), dir(&p.binary)); + assert!(p.temp.rsplit('/').next().unwrap().starts_with('.')); + assert!(!p.binary.rsplit('/').next().unwrap().starts_with('.')); + } + + /// A trailing slash on the resolved home (some SFTP servers return `/root/`) + /// must not produce a doubled separator. + #[test] + fn trailing_slash_on_home_is_absorbed() { + assert_eq!( + remote_paths("/root/", "1.0.0").binary, + "/root/.local/share/tty7/bin/tty7-server-1.0.0" + ); + // Root as home is degenerate but must still be well-formed. + assert_eq!(remote_paths("/", "1.0.0").bin_dir, "/.local/share/tty7/bin"); + } + + /// The inverse used to identify a *running* daemon from its executable path. + #[test] + fn version_is_recoverable_from_an_install_path() { + assert_eq!( + version_from_path("/home/me/.local/share/tty7/bin/tty7-server-26.7.4").as_deref(), + Some("26.7.4") + ); + assert_eq!( + version_from_path("tty7-server-26.7.6-nightly.20260727").as_deref(), + Some("26.7.6-nightly.20260727") + ); + // Not ours, or not versioned: no opinion rather than a wrong one. + assert_eq!(version_from_path("/usr/bin/tty7-server"), None); + assert_eq!(version_from_path("/usr/local/bin/tty7-server-"), None); + assert_eq!(version_from_path("/bin/bash"), None); + } + + /// Round-trip: the name we install under is the name we recognise later. + #[test] + fn install_path_and_version_extraction_round_trip() { + for version in ["26.7.5", "0.1.0", "26.7.6-nightly.20260727"] { + let p = remote_paths("/home/me", version); + assert_eq!(version_from_path(&p.binary).as_deref(), Some(version)); + } + } +} diff --git a/crates/tty7-core/src/daemon/install/checksums.rs b/crates/tty7-core/src/daemon/install/checksums.rs new file mode 100644 index 00000000..1699a55a --- /dev/null +++ b/crates/tty7-core/src/daemon/install/checksums.rs @@ -0,0 +1,301 @@ +//! `checksums.txt` parsing and asset verification (§16). +//! +//! The release publishes one GNU coreutils `sha256sum`-format manifest covering +//! every asset. HTTPS to github.com is the trust anchor — the manifest is not +//! separately signed — so this module's whole job is to make sure the bytes we +//! are about to write onto someone else's machine are the bytes that release +//! actually published. +//! +//! Pure: no network, no filesystem. The bytes come in as a slice. + +use std::fmt; + +use sha2::{Digest as _, Sha256}; + +/// A parsed sha256 digest: 32 raw bytes, compared by value rather than by +/// string so casing and whitespace can never make a comparison accidentally +/// succeed. +pub type Digest = [u8; 32]; + +/// Why an asset failed verification. Every variant aborts the install; none of +/// them is retried, and there is no unverified fallback (§17). +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ChecksumError { + /// The manifest has no line for this asset. Either the release is + /// incomplete or we derived an asset name the release does not carry — both + /// are "stop", never "install it anyway". + Missing { asset: String }, + /// The asset's line exists but is not `<64 hex> `. + Malformed { asset: String, line: String }, + /// The manifest and the downloaded bytes disagree. The one variant that can + /// mean something is actively wrong (a corrupted download, a proxy that + /// rewrote the body, a compromised mirror), so it reports both digests. + Mismatch { + asset: String, + expected: String, + actual: String, + }, +} + +impl fmt::Display for ChecksumError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Missing { asset } => write!( + f, + "checksums.txt has no entry for {asset}; refusing to install an unverified binary" + ), + Self::Malformed { asset, line } => write!( + f, + "checksums.txt entry for {asset} is malformed ({line:?}); \ + refusing to install an unverified binary" + ), + Self::Mismatch { + asset, + expected, + actual, + } => write!( + f, + "{asset} failed sha256 verification: release says {expected}, downloaded bytes are \ + {actual}. Install aborted; nothing was written to the remote machine" + ), + } + } +} + +impl std::error::Error for ChecksumError {} + +/// The sha256 of some bytes. +pub fn sha256(bytes: &[u8]) -> Digest { + let mut hasher = Sha256::new(); + hasher.update(bytes); + hasher.finalize().into() +} + +/// Render a digest as lowercase hex, for messages. +pub fn hex(digest: &Digest) -> String { + use fmt::Write as _; + digest.iter().fold(String::with_capacity(64), |mut s, b| { + let _ = write!(s, "{b:02x}"); + s + }) +} + +/// Parse 64 hex characters into a digest. Case-insensitive (§16 step 3); any +/// other length or a non-hex character is a parse failure. +fn parse_hex(s: &str) -> Option { + if s.len() != 64 { + return None; + } + let mut out = [0u8; 32]; + for (i, byte) in out.iter_mut().enumerate() { + *byte = u8::from_str_radix(s.get(i * 2..i * 2 + 2)?, 16).ok()?; + } + Some(out) +} + +/// The digest `manifest` records for `asset`. +/// +/// **The filename field is matched whole, never by substring** (§16 step 2). +/// `tty7-server-x86_64-unknown-linux-musl` happens not to be a substring of any +/// other asset today, but that is an accident of the current release contents, +/// not a property anyone maintains — and a substring match that drifted would +/// silently verify one binary's bytes against another's digest. +/// +/// The coreutils format is ``; the second space is `*` +/// in binary mode (` *`), which some tools emit, so a leading `*` +/// on the name is stripped. Blank lines and `#` comments are skipped. +pub fn expected_digest(manifest: &str, asset: &str) -> Result { + for line in manifest.lines() { + let line = line.trim_end_matches(['\r', '\n']); + if line.trim().is_empty() || line.trim_start().starts_with('#') { + continue; + } + // Split once on whitespace: everything before is the digest field, + // everything after (minus the binary-mode marker) is the filename field. + let Some((digest_field, name_field)) = line.split_once(char::is_whitespace) else { + continue; + }; + let name = name_field.trim_start().trim_start_matches('*'); + if name != asset { + continue; + } + return parse_hex(digest_field).ok_or_else(|| ChecksumError::Malformed { + asset: asset.to_string(), + line: line.to_string(), + }); + } + Err(ChecksumError::Missing { + asset: asset.to_string(), + }) +} + +/// Verify downloaded `bytes` against the manifest. `Ok(())` is the only outcome +/// that permits an install. +pub fn verify(manifest: &str, asset: &str, bytes: &[u8]) -> Result<(), ChecksumError> { + let expected = expected_digest(manifest, asset)?; + let actual = sha256(bytes); + if expected == actual { + return Ok(()); + } + Err(ChecksumError::Mismatch { + asset: asset.to_string(), + expected: hex(&expected), + actual: hex(&actual), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::daemon::install::asset::{ASSET_AARCH64, ASSET_X86_64}; + + /// A manifest shaped exactly like the release workflow's, with digests that + /// really are the sha256 of the payloads below. + fn manifest_for(payloads: &[(&str, &[u8])]) -> String { + payloads + .iter() + .map(|(name, bytes)| format!("{} {name}\n", hex(&sha256(bytes)))) + .collect() + } + + #[test] + fn matching_bytes_verify() { + let bytes = b"\x7fELF pretend this is a server".as_slice(); + let manifest = manifest_for(&[(ASSET_X86_64, bytes), (ASSET_AARCH64, b"other")]); + verify(&manifest, ASSET_X86_64, bytes).expect("the published bytes must verify"); + } + + /// Uppercase hex in the manifest is still the same digest (§16 step 3). + #[test] + fn digest_comparison_is_case_insensitive() { + let bytes = b"payload".as_slice(); + // Only the digest is uppercased — the filename field stays exact-match. + let manifest = format!("{} {ASSET_X86_64}\n", hex(&sha256(bytes)).to_uppercase()); + verify(&manifest, ASSET_X86_64, bytes).expect("case must not matter"); + } + + /// **The failure path §18 names.** Bytes that do not match must abort with + /// both digests reported — not retry, not install anyway. + #[test] + fn mismatched_bytes_abort_with_both_digests() { + let published = b"the real server binary".as_slice(); + let tampered = b"the real server binary!".as_slice(); + let manifest = manifest_for(&[(ASSET_X86_64, published)]); + + let err = verify(&manifest, ASSET_X86_64, tampered).unwrap_err(); + match err { + ChecksumError::Mismatch { + ref asset, + ref expected, + ref actual, + } => { + assert_eq!(asset, ASSET_X86_64); + assert_eq!(*expected, hex(&sha256(published))); + assert_eq!(*actual, hex(&sha256(tampered))); + assert_ne!(expected, actual); + } + other => panic!("a mismatch must report both digests, got {other:?}"), + } + // The message has to be actionable on its own — it is what the user sees. + let msg = err.to_string(); + assert!(msg.contains("sha256"), "{msg}"); + assert!(msg.contains("aborted"), "{msg}"); + } + + /// A one-bit difference is caught. Cheap to assert, and the property the + /// whole verification exists for. + #[test] + fn a_single_flipped_bit_fails() { + let mut payload = vec![0u8; 4096]; + payload[1234] = 0x5a; + let manifest = manifest_for(&[(ASSET_X86_64, &payload)]); + let mut flipped = payload.clone(); + flipped[1234] ^= 0x01; + assert!(matches!( + verify(&manifest, ASSET_X86_64, &flipped), + Err(ChecksumError::Mismatch { .. }) + )); + } + + /// No line for our asset → abort. This is the "release is missing the + /// architecture we need" case, and installing the other architecture (or + /// nothing-checked) would both be worse than stopping. + #[test] + fn a_missing_entry_aborts() { + let manifest = manifest_for(&[(ASSET_AARCH64, b"arm bytes")]); + assert!(matches!( + verify(&manifest, ASSET_X86_64, b"anything"), + Err(ChecksumError::Missing { .. }) + )); + assert!(matches!( + verify("", ASSET_X86_64, b"anything"), + Err(ChecksumError::Missing { .. }) + )); + } + + /// A line whose digest field is not 64 hex characters is malformed, not + /// "close enough". Truncated digests are exactly what a partially-uploaded + /// manifest looks like. + #[test] + fn a_malformed_entry_aborts() { + for bad in [ + "abc tty7-server-x86_64-unknown-linux-musl", + "zz786850e387550fdab836ed7e6dc881de23001b4b4d8ec3a1a0b9d5e0d5c0f1x tty7-server-x86_64-unknown-linux-musl", + " tty7-server-x86_64-unknown-linux-musl", + ] { + let err = expected_digest(bad, ASSET_X86_64).unwrap_err(); + assert!( + matches!( + err, + ChecksumError::Malformed { .. } | ChecksumError::Missing { .. } + ), + "{bad:?} produced {err:?}" + ); + } + } + + /// **Whole-field match, not substring.** A manifest carrying a longer name + /// that *contains* ours must not satisfy the lookup — this is the guard §16 + /// step 2 asks for. + #[test] + fn filename_matching_is_exact_not_substring() { + let payload = b"decoy".as_slice(); + let manifest = format!( + "{} {ASSET_X86_64}.sig\n{} old-{ASSET_X86_64}\n", + hex(&sha256(payload)), + hex(&sha256(payload)), + ); + assert!( + matches!( + expected_digest(&manifest, ASSET_X86_64), + Err(ChecksumError::Missing { .. }) + ), + "neither a suffixed nor a prefixed name may satisfy the lookup" + ); + } + + /// Binary-mode (`*name`) lines, CRLF line endings, comments and blank lines + /// are all shapes a checksum file can legitimately arrive in. + #[test] + fn tolerates_binary_mode_crlf_and_comments() { + let payload = b"payload".as_slice(); + let digest = hex(&sha256(payload)); + let manifest = + format!("# generated by the release workflow\r\n\r\n{digest} *{ASSET_X86_64}\r\n"); + verify(&manifest, ASSET_X86_64, payload).expect("binary-mode CRLF lines must parse"); + } + + /// Empty input hashes to the well-known empty sha256; a fixed vector keeps + /// the hashing itself honest rather than only self-consistent. + #[test] + fn sha256_matches_known_vectors() { + assert_eq!( + hex(&sha256(b"")), + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + ); + assert_eq!( + hex(&sha256(b"abc")), + "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad" + ); + } +} diff --git a/crates/tty7-core/src/daemon/install/download.rs b/crates/tty7-core/src/daemon/install/download.rs new file mode 100644 index 00000000..b4825da0 --- /dev/null +++ b/crates/tty7-core/src/daemon/install/download.rs @@ -0,0 +1,168 @@ +//! The HTTPS half of D5: the *client* downloads release assets and pushes them +//! over SSH, because the machines this feature exists for — behind a jump host, +//! on an internal network, in a locked-down VPC — frequently cannot reach GitHub +//! themselves. +//! +//! ## Why `ureq`, and why behind a feature +//! +//! The GUI's update check uses `reqwest_client`, which wraps Zed's reqwest fork +//! behind `gpui::http_client`. `tty7-core` must not depend on gpui, so that stack +//! is unavailable here. `ureq` is blocking (which matches this call path — the +//! installer runs on a daemon std thread, not in an async context), rustls-based +//! (no OpenSSL, so nothing to find at build time), and shares the `rustls` and +//! `http` versions already in the tree. +//! +//! It is optional, behind `remote-install`, which the GUI crate turns on and +//! `tty7-server` does not. `tty7-server` builds as a *static musl* binary that is +//! itself the thing being downloaded; giving it an HTTP client would add size and +//! a TLS backend to every remote install for a code path it can never take. Same +//! mechanism as the existing `gssapi` feature, for the same reason. + +use std::io::Read as _; +use std::time::Duration; + +use super::AssetFetcher; + +/// Overall budget for one asset download. A 6 MB binary on a bad connection is +/// slow but finite; a stalled TLS session is not, and this is what makes the +/// difference visible. +const DOWNLOAD_TIMEOUT: Duration = Duration::from_secs(180); + +/// Refuse a body larger than this. A release asset is ~6 MB; anything at this +/// scale means we are downloading something other than what we asked for, and +/// buffering it in memory before finding out is not a good trade. +const MAX_ASSET_BYTES: u64 = 128 * 1024 * 1024; + +/// Downloads release assets over HTTPS. +pub struct HttpsFetcher { + agent: ureq::Agent, +} + +impl Default for HttpsFetcher { + fn default() -> Self { + let config = ureq::Agent::config_builder() + .timeout_global(Some(DOWNLOAD_TIMEOUT)) + .user_agent(concat!("tty7/", env!("CARGO_PKG_VERSION"))) + .build(); + Self { + agent: config.into(), + } + } +} + +impl AssetFetcher for HttpsFetcher { + fn get(&self, url: &str) -> Result, String> { + let response = self + .agent + .get(url) + .call() + .map_err(|e| describe(url, &e.to_string()))?; + + // GitHub serves release assets from a redirect to object storage; ureq + // follows those itself. A 404 here is the interesting one: it means the + // release tag we derived from our own version was never published (a + // local dev build, a tag that failed to publish), and saying so beats + // "download failed". + let status = response.status().as_u16(); + if status == 404 { + return Err(format!( + "{url} does not exist (404) — this build's release may not be published" + )); + } + if !(200..300).contains(&status) { + return Err(format!("{url} returned HTTP {status}")); + } + + let mut body = response.into_body(); + let mut bytes = Vec::new(); + body.as_reader() + .take(MAX_ASSET_BYTES + 1) + .read_to_end(&mut bytes) + .map_err(|e| describe(url, &e.to_string()))?; + if bytes.len() as u64 > MAX_ASSET_BYTES { + return Err(format!( + "{url} is larger than the {MAX_ASSET_BYTES} byte ceiling for a release asset" + )); + } + Ok(bytes) + } +} + +/// Turn a transport error into something a user can act on. The distinction +/// worth drawing is "the network is not reachable from here" (retry later, or +/// check the proxy) versus everything else. +fn describe(url: &str, reason: &str) -> String { + let lower = reason.to_ascii_lowercase(); + if lower.contains("dns") || lower.contains("resolve") { + return format!("could not resolve the host for {url} ({reason})"); + } + if lower.contains("certificate") || lower.contains("tls") || lower.contains("handshake") { + return format!( + "TLS failed fetching {url} ({reason}) — a TLS-intercepting proxy would explain this" + ); + } + reason.to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Constructing the agent must not panic (a rustls provider that fails to + /// install would, and would do it at the worst possible moment — mid-connect + /// on someone's first remote workspace). + #[test] + fn the_agent_builds() { + let _ = HttpsFetcher::default(); + } + + /// Talks to the real github.com. `#[ignore]`d because it needs the network, + /// which no other test here does — run it by hand (`cargo test -p tty7-core + /// --features remote-install -- --ignored talks_to_github --nocapture`) + /// after touching the HTTP client. + /// + /// Two things only a live server can prove: + /// + /// - **Redirects are followed.** Every GitHub download path — `/raw/` and + /// `releases/download/…` alike — answers with a 302 to another host. A + /// client that does not follow it returns an empty body under a status + /// that still reads as success, so the "asset" would sha256 to the digest + /// of nothing. Asserting real content is what catches that. + /// - **The TLS trust anchor works.** ureq's webpki roots must accept + /// github.com's chain; that HTTPS connection *is* the security model here + /// (§16 — `checksums.txt` is not separately signed). + /// + /// Deliberately a small file rather than a release asset: assets are ~20 MB + /// and this is a correctness check, not a bandwidth test. + #[test] + #[ignore = "needs the network"] + fn talks_to_github() { + let fetcher = HttpsFetcher::default(); + let bytes = fetcher + .get("https://github.com/l0ng-ai/tty7/raw/main/README.md") + .expect("github must be reachable over TLS"); + assert!( + bytes.len() > 500, + "got {} bytes — an unfollowed redirect looks exactly like this", + bytes.len() + ); + + // And a tag that was never published is reported as such, not as a + // generic transport failure. + let missing = super::super::asset::download_url("v0.0.0-never", "tty7-server-nope"); + let err = fetcher + .get(&missing) + .expect_err("a missing release must fail"); + assert!(err.contains("404"), "{err}"); + } + + /// The proxy/TLS case gets its own wording because the fix is completely + /// different from "try again later". + #[test] + fn tls_failures_name_the_likely_cause() { + let msg = describe("https://example/x", "invalid peer certificate"); + assert!(msg.contains("proxy"), "{msg}"); + let msg = describe("https://example/x", "failed to lookup address information"); + assert!(!msg.contains("proxy"), "{msg}"); + } +} diff --git a/crates/tty7-core/src/daemon/install/mod.rs b/crates/tty7-core/src/daemon/install/mod.rs new file mode 100644 index 00000000..fb897797 --- /dev/null +++ b/crates/tty7-core/src/daemon/install/mod.rs @@ -0,0 +1,1154 @@ +//! Installing, launching and version-matching `tty7-server` on a remote machine +//! (design §12, §16, §17). +//! +//! The six steps, in order: +//! +//! | | Step | Where | +//! |---|---|---| +//! | 1 | `uname -sm` → the release asset that runs there | [`asset::asset_for_uname`] | +//! | 2 | SFTP-stat `~/.local/share/tty7/bin/tty7-server-` | [`Installer::run`] | +//! | 3 | absent → download the asset **on the client** + sha256-verify it | [`download`], [`checksums`] | +//! | 4 | SFTP-put into `bin/.tty7-server-.tmp` | [`RemoteOps::put`] | +//! | 5 | `chmod 0755` then `rename` — atomic publish | [`RemoteOps::rename`] | +//! | 6 | probe the remote control socket; nothing there → launch a detached daemon | [`Installer::ensure_daemon`] | +//! +//! ## Why the client downloads +//! +//! Design D5: the client fetches the binary over HTTPS and pushes it over the +//! existing SSH connection, rather than having the remote `curl` it. Machines +//! behind a jump host or on an air-gapped internal network cannot reach GitHub — +//! and those are a large share of the machines this feature exists for. The +//! client always can, because it just downloaded its own copy of tty7 the same +//! way. +//! +//! ## …except for WSL, which downloads nothing +//! +//! Design §12's last paragraph: a WSL distro is served the Linux binary the +//! *Windows client already shipped with*, not one fetched from a release. Both +//! paths meet at [`ServerBinarySource`] — [`ReleaseDownload`] for a real remote, +//! [`wsl::BundledServerBinary`] for a distro on this machine — so steps 2 and +//! 4-6 (stat, upload, atomic publish, launch) are literally the same code, and +//! only "where do the bytes come from" differs. See [`wsl`]. +//! +//! ## What is injected, and why +//! +//! Three seams — [`RemoteOps`] (SSH/SFTP), [`AssetFetcher`] (HTTPS), and +//! [`InstallConfirm`] (the user) — so the whole flow can be driven by fakes. The +//! failure paths that matter most here (a sha256 mismatch, a full disk, a refused +//! consent) are exactly the ones you cannot conjure on a real machine on demand, +//! so they have to be reachable without one. +//! +//! ## Scope +//! +//! Nothing here uses `sudo` or writes outside `$HOME` (§16). Nothing here opens +//! the workspace link either: this module's contract with the transport +//! (`remote_link` / the SSH router) is exactly [`ensure_remote_server`] — call it, +//! and on `Ok` the far end has the right binary installed and a daemon serving. + +use std::io; +use std::sync::{Arc, Mutex, OnceLock}; +use std::time::{Duration, Instant}; + +pub mod asset; +pub mod checksums; +#[cfg(feature = "remote-install")] +pub mod download; +pub mod ssh_ops; +pub mod wsl; + +pub use asset::{RemotePaths, UnsupportedTarget}; +pub use checksums::ChecksumError; + +use crate::daemon::ssh::SshConnection; + +/// The client version, which is also the version of the server it installs. +/// Client and server ship from the same workspace version, so "the server that +/// matches me" is always `tty7-server-`. +pub fn client_version() -> &'static str { + env!("CARGO_PKG_VERSION") +} + +/// Mode bits for the installed binary: owner-executable, world-readable. Not +/// 0700 — the *directory* is 0700, which is what actually scopes access, and a +/// 0755 binary matches what every other user-local install looks like. +const BINARY_MODE: u32 = 0o755; +/// Mode bits for every directory we create (§16: directories 0700). +const DIR_MODE: u32 = 0o700; + +/// How long a freshly launched remote daemon gets to start answering on its +/// control socket. Longer than the local [`crate::daemon::spawn`] budget: every +/// probe is an SSH round trip, and the far end may be a loaded or distant box. +const REMOTE_STARTUP_TIMEOUT: Duration = Duration::from_secs(15); +/// Gap between probes while waiting for a launched daemon. +const REMOTE_POLL_INTERVAL: Duration = Duration::from_millis(400); +/// How long a remote daemon asked to stop gets before we conclude it will not. +const REMOTE_SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(10); + +// --------------------------------------------------------------------------- +// Injection seams. +// --------------------------------------------------------------------------- + +/// The result of running one command on the remote machine. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ExecOutput { + /// The command's exit status, or `None` if the channel closed without one + /// (killed by a signal, or a server that does not report status). + pub status: Option, + pub stdout: String, + pub stderr: String, +} + +impl ExecOutput { + pub fn success(&self) -> bool { + self.status == Some(0) + } + + /// The most informative one-line reason a command failed, for error + /// messages: stderr if it said anything, else the exit status. + pub(crate) fn failure_reason(&self) -> String { + let stderr = self.stderr.trim(); + if !stderr.is_empty() { + return stderr.lines().next().unwrap_or(stderr).to_string(); + } + match self.status { + Some(code) => format!("exit status {code}"), + None => "the command was killed before it reported a status".to_string(), + } + } +} + +/// What a remote path is, if it is anything. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct RemoteStat { + pub size: u64, + pub mode: u32, + pub is_dir: bool, +} + +/// Everything the installer needs to do *on the remote machine*: run a command +/// and manipulate files. Implemented for real over SSH + SFTP in [`ssh_ops`], +/// and by an in-memory fake in this module's tests. +/// +/// Errors are strings because that is what the SFTP layer produces and because +/// every one of them is destined for a user-visible message that quotes the +/// server's own wording — "Failure" from a full disk, "Permission denied" from a +/// read-only home. Classification back into structure happens in +/// [`InstallError`], where the path is known too. +pub trait RemoteOps: Send + Sync { + /// The remote user's home directory, absolute. SFTP has no `~` expansion, so + /// every path the installer builds starts here. + fn home_dir(&self) -> Result; + /// Run `cmd` through the remote's shell and collect its output. + fn run(&self, cmd: &str) -> Result; + /// Start `cmd` and return as soon as it has been accepted, without waiting + /// for it to exit. Used only for the daemon launch, which by design never + /// exits. + fn spawn_detached(&self, cmd: &str) -> Result<(), String>; + /// `stat` following symlinks. `Ok(None)` means "not there", which is a normal + /// answer, not an error. + fn stat(&self, path: &str) -> Result, String>; + /// Create one directory. Succeeding when it already exists is the + /// implementation's job (SFTP servers disagree about which status they + /// return for that). + fn mkdir(&self, path: &str) -> Result<(), String>; + fn chmod(&self, path: &str, mode: u32) -> Result<(), String>; + /// Write `bytes` to `path`, truncating anything already there. + fn put(&self, path: &str, bytes: &[u8]) -> Result<(), String>; + /// Rename `from` over `to`. Same directory, so same filesystem, so atomic. + fn rename(&self, from: &str, to: &str) -> Result<(), String>; + fn remove_file(&self, path: &str) -> Result<(), String>; + /// Entry names (not paths) in a directory. `Ok(None)` if it does not exist. + fn list_dir(&self, path: &str) -> Result>, String>; +} + +/// Fetches a release asset over HTTPS. The seam exists so the whole install +/// flow is testable without a network, and so the HTTP client stays behind one +/// small interface that `tty7-server` never links (see the `remote-install` +/// feature). +pub trait AssetFetcher: Send + Sync { + fn get(&self, url: &str) -> Result, String>; +} + +/// A verified server binary, and where it came from. +pub struct LoadedBinary { + pub bytes: Vec, + /// Human-readable provenance, quoted verbatim in the consent prompt: a + /// release URL for a downloaded asset, an absolute local path for a bundled + /// one. The user is being asked to approve a write, and "from where" is half + /// of what makes that question answerable. + pub origin: String, +} + +/// Length and provenance, never the bytes: a derived `Debug` here would put six +/// megabytes of ELF into a log line or a test failure. +impl std::fmt::Debug for LoadedBinary { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("LoadedBinary") + .field("bytes", &format_args!("{} bytes", self.bytes.len())) + .field("origin", &self.origin) + .finish() + } +} + +/// Where the bytes of a server binary come from. +/// +/// Three implementations: +/// +/// | Source | Used for | Integrity | +/// |---|---|---| +/// | [`ReleaseDownload`] | Any real remote | sha256 against the release's `checksums.txt` | +/// | [`wsl::BundledServerBinary`] | A WSL distro on this machine | The bytes shipped inside this install; whatever verified *this* client covers them | +/// | [`BundledOrRelease`] | A real remote when a local binary is on hand | The local copy if there is one, else the release's sha256 | +/// +/// A WSL distro downloading its own copy would be absurd — the client is on the +/// same disk, often on a machine that reaches GitHub only through the proxy the +/// user is trying to escape — and a checksum manifest for a file we shipped +/// ourselves would verify nothing that the client's own signature did not. +pub trait ServerBinarySource: Send + Sync { + fn load(&self, version: &str, asset: &'static str) -> Result; +} + +/// A local binary if [`wsl::BUNDLED_DIR_ENV`] names a directory holding one, +/// otherwise the release download. +/// +/// Design D5 chose "client downloads, client uploads" because the *remote* is +/// often walled off from GitHub. But the client can be walled off too — an +/// air-gapped laptop, a TLS-intercepting corporate proxy (`ureq` trusts webpki +/// roots, not the system store), or simply a build with no published release, +/// which is every developer build and every `cargo install` from source. In all +/// of those the bytes are already on the disk and the download is the only thing +/// standing in the way. +/// +/// **Opt-in, and never silent.** With the variable unset this is byte-for-byte +/// `ReleaseDownload`. Pointing it somewhere means "I vouch for these bytes": +/// there is no `checksums.txt` to verify a file the user placed by hand +/// against, exactly as with the WSL bundle. The install prompt still names the +/// origin, so the choice is visible at the moment it matters. +pub struct BundledOrRelease<'a> { + pub fetch: &'a dyn AssetFetcher, + /// Resolved once, at construction, rather than read from the environment + /// per call — so the choice is a value the tests can hand in, and a + /// mid-install change to the variable cannot make two steps disagree. + pub bundled: Option, +} + +impl<'a> BundledOrRelease<'a> { + pub fn from_env(fetch: &'a dyn AssetFetcher) -> Self { + Self { + fetch, + bundled: wsl::BundledServerBinary::from_env_only(), + } + } +} + +impl ServerBinarySource for BundledOrRelease<'_> { + fn load(&self, version: &str, asset: &'static str) -> Result { + match &self.bundled { + // A named directory that does *not* hold this asset is an error, not + // a reason to fall back: someone who set the variable meant to + // install from it, and quietly downloading instead would defeat + // whichever of the reasons above they set it for. + Some(bundled) => bundled.load(version, asset), + None => ReleaseDownload { fetch: self.fetch }.load(version, asset), + } + } +} + +/// The default source: fetch the release asset and its `checksums.txt` over +/// HTTPS, and verify one against the other before anything is written or the +/// user is asked (§16, §17). +pub struct ReleaseDownload<'a> { + pub fetch: &'a dyn AssetFetcher, +} + +impl ServerBinarySource for ReleaseDownload<'_> { + fn load(&self, version: &str, asset: &'static str) -> Result { + let tag = asset::release_tag(version); + let manifest_url = asset::download_url(&tag, asset::CHECKSUMS_ASSET); + let manifest = self + .fetch + .get(&manifest_url) + .map_err(|reason| InstallError::Download { + url: manifest_url.clone(), + reason, + })?; + let manifest = String::from_utf8(manifest).map_err(|_| InstallError::Download { + url: manifest_url.clone(), + reason: "checksums.txt is not valid UTF-8".to_string(), + })?; + + let asset_url = asset::download_url(&tag, asset); + let bytes = self + .fetch + .get(&asset_url) + .map_err(|reason| InstallError::Download { + url: asset_url.clone(), + reason, + })?; + + checksums::verify(&manifest, asset, &bytes).map_err(InstallError::Checksum)?; + Ok(LoadedBinary { + bytes, + origin: asset_url, + }) + } +} + +// --------------------------------------------------------------------------- +// Consent (§12, §16) — the decision point M5's UI plugs into. +// --------------------------------------------------------------------------- + +/// Everything the user needs to answer "may tty7 write a binary onto this +/// machine?": what, where, how big, and where it came from. +/// +/// `size_bytes` is the size of the bytes *already downloaded and verified*, not +/// a `Content-Length` guess — by the time this is raised the download has +/// happened and its sha256 matched, so the number quoted is exactly what will be +/// written. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct InstallRequest { + /// A human label for the machine (`me@build-box:22`), for the prompt title. + pub host: String, + /// The version about to be installed (= the client's own version). + pub version: String, + /// The release asset name, e.g. `tty7-server-x86_64-unknown-linux-musl`. + pub asset: &'static str, + /// The URL it was downloaded from. + pub source_url: String, + /// Absolute remote path it will be published at. + pub remote_path: String, + /// Exact byte count that will be written. + pub size_bytes: u64, + /// Lowercase hex sha256 of those bytes, as published in `checksums.txt` and + /// as verified locally. Shown so a cautious user can check it by hand. + pub sha256: String, +} + +/// The user's answer. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum InstallDecision { + Approve, + Decline, +} + +/// Asks the user whether to write a server binary onto a machine for the first +/// time (§12: "往别人机器上写二进制值得问一次"). +/// +/// **Only the first install on a given machine asks.** "First" is decided from +/// evidence on the remote itself — an empty (or absent) +/// `~/.local/share/tty7/bin` — rather than from client-side bookkeeping, so a +/// reinstalled client, a second laptop, or a wiped config dir does not re-ask +/// about a machine tty7 has demonstrably already written to. Later version +/// upgrades on that machine are silent, which is the design's explicit intent. +/// +/// The default implementation ([`DenyInstall`]) **declines**. A headless daemon +/// with no UI attached must not decide on the user's behalf that writing to +/// their servers is fine; it fails with a message that says consent was never +/// obtained. M5's GUI registers a real one with [`set_install_confirm`]. +pub trait InstallConfirm: Send + Sync { + fn confirm(&self, request: &InstallRequest) -> InstallDecision; +} + +/// The default: no UI, no consent, no install. +pub struct DenyInstall; + +impl InstallConfirm for DenyInstall { + fn confirm(&self, _request: &InstallRequest) -> InstallDecision { + InstallDecision::Decline + } +} + +static CONFIRM: OnceLock>> = OnceLock::new(); + +fn confirm_slot() -> &'static Mutex> { + CONFIRM.get_or_init(|| Mutex::new(Arc::new(DenyInstall))) +} + +/// Register the confirmation handler. Called once by the GUI at startup; last +/// call wins so a test can install its own. +pub fn set_install_confirm(confirm: Arc) { + if let Ok(mut slot) = confirm_slot().lock() { + *slot = confirm; + } +} + +thread_local! { + /// A confirmation handler that outranks [`CONFIRM`] for the duration of one + /// call, on one thread. See [`with_install_confirm`]. + static SCOPED_CONFIRM: std::cell::RefCell>> = + const { std::cell::RefCell::new(None) }; +} + +/// Run `f` with `confirm` answering any install prompt it raises, then put the +/// previous handler back. +/// +/// **Why a thread-local and not just [`set_install_confirm`].** The process-wide +/// slot is the right shape for the GUI, which has exactly one user and one +/// answer for all of them. It is the wrong shape for the *daemon*, where the +/// only handler that can reach a user is one bound to a particular routed +/// connection — the client on the other end of it. Two workspaces connecting to +/// two machines at once each need their own, and a global would give the second +/// one's prompt to the first one's socket. +/// +/// A thread-local works because [`Installer`] is blocking start to finish: the +/// consent question is asked on the same thread that will write the bytes. The +/// router's relay therefore wraps its `spawn_blocking` body in this, and +/// [`install_confirm`] finds it before it looks at the global. +/// +/// Nesting restores rather than clears, so a GUI-process call inside a scoped +/// one (there are none today) would not silently lose its handler. +pub fn with_install_confirm(confirm: Arc, f: impl FnOnce() -> T) -> T { + let previous = SCOPED_CONFIRM.with(|slot| slot.borrow_mut().replace(confirm)); + let out = f(); + SCOPED_CONFIRM.with(|slot| *slot.borrow_mut() = previous); + out +} + +/// The confirmation handler in force: this thread's scoped one +/// ([`with_install_confirm`]) if there is one, else the process-wide one +/// ([`set_install_confirm`]), else [`DenyInstall`]. +pub fn install_confirm() -> Arc { + if let Some(scoped) = SCOPED_CONFIRM.with(|slot| slot.borrow().clone()) { + return scoped; + } + confirm_slot() + .lock() + .map(|slot| slot.clone()) + .unwrap_or_else(|_| Arc::new(DenyInstall)) +} + +// --------------------------------------------------------------------------- +// Version negotiation (§12, mirroring `spawn::ensure_running`). +// --------------------------------------------------------------------------- + +/// A remote daemon that is serving a machine at a *different* build than the +/// client we are running. +/// +/// The local analogue is [`crate::daemon::spawn::MismatchedDaemon`], and the +/// reasoning is identical: that daemon owns every live pane on that machine, so +/// killing it at connect time would silently destroy running work. We keep using +/// it and record the mismatch here; the GUI raises the keep-or-restart prompt and +/// calls [`restart_remote_daemon`] if the user picks restart. +/// +/// Binaries coexist (the install path carries the version), so "restart" means +/// only that the *running process* is replaced — the old binary stays on disk. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct MismatchedRemoteDaemon { + /// Machine label, matching [`InstallRequest::host`]. + pub host: String, + /// The version the running daemon's executable path encodes, or `None` when + /// it could not be read (a locked-down `/proc`, a hand-placed binary). + pub running_version: Option, + /// The executable path the running daemon was launched from, when known. + pub running_exe: Option, + /// The version we installed and would rather it were. + pub wanted_version: String, +} + +static MISMATCHED: Mutex> = Mutex::new(Vec::new()); + +thread_local! { + /// Where mismatches found on this thread go instead of [`MISMATCHED`]. + /// See [`with_mismatch_sink`]. + static SCOPED_MISMATCH: std::cell::RefCell>>>> = + const { std::cell::RefCell::new(None) }; +} + +/// Run `f` with any mismatch it discovers collected into `sink` rather than into +/// the process-wide registry. +/// +/// The counterpart of [`with_install_confirm`], and for the same reason: +/// [`take_mismatched_remote_daemons`] reads a static in *this* process, so a +/// mismatch the daemon finds while opening a routed connection is invisible to +/// the GUI that asked for it. Scoped, it can be handed to the client that is +/// waiting on the other end of that very connection, which is the only party +/// that can answer "keep the old sessions or restart the service?". +pub fn with_mismatch_sink( + sink: Arc>>, + f: impl FnOnce() -> T, +) -> T { + let previous = SCOPED_MISMATCH.with(|slot| slot.borrow_mut().replace(sink)); + let out = f(); + SCOPED_MISMATCH.with(|slot| *slot.borrow_mut() = previous); + out +} + +fn record_mismatch(entry: MismatchedRemoteDaemon) { + if let Some(sink) = SCOPED_MISMATCH.with(|slot| slot.borrow().clone()) { + if let Ok(mut slot) = sink.lock() + && !slot.iter().any(|e| e.host == entry.host) + { + slot.push(entry); + } + return; + } + let Ok(mut slot) = MISMATCHED.lock() else { + return; + }; + // One entry per host: reconnecting to the same machine repeatedly must not + // queue up a prompt per attempt. + if slot.iter().any(|e| e.host == entry.host) { + return; + } + slot.push(entry); +} + +/// File mismatches discovered in *another* process into this one's registry. +/// +/// The relay's landing point (`daemon::router`): the daemon finds the mismatch, +/// the GUI is the process with the keep-or-restart prompt, and +/// [`take_mismatched_remote_daemons`] only ever reads a local static. Without +/// this the prompt design §12 specifies could not fire at all. +pub fn record_remote_mismatches(entries: Vec) { + for entry in entries { + record_mismatch(entry); + } +} + +/// Remote daemons found running at a different build than this client. Take +/// semantics, so the keep-or-restart prompt fires once per discovery rather than +/// once per window. +pub fn take_mismatched_remote_daemons() -> Vec { + MISMATCHED + .lock() + .map(|mut slot| std::mem::take(&mut *slot)) + .unwrap_or_default() +} + +// --------------------------------------------------------------------------- +// Errors (§17: specific, path-bearing, never retried into a different path). +// --------------------------------------------------------------------------- + +#[derive(Debug)] +pub enum InstallError { + /// `uname -sm` could not be run at all. + Probe(String), + /// It ran, and the answer is a machine we do not publish for. + Unsupported(UnsupportedTarget), + /// The remote home directory could not be resolved, so no path can be built. + NoHome(String), + /// The download failed (network, 404 for a tag that was never published, a + /// proxy). Carries the URL, because "which release did it even look for" is + /// the first question. + Download { url: String, reason: String }, + /// sha256 verification failed. Terminal: no retry, no unverified fallback + /// (§16, §17). + Checksum(ChecksumError), + /// A WSL install found no bundled Linux server binary in this client's own + /// installation. Terminal, and deliberately **not** downgraded to a + /// download: design §12 says a WSL distro is served the binary the client + /// shipped with, and silently reaching for GitHub instead would turn a + /// packaging bug into an intermittent network failure on someone else's + /// machine. Names every directory that was looked in, because the fix is + /// always "the installer did not ship the file". + MissingBundled { + asset: &'static str, + searched: Vec, + }, + /// The user was asked and said no. + Declined { host: String, path: String }, + /// A write to the remote failed — full disk, read-only home, no permission. + /// Reports the exact path and the server's own reason, and is **not** + /// retried anywhere else (§17: "不重试,不降级到别的路径"). + Write { path: String, reason: String }, + /// The daemon would not start, or would not answer after starting. + Launch { reason: String }, +} + +impl std::fmt::Display for InstallError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Probe(reason) => write!(f, "could not identify the remote machine: {reason}"), + Self::Unsupported(target) => write!(f, "{target}"), + Self::NoHome(reason) => { + write!(f, "could not resolve the remote home directory: {reason}") + } + Self::Download { url, reason } => { + write!(f, "could not download {url}: {reason}") + } + Self::Checksum(e) => write!(f, "{e}"), + Self::MissingBundled { asset, searched } => write!( + f, + "this build of tty7 does not ship a Linux server binary, so it cannot \ + install one into a WSL distribution: `{asset}` was not found in {}", + if searched.is_empty() { + "any known location".to_string() + } else { + searched.join(", ") + } + ), + Self::Declined { host, path } => write!( + f, + "installing tty7-server at {path} on {host} was not confirmed; nothing was written" + ), + Self::Write { path, reason } => { + write!(f, "could not write {path} on the remote machine: {reason}") + } + Self::Launch { reason } => write!(f, "the remote tty7-server did not start: {reason}"), + } + } +} + +impl std::error::Error for InstallError {} + +impl From for io::Error { + fn from(e: InstallError) -> io::Error { + let kind = match &e { + InstallError::Unsupported(_) | InstallError::MissingBundled { .. } => { + io::ErrorKind::Unsupported + } + InstallError::Declined { .. } => io::ErrorKind::PermissionDenied, + InstallError::Checksum(_) => io::ErrorKind::InvalidData, + InstallError::Launch { .. } => io::ErrorKind::TimedOut, + _ => io::ErrorKind::Other, + }; + io::Error::new(kind, e.to_string()) + } +} + +// --------------------------------------------------------------------------- +// Outcome. +// --------------------------------------------------------------------------- + +/// What [`Installer::run`] actually did, for logs and tests. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct InstallReport { + pub asset: &'static str, + pub paths: RemotePaths, + /// Whether bytes were transferred (false when the right version was already + /// installed). + pub installed: bool, + /// Whether the user was asked (only ever true on a machine with no prior + /// tty7 install). + pub confirmed: bool, + /// Whether a daemon had to be launched (false when one was already serving). + pub launched: bool, + /// Set when a daemon of another build is serving this machine. + pub mismatch: Option, +} + +// --------------------------------------------------------------------------- +// The flow. +// --------------------------------------------------------------------------- + +/// Runs the six steps against injected remote/network/user seams. +pub struct Installer<'a> { + ops: &'a dyn RemoteOps, + /// Set by [`Installer::new`]; wrapped in a [`ReleaseDownload`] at use. + fetch: Option<&'a dyn AssetFetcher>, + /// Set by [`Installer::with_source`], and takes precedence. Exactly one of + /// the two is ever `Some`. + source: Option<&'a dyn ServerBinarySource>, + confirm: &'a dyn InstallConfirm, + host: String, + version: String, + /// Overridable so tests do not spend the real budget waiting for a daemon + /// that a fake will never start. + startup_timeout: Duration, + poll_interval: Duration, +} + +impl<'a> Installer<'a> { + pub fn new( + ops: &'a dyn RemoteOps, + fetch: &'a dyn AssetFetcher, + confirm: &'a dyn InstallConfirm, + host: impl Into, + ) -> Self { + Self { + ops, + fetch: Some(fetch), + source: None, + confirm, + host: host.into(), + version: client_version().to_string(), + startup_timeout: REMOTE_STARTUP_TIMEOUT, + poll_interval: REMOTE_POLL_INTERVAL, + } + } + + /// The same six steps, with `source` deciding where the bytes come from: + /// [`wsl::BundledServerBinary`] for a distro on this machine, + /// [`BundledOrRelease`] for a real remote. A build with no HTTP client at + /// all can still install through the former. + pub fn with_source( + ops: &'a dyn RemoteOps, + source: &'a dyn ServerBinarySource, + confirm: &'a dyn InstallConfirm, + host: impl Into, + ) -> Self { + Self { + ops, + fetch: None, + source: Some(source), + confirm, + host: host.into(), + version: client_version().to_string(), + startup_timeout: REMOTE_STARTUP_TIMEOUT, + poll_interval: REMOTE_POLL_INTERVAL, + } + } + + /// Install a specific version instead of this build's. Tests only — a real + /// client can only speak its own dialect. + pub fn with_version(mut self, version: impl Into) -> Self { + self.version = version.into(); + self + } + + /// Shorten the daemon-startup budget. Tests only. + pub fn with_timeouts(mut self, startup: Duration, poll: Duration) -> Self { + self.startup_timeout = startup; + self.poll_interval = poll; + self + } + + /// The whole flow. On `Ok`, the machine has `tty7-server-` installed + /// and a daemon answering on its control socket. + pub fn run(&self) -> Result { + // --- 1. uname -sm -------------------------------------------------- + let uname = self + .ops + .run("uname -sm") + .map_err(InstallError::Probe) + .and_then(|out| { + if out.success() { + Ok(out.stdout) + } else { + Err(InstallError::Probe(out.failure_reason())) + } + })?; + let asset = asset::asset_for_uname(&uname).map_err(InstallError::Unsupported)?; + + // --- 2. is the matching version already there? ---------------------- + let home = self.ops.home_dir().map_err(InstallError::NoHome)?; + let paths = asset::remote_paths(&home, &self.version); + + let already = self + .ops + .stat(&paths.binary) + .map_err(|reason| InstallError::Write { + path: paths.binary.clone(), + reason, + })?; + + let mut report = InstallReport { + asset, + paths: paths.clone(), + installed: false, + confirmed: false, + launched: false, + mismatch: None, + }; + + // A file that exists but is not executable is a half-finished install + // from a crashed run (rename landed, chmod did not) — redo it rather + // than launching something the kernel will refuse. + let usable = already.is_some_and(|stat| !stat.is_dir && stat.mode & 0o100 != 0); + if !usable { + let (confirmed, _) = self.install(asset, &paths)?; + report.installed = true; + report.confirmed = confirmed; + } + + // --- 6. make sure a daemon is serving -------------------------------- + let (launched, mismatch) = self.ensure_daemon(&paths)?; + report.launched = launched; + report.mismatch = mismatch; + Ok(report) + } + + /// Steps 3–5: download, verify, confirm, upload, publish. + fn install( + &self, + asset: &'static str, + paths: &RemotePaths, + ) -> Result<(bool, Vec), InstallError> { + // --- 3. load + verify, both on the client --------------------------- + // + // Verification happens *before* the user is asked, not after: a prompt + // for an install that could only fail its own integrity check is worse + // than useless, and asking afterwards lets the prompt quote the exact + // byte count and digest rather than a Content-Length promise. + let LoadedBinary { + bytes, + origin: asset_url, + } = self.load_binary(asset)?; + + // --- consent, once per machine -------------------------------------- + let confirmed = if self.is_first_install(paths) { + let request = InstallRequest { + host: self.host.clone(), + version: self.version.clone(), + asset, + source_url: asset_url, + remote_path: paths.binary.clone(), + size_bytes: bytes.len() as u64, + sha256: checksums::hex(&checksums::sha256(&bytes)), + }; + if self.confirm.confirm(&request) == InstallDecision::Decline { + return Err(InstallError::Declined { + host: self.host.clone(), + path: paths.binary.clone(), + }); + } + true + } else { + false + }; + + // --- 4. upload to the temp name -------------------------------------- + for dir in &paths.dir_chain { + self.ops.mkdir(dir).map_err(|reason| InstallError::Write { + path: dir.clone(), + reason, + })?; + } + // Best effort: a pre-existing directory we do not own (or a server that + // refuses SETSTAT) must not block an install that will otherwise work. + let _ = self.ops.chmod(&paths.bin_dir, DIR_MODE); + + self.ops + .put(&paths.temp, &bytes) + .map_err(|reason| InstallError::Write { + path: paths.temp.clone(), + reason, + })?; + + // --- 5. chmod then rename -------------------------------------------- + // + // chmod *before* the rename, so the binary is never visible at its final + // path in a non-executable state: a concurrent connect that finds + // `tty7-server-` present would otherwise try to exec a 0644 file. + self.ops + .chmod(&paths.temp, BINARY_MODE) + .map_err(|reason| InstallError::Write { + path: paths.temp.clone(), + reason, + })?; + if let Err(reason) = self.ops.rename(&paths.temp, &paths.binary) { + // Some SFTP servers refuse a rename onto an existing name. The only + // way that path exists here is a leftover from an interrupted run + // (a *usable* binary short-circuits in `run`), so removing it and + // retrying the rename is recovery, not a fallback to a different + // location. + let _ = self.ops.remove_file(&paths.binary); + self.ops + .rename(&paths.temp, &paths.binary) + .map_err(|_| InstallError::Write { + path: paths.binary.clone(), + reason, + })?; + } + + Ok((confirmed, bytes)) + } + + /// Where step 3's bytes come from: the injected source if there is one, + /// otherwise a [`ReleaseDownload`] over the injected fetcher. + fn load_binary(&self, asset: &'static str) -> Result { + if let Some(source) = self.source { + return source.load(&self.version, asset); + } + let Some(fetch) = self.fetch else { + // Unreachable through either constructor; a plain error rather than + // a panic because an installer is holding someone else's machine + // open when it runs. + return Err(InstallError::Download { + url: String::new(), + reason: "no binary source was configured".to_string(), + }); + }; + ReleaseDownload { fetch }.load(&self.version, asset) + } + + /// Whether tty7 has ever written to this machine, decided from the remote's + /// own state: an absent or empty `bin` directory means no. + /// + /// Erring towards *asking* — an unreadable directory counts as "first" — + /// keeps the failure mode on the side of one extra prompt rather than one + /// silent write to a machine nobody agreed to. + fn is_first_install(&self, paths: &RemotePaths) -> bool { + match self.ops.list_dir(&paths.bin_dir) { + Ok(Some(entries)) => !entries.iter().any(|name| name.starts_with("tty7-server-")), + Ok(None) => true, + Err(_) => true, + } + } + + /// Step 6. Probe the remote control socket; if nothing answers, launch a + /// detached daemon and probe again until it does. + /// + /// The probe is `tty7-server --stdio --bridge` with stdin closed: `--bridge` + /// connects to the machine's control socket and refuses to serve in-process, + /// so its exit status *is* the answer, and a socket file a crash left behind + /// reads as "nothing there" rather than as a live server. Nothing here + /// parses a frame — the protocol handshake is end-to-end between the GUI and + /// the far server (contract §6.9), and a second opinion about the version + /// living down here is exactly the coupling that design forbids. + fn ensure_daemon( + &self, + paths: &RemotePaths, + ) -> Result<(bool, Option), InstallError> { + if self.daemon_is_serving(paths)? { + return Ok((false, self.check_running_build(paths))); + } + + self.launch_daemon(paths)?; + + let deadline = Instant::now() + self.startup_timeout; + loop { + if self.daemon_is_serving(paths)? { + return Ok((true, self.check_running_build(paths))); + } + if Instant::now() >= deadline { + return Err(InstallError::Launch { + reason: format!( + "{} started but nothing was answering on the control socket after {:?}", + paths.binary, self.startup_timeout + ), + }); + } + std::thread::sleep(self.poll_interval); + } + } + + fn daemon_is_serving(&self, paths: &RemotePaths) -> Result { + let cmd = format!( + "{} --stdio --bridge < /dev/null", + shell_quote(&paths.binary) + ); + match self.ops.run(&cmd) { + Ok(out) => Ok(out.success()), + Err(reason) => Err(InstallError::Launch { reason }), + } + } + + fn launch_daemon(&self, paths: &RemotePaths) -> Result<(), InstallError> { + self.ops + .spawn_detached(&launch_command(&paths.binary)) + .map_err(|reason| InstallError::Launch { reason }) + } + + /// Identify the build of the daemon that is actually serving, and record a + /// mismatch if it is not ours. + /// + /// The install path carries the version by construction, so reading the + /// running process's executable link answers this for *every* build we have + /// shipped — including ones older than any handshake we could send them. + /// Failing to read it is not an error: an unreadable `/proc` means we simply + /// have no opinion, and no opinion must never be reported as a mismatch. + fn check_running_build(&self, paths: &RemotePaths) -> Option { + let out = self.ops.run(RUNNING_EXE_COMMAND).ok()?; + let exe = out.stdout.trim(); + if exe.is_empty() { + return None; + } + let running_version = asset::version_from_path(exe); + if running_version.as_deref() == Some(self.version.as_str()) || exe == paths.binary { + return None; + } + let entry = MismatchedRemoteDaemon { + host: self.host.clone(), + running_version, + running_exe: Some(exe.to_string()), + wanted_version: self.version.clone(), + }; + log::warn!( + "remote {} is served by {} but this client is {}; keeping it and deferring to the user", + entry.host, + entry.running_exe.as_deref().unwrap_or("an unknown build"), + entry.wanted_version, + ); + record_mismatch(entry.clone()); + Some(entry) + } + + /// Replace the running daemon with this client's build — the "restart the + /// service" branch of the version-mismatch prompt. Every pane it is hosting + /// dies; that is what the prompt warns about. + pub fn restart_daemon(&self) -> Result<(), InstallError> { + let home = self.ops.home_dir().map_err(InstallError::NoHome)?; + let paths = asset::remote_paths(&home, &self.version); + + // SIGTERM by the pid whose executable is a tty7-server: the daemon tears + // down like a local `Shutdown`, hanging every pane's child up with its + // usual grace period. + let _ = self.ops.run(TERMINATE_RUNNING_COMMAND); + + let deadline = Instant::now() + REMOTE_SHUTDOWN_TIMEOUT; + while self.daemon_is_serving(&paths)? { + if Instant::now() >= deadline { + return Err(InstallError::Launch { + reason: format!( + "the running remote daemon did not stop within {REMOTE_SHUTDOWN_TIMEOUT:?}" + ), + }); + } + std::thread::sleep(self.poll_interval); + } + + self.launch_daemon(&paths)?; + let deadline = Instant::now() + self.startup_timeout; + loop { + if self.daemon_is_serving(&paths)? { + return Ok(()); + } + if Instant::now() >= deadline { + return Err(InstallError::Launch { + reason: format!("{} was restarted but never started answering", paths.binary), + }); + } + std::thread::sleep(self.poll_interval); + } + } +} + +/// Find the executable path of this user's running `tty7-server`, if any. +/// +/// `readlink /proc//exe` is readable only for the caller's own processes, +/// which is exactly the scope wanted: one `tty7-server` per user (contract §8). +/// `|| true` on the loop keeps a `set -e` login shell from turning "no daemon +/// running" into a failed command. +const RUNNING_EXE_COMMAND: &str = r#"for p in /proc/[0-9]*; do e=$(readlink "$p/exe" 2>/dev/null) || continue; case "$e" in */tty7-server-*) printf '%s' "${e% (deleted)}"; break;; esac; done; true"#; + +/// SIGTERM this user's running `tty7-server`, if any. +const TERMINATE_RUNNING_COMMAND: &str = r#"for p in /proc/[0-9]*; do e=$(readlink "$p/exe" 2>/dev/null) || continue; case "$e" in */tty7-server-*) kill -TERM "${p#/proc/}" 2>/dev/null; break;; esac; done; true"#; + +/// The command that starts a detached remote daemon. +/// +/// `setsid` puts it in its own session so closing the SSH channel — which sends +/// SIGHUP to the session's foreground group — cannot take the daemon with it, +/// mirroring what `spawn::detach` does locally. Not every minimal image ships +/// `setsid`, so `nohup` is the fallback; both redirect all three streams to +/// `/dev/null`, without which the SSH channel would stay open holding the +/// daemon's inherited stdout for as long as the daemon lives. +fn launch_command(binary: &str) -> String { + let bin = shell_quote(binary); + format!( + "if command -v setsid >/dev/null 2>&1; then \ + setsid {bin} --daemon < /dev/null > /dev/null 2>&1 & \ + else \ + nohup {bin} --daemon < /dev/null > /dev/null 2>&1 & \ + fi" + ) +} + +/// POSIX single-quote escaping. Home directories with spaces, apostrophes or +/// `$` in them are rare but real, and every command here interpolates a path. +pub(crate) fn shell_quote(s: &str) -> String { + format!("'{}'", s.replace('\'', r"'\''")) +} + +/// A stable label for a connection, for prompts and mismatch records. +/// +/// **This string crosses the process boundary.** It is what +/// [`MismatchedRemoteDaemon::host`] carries to the GUI, and the GUI resolves it +/// back to a machine through +/// [`RouteTarget::origin_key`](crate::daemon::router::RouteTarget::origin_key), +/// which produces the same [`ConnectionKey`](crate::daemon::ssh::ConnectionKey) +/// string for an SSH target. That is how "Restart Server" finds the box it is +/// about. Changing the shape here without changing that one breaks the restart +/// silently, so the two are pinned together by +/// `the_origin_key_of_an_ssh_target_is_its_connection_key`. +fn connection_label(conn: &SshConnection) -> String { + conn.key().as_str().to_string() +} + +// --------------------------------------------------------------------------- +// The entry point B1's transport calls. +// --------------------------------------------------------------------------- + +/// Make sure `conn`'s machine has this client's `tty7-server` installed and a +/// daemon serving on its control socket, and answer **where that binary is**. +/// +/// **This is the seam with the SSH transport**: call it before opening a link, +/// and on `Ok` `direct-streamlocal` (or the `--stdio` fallback) has something to +/// reach. It is idempotent and cheap on the common path — an already-installed +/// binary plus a live daemon costs two SSH commands and one SFTP stat, no +/// download, no prompt. +/// +/// The returned path is **absolute and version-qualified** +/// (`~/.local/share/tty7/bin/tty7-server-`), and the session-channel +/// fallback must use it rather than the bare name. Nothing puts that directory +/// on a non-interactive `PATH`, and the file is not even called `tty7-server` — +/// so `exec tty7-server --stdio` is a `command not found` on a machine where the +/// install just succeeded. +/// +/// A version mismatch is *not* an error: an older daemon still owns every live +/// pane on that machine, so it keeps serving and the mismatch is recorded for +/// [`take_mismatched_remote_daemons`] to raise. Only a machine we cannot install +/// on, cannot verify a download for, or cannot get a daemon running on fails. +pub fn ensure_remote_server(conn: &Arc) -> io::Result { + let host = connection_label(conn); + ensure_remote_server_labeled(conn, &host) +} + +/// [`ensure_remote_server`] with an explicit machine label for the prompt (the +/// GUI knows the user's own name for a host; the connection key does not). +pub fn ensure_remote_server_labeled(conn: &Arc, host: &str) -> io::Result { + let ops = ssh_ops::SshRemoteOps::new(conn.clone()); + let fetch = default_fetcher(); + let confirm = install_confirm(); + let source = BundledOrRelease::from_env(fetch.as_ref()); + let report = Installer::with_source(&ops, &source, confirm.as_ref(), host).run()?; + log::info!( + "remote {host}: {} at {} ({}{})", + if report.installed { + "installed tty7-server" + } else { + "tty7-server already present" + }, + report.paths.binary, + if report.launched { + "daemon launched" + } else { + "daemon already running" + }, + if report.mismatch.is_some() { + ", build mismatch recorded" + } else { + "" + }, + ); + Ok(report.paths.binary) +} + +/// Restart the remote daemon at this client's build, dropping every pane it +/// hosts. The "restart the service" answer to the version-mismatch prompt. +pub fn restart_remote_daemon(conn: &Arc) -> io::Result<()> { + let host = connection_label(conn); + let ops = ssh_ops::SshRemoteOps::new(conn.clone()); + let fetch = default_fetcher(); + let confirm = install_confirm(); + Installer::new(&ops, fetch.as_ref(), confirm.as_ref(), host).restart_daemon()?; + Ok(()) +} + +/// The HTTPS fetcher, when this build has one. +#[cfg(feature = "remote-install")] +fn default_fetcher() -> Arc { + Arc::new(download::HttpsFetcher::default()) +} + +/// A build without the `remote-install` feature — `tty7-server` itself, which +/// links no HTTP client — can still *use* an installed server, but cannot fetch +/// one. Failing here with a plain message beats failing at link time or, worse, +/// pretending the download was attempted. +#[cfg(not(feature = "remote-install"))] +fn default_fetcher() -> Arc { + struct NoFetcher; + impl AssetFetcher for NoFetcher { + fn get(&self, _url: &str) -> Result, String> { + Err( + "this build has no HTTP client (the `remote-install` feature is off), \ + so it cannot download a server binary" + .to_string(), + ) + } + } + Arc::new(NoFetcher) +} + +#[cfg(test)] +mod tests; diff --git a/crates/tty7-core/src/daemon/install/ssh_ops.rs b/crates/tty7-core/src/daemon/install/ssh_ops.rs new file mode 100644 index 00000000..5ea0adb4 --- /dev/null +++ b/crates/tty7-core/src/daemon/install/ssh_ops.rs @@ -0,0 +1,273 @@ +//! [`RemoteOps`] over a live [`SshConnection`]: command execution on a session +//! channel, file manipulation over SFTP. +//! +//! This is the only file in `install` that talks to a network. Everything it +//! does is a thin, synchronous wrapper — the installer above it is a state +//! machine, and keeping the IO down here as dumb as possible is what lets that +//! state machine be tested against a fake. +//! +//! ## Why the SFTP work is split +//! +//! `stat` / `mkdir` / `chmod` / `rename` / `remove` / `list` all go through +//! [`SftpManager`], which owns one cached SFTP session per connection — the +//! installer costs no extra channel for them. The **byte write** does not: +//! `SftpManager::start_transfer` is the wrong upload path for an install: it is +//! a background job keyed by `pane_id` that reads from a local *file* and +//! reports progress to the GUI's transfer tray, and an install has no pane, no +//! local file (the bytes are in memory, already verified) and nothing to show +//! in a tray. [`SftpManager::put_bytes`] exists for exactly this shape, so the +//! write shares the connection's cached SFTP session — and its +//! retry-once-on-transport-failure behaviour — rather than opening a channel of +//! its own. + +use std::sync::Arc; +use std::time::Duration; + +use russh::ChannelMsg; + +use crate::daemon::protocol::{SftpOp, SftpOpResult}; +use crate::daemon::ssh::{SshConnection, SshManager, sftp::SftpManager}; + +use super::{ExecOutput, RemoteOps, RemoteStat}; + +/// How long any single remote command may take. Generous: `uname` is instant, +/// but the daemon probe opens a socket on a machine that may be busy, and a +/// distant host's round trips add up. Short enough that a hung sshd surfaces as +/// an error rather than as a connect that never returns. +const COMMAND_TIMEOUT: Duration = Duration::from_secs(30); +/// Budget for the fire-and-forget daemon launch. The remote shell backgrounds +/// the daemon and exits immediately, so this only has to cover a round trip. +const LAUNCH_TIMEOUT: Duration = Duration::from_secs(15); + +/// [`RemoteOps`] backed by one authenticated SSH connection. +pub struct SshRemoteOps { + conn: Arc, +} + +impl SshRemoteOps { + pub fn new(conn: Arc) -> Self { + Self { conn } + } + + /// Run one SFTP op through the shared, cached session. + fn sftp_op(&self, op: SftpOp) -> Result { + match SftpManager::global().op(&self.conn, &op) { + SftpOpResult::Error(e) => Err(e), + other => Ok(other), + } + } + + fn block_on(&self, fut: impl Future) -> T { + SshManager::global().handle().block_on(fut) + } +} + +impl RemoteOps for SshRemoteOps { + fn home_dir(&self) -> Result { + // SFTP's REALPATH against the session's own working directory, which is + // the login directory. The same trick the file browser uses to open + // somewhere better than `/`, and the only way to learn `$HOME` without + // trusting a shell to have one set. + match self.sftp_op(SftpOp::Realpath { + path: ".".to_string(), + })? { + SftpOpResult::Link(path) if path.starts_with('/') => Ok(path), + SftpOpResult::Link(path) => Err(format!( + "the remote resolved its home to {path:?}, which is not absolute" + )), + other => Err(format!( + "unexpected SFTP reply resolving the home directory: {other:?}" + )), + } + } + + fn run(&self, cmd: &str) -> Result { + let conn = self.conn.clone(); + let cmd = cmd.to_string(); + self.block_on(async move { + match tokio::time::timeout(COMMAND_TIMEOUT, exec(&conn, &cmd)).await { + Ok(result) => result, + Err(_) => Err(format!( + "the remote did not finish `{cmd}` within {COMMAND_TIMEOUT:?}" + )), + } + }) + } + + fn spawn_detached(&self, cmd: &str) -> Result<(), String> { + let conn = self.conn.clone(); + let cmd = cmd.to_string(); + // The remote shell backgrounds the process and exits, so this *is* a + // normal exec — only the budget differs. Its exit status is ignored on + // purpose: `sh -c '... &'` reports on the backgrounding, never on the + // daemon, and whether the daemon really came up is settled by probing + // its socket, not by trusting a shell. + self.block_on(async move { + match tokio::time::timeout(LAUNCH_TIMEOUT, exec(&conn, &cmd)).await { + Ok(Ok(_)) => Ok(()), + Ok(Err(e)) => Err(e), + Err(_) => Err(format!( + "the remote did not accept the daemon launch within {LAUNCH_TIMEOUT:?}" + )), + } + }) + } + + fn stat(&self, path: &str) -> Result, String> { + match self.sftp_op(SftpOp::Stat { + path: path.to_string(), + }) { + Ok(SftpOpResult::Stat(entry)) => Ok(Some(RemoteStat { + size: entry.size, + mode: entry.permissions, + is_dir: entry.kind == crate::daemon::protocol::SftpEntryKind::Dir, + })), + Ok(other) => Err(format!("unexpected SFTP reply for stat: {other:?}")), + // "Not there" is an answer, not a failure — it is the *expected* + // answer on the first install, and turning it into an error would + // make step 2 unable to say "go install it". + Err(e) if is_not_found(&e) => Ok(None), + Err(e) => Err(e), + } + } + + fn mkdir(&self, path: &str) -> Result<(), String> { + match self.sftp_op(SftpOp::Mkdir { + path: path.to_string(), + }) { + Ok(_) => Ok(()), + // Servers disagree about which status an existing directory gets + // (`Failure`, `PermissionDenied`, a bare "file already exists"), so + // the authority on "does it exist" is a stat, not the error text. + Err(e) => match self.stat(path) { + Ok(Some(stat)) if stat.is_dir => Ok(()), + _ => Err(e), + }, + } + } + + fn chmod(&self, path: &str, mode: u32) -> Result<(), String> { + self.sftp_op(SftpOp::Chmod { + path: path.to_string(), + mode, + }) + .map(|_| ()) + } + + fn put(&self, path: &str, bytes: &[u8]) -> Result<(), String> { + SftpManager::global().put_bytes(&self.conn, path, bytes) + } + + fn rename(&self, from: &str, to: &str) -> Result<(), String> { + self.sftp_op(SftpOp::Rename { + from: from.to_string(), + to: to.to_string(), + }) + .map(|_| ()) + } + + fn remove_file(&self, path: &str) -> Result<(), String> { + self.sftp_op(SftpOp::RemoveFile { + path: path.to_string(), + }) + .map(|_| ()) + } + + fn list_dir(&self, path: &str) -> Result>, String> { + match SftpManager::global().list(&self.conn, path) { + Ok(entries) => Ok(Some(entries.into_iter().map(|e| e.name).collect())), + Err(e) if is_not_found(&e) => Ok(None), + Err(e) => Err(e), + } + } +} + +/// Whether a stringified SFTP error means "no such file", which every caller +/// here treats as a normal answer rather than a failure. +/// +/// russh-sftp renders a server status as `: `; the message text +/// is the server's, so this matches on the shapes OpenSSH and the common +/// non-OpenSSH servers produce rather than on a code we cannot see. +fn is_not_found(msg: &str) -> bool { + let lower = msg.to_ascii_lowercase(); + lower.contains("no such file") + || lower.contains("nosuchfile") + || lower.contains("not found") + || lower.contains("does not exist") +} + +/// Run one command on its own session channel and collect everything it said. +/// +/// Loops until `wait()` returns `None` rather than breaking on `Eof`/`Close`: +/// the exit status arrives as its own message and can follow both, and the exit +/// status is the entire point of the daemon probe. +async fn exec(conn: &Arc, cmd: &str) -> Result { + let mut channel = conn + .open_session_channel() + .await + .map_err(|e| format!("could not open a command channel: {e}"))?; + channel + .exec(true, cmd) + .await + .map_err(|e| format!("could not run `{cmd}`: {e}"))?; + // Close our end of the command's stdin immediately. Nothing here writes to + // a command, and the daemon probe specifically relies on its stdin ending + // so the bridge it starts hangs up instead of parking forever. + let _ = channel.eof().await; + + let mut stdout = Vec::new(); + let mut stderr = Vec::new(); + let mut status = None; + while let Some(msg) = channel.wait().await { + match msg { + ChannelMsg::Data { data } => stdout.extend_from_slice(&data), + ChannelMsg::ExtendedData { data, .. } => stderr.extend_from_slice(&data), + ChannelMsg::ExitStatus { exit_status } => status = Some(exit_status), + _ => {} + } + } + + Ok(ExecOutput { + status, + stdout: String::from_utf8_lossy(&stdout).into_owned(), + stderr: String::from_utf8_lossy(&stderr).into_owned(), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The "absent" classification has to hold for the wordings the servers we + /// meet actually use, because step 2's whole decision ("is the right version + /// already installed?") rests on it — and misreading a missing file as an + /// error would turn every first install into a hard failure. + #[test] + fn missing_files_are_recognised_across_server_wordings() { + for msg in [ + "2: No such file", + "No such file or directory", + "NoSuchFile", + "file not found", + "The system cannot find the path specified: does not exist", + ] { + assert!(is_not_found(msg), "{msg:?} means absent"); + } + } + + /// And must not swallow the failures §17 requires to be reported: a full + /// disk or a read-only home has to surface as an error with a path, never as + /// "the file isn't there, go ahead and install". + #[test] + fn real_failures_are_not_mistaken_for_absence() { + for msg in [ + "3: Permission denied", + "4: Failure", + "no space left on device", + "disk quota exceeded", + "connection reset by peer", + ] { + assert!(!is_not_found(msg), "{msg:?} is a failure, not an absence"); + } + } +} diff --git a/crates/tty7-core/src/daemon/install/tests.rs b/crates/tty7-core/src/daemon/install/tests.rs new file mode 100644 index 00000000..3e228e22 --- /dev/null +++ b/crates/tty7-core/src/daemon/install/tests.rs @@ -0,0 +1,1159 @@ +//! The install flow, driven end to end against an in-memory remote. +//! +//! Contract §18 asks for four things by name — `uname` parsing, version path +//! construction, atomic replacement, and the sha256 failure path — and none of +//! them may touch the network. The first two are unit-tested in +//! [`super::asset`] and [`super::checksums`]; the last two need the *whole* +//! sequence, which is what the fake remote here provides. +//! +//! The fake keeps a journal of every operation in order. That is what makes +//! "atomic" testable: atomicity is not a property of any single call, it is the +//! claim that the final path is only ever touched by a `rename` of an +//! already-`chmod`ed temp — which is a statement about the *order* of the +//! journal. + +use std::collections::HashMap; +use std::sync::Mutex; +use std::time::Duration; + +use super::*; +use crate::daemon::install::asset::{ASSET_X86_64, CHECKSUMS_ASSET}; + +const VERSION: &str = "26.7.5"; +const HOME: &str = "/home/me"; +const BIN_DIR: &str = "/home/me/.local/share/tty7/bin"; +const BINARY: &str = "/home/me/.local/share/tty7/bin/tty7-server-26.7.5"; +const TEMP: &str = "/home/me/.local/share/tty7/bin/.tty7-server-26.7.5.tmp"; + +/// Stand-in for the release asset. Content is irrelevant; only its digest is. +const SERVER_BYTES: &[u8] = b"\x7fELF...a static musl tty7-server, pretend it is 6 MB"; + +// --------------------------------------------------------------------------- +// Fakes. +// --------------------------------------------------------------------------- + +#[derive(Clone, Debug)] +struct FakeFile { + bytes: Vec, + mode: u32, + is_dir: bool, +} + +/// One entry in the journal. Only the operations that can change what is on +/// disk are recorded; reads are not, because no ordering claim depends on them. +#[derive(Clone, Debug, PartialEq, Eq)] +enum Journal { + Mkdir(String), + Put { path: String, len: usize }, + Chmod { path: String, mode: u32 }, + Rename { from: String, to: String }, + Remove(String), + Exec(String), + Launch, +} + +struct FakeRemote { + files: Mutex>, + journal: Mutex>, + uname: String, + /// Set to make every `put` fail, simulating a full disk / read-only home. + put_error: Option, + daemon_running: Mutex, + /// What `readlink /proc//exe` finds, when a daemon is running. + running_exe: Mutex>, + /// Whether launching actually starts the fake daemon (false models a binary + /// that dies on exec). + launch_works: bool, +} + +impl FakeRemote { + fn new() -> Self { + let mut files = HashMap::new(); + files.insert( + HOME.to_string(), + FakeFile { + bytes: Vec::new(), + mode: 0o755, + is_dir: true, + }, + ); + Self { + files: Mutex::new(files), + journal: Mutex::new(Vec::new()), + uname: "Linux x86_64\n".to_string(), + put_error: None, + daemon_running: Mutex::new(false), + running_exe: Mutex::new(None), + launch_works: true, + } + } + + /// A machine tty7 has installed on before (so consent is not re-asked). + fn with_previous_install(self, version: &str) -> Self { + self.preinstall(&format!("{BIN_DIR}/tty7-server-{version}"), 0o755); + self + } + + fn preinstall(&self, path: &str, mode: u32) { + let mut files = self.files.lock().unwrap(); + for dir in asset::remote_paths(HOME, VERSION).dir_chain { + files.entry(dir).or_insert(FakeFile { + bytes: Vec::new(), + mode: 0o700, + is_dir: true, + }); + } + files.insert( + path.to_string(), + FakeFile { + bytes: SERVER_BYTES.to_vec(), + mode, + is_dir: false, + }, + ); + } + + fn serving(self, exe: &str) -> Self { + *self.daemon_running.lock().unwrap() = true; + *self.running_exe.lock().unwrap() = Some(exe.to_string()); + self + } + + fn journal(&self) -> Vec { + self.journal.lock().unwrap().clone() + } + + fn file(&self, path: &str) -> Option { + self.files.lock().unwrap().get(path).cloned() + } + + fn writes(&self) -> Vec { + self.journal() + .into_iter() + .filter(|j| !matches!(j, Journal::Exec(_))) + .collect() + } +} + +impl RemoteOps for FakeRemote { + fn home_dir(&self) -> Result { + Ok(HOME.to_string()) + } + + fn run(&self, cmd: &str) -> Result { + self.journal.lock().unwrap().push(Journal::Exec(cmd.into())); + let ok = |stdout: &str| { + Ok(ExecOutput { + status: Some(0), + stdout: stdout.to_string(), + stderr: String::new(), + }) + }; + if cmd == "uname -sm" { + return ok(&self.uname); + } + if cmd == RUNNING_EXE_COMMAND { + let exe = self.running_exe.lock().unwrap().clone().unwrap_or_default(); + return ok(&exe); + } + if cmd == TERMINATE_RUNNING_COMMAND { + *self.daemon_running.lock().unwrap() = false; + *self.running_exe.lock().unwrap() = None; + return ok(""); + } + if cmd.contains("--stdio --bridge") { + let running = *self.daemon_running.lock().unwrap(); + return Ok(ExecOutput { + status: Some(if running { 0 } else { 1 }), + stdout: String::new(), + stderr: if running { + String::new() + } else { + "no control server".into() + }, + }); + } + if cmd.contains("--daemon") { + self.journal.lock().unwrap().push(Journal::Launch); + if self.launch_works { + *self.daemon_running.lock().unwrap() = true; + let mut exe = self.running_exe.lock().unwrap(); + if exe.is_none() { + *exe = Some(BINARY.to_string()); + } + } + return ok(""); + } + Err(format!("the fake remote does not know `{cmd}`")) + } + + fn spawn_detached(&self, cmd: &str) -> Result<(), String> { + self.run(cmd).map(|_| ()) + } + + fn stat(&self, path: &str) -> Result, String> { + Ok(self.file(path).map(|f| RemoteStat { + size: f.bytes.len() as u64, + mode: f.mode, + is_dir: f.is_dir, + })) + } + + fn mkdir(&self, path: &str) -> Result<(), String> { + self.journal + .lock() + .unwrap() + .push(Journal::Mkdir(path.into())); + self.files + .lock() + .unwrap() + .entry(path.to_string()) + .or_insert(FakeFile { + bytes: Vec::new(), + mode: 0o755, + is_dir: true, + }); + Ok(()) + } + + fn chmod(&self, path: &str, mode: u32) -> Result<(), String> { + self.journal.lock().unwrap().push(Journal::Chmod { + path: path.into(), + mode, + }); + match self.files.lock().unwrap().get_mut(path) { + Some(f) => { + f.mode = mode; + Ok(()) + } + None => Err("2: No such file".into()), + } + } + + fn put(&self, path: &str, bytes: &[u8]) -> Result<(), String> { + self.journal.lock().unwrap().push(Journal::Put { + path: path.into(), + len: bytes.len(), + }); + if let Some(e) = &self.put_error { + return Err(e.clone()); + } + self.files.lock().unwrap().insert( + path.to_string(), + FakeFile { + bytes: bytes.to_vec(), + mode: 0o644, + is_dir: false, + }, + ); + Ok(()) + } + + fn rename(&self, from: &str, to: &str) -> Result<(), String> { + self.journal.lock().unwrap().push(Journal::Rename { + from: from.into(), + to: to.into(), + }); + let mut files = self.files.lock().unwrap(); + match files.remove(from) { + Some(f) => { + files.insert(to.to_string(), f); + Ok(()) + } + None => Err("2: No such file".into()), + } + } + + fn remove_file(&self, path: &str) -> Result<(), String> { + self.journal + .lock() + .unwrap() + .push(Journal::Remove(path.into())); + self.files.lock().unwrap().remove(path); + Ok(()) + } + + fn list_dir(&self, path: &str) -> Result>, String> { + let files = self.files.lock().unwrap(); + if !files.get(path).is_some_and(|f| f.is_dir) { + return Ok(None); + } + let prefix = format!("{path}/"); + Ok(Some( + files + .keys() + .filter_map(|k| k.strip_prefix(&prefix)) + .filter(|rest| !rest.contains('/')) + .map(str::to_string) + .collect(), + )) + } +} + +/// Serves a canned release: the asset plus a manifest that really does contain +/// its digest, unless [`FakeRelease::corrupt`] says otherwise. +struct FakeRelease { + asset_bytes: Vec, + /// Bytes the manifest claims the asset hashes to. Differs from + /// `asset_bytes` in the tampering test. + manifest_of: Vec, + fetched: Mutex>, + fail: Option, +} + +impl FakeRelease { + fn new() -> Self { + Self { + asset_bytes: SERVER_BYTES.to_vec(), + manifest_of: SERVER_BYTES.to_vec(), + fetched: Mutex::new(Vec::new()), + fail: None, + } + } + + /// A release whose manifest does not describe the bytes it serves — a + /// corrupted download, a rewriting proxy, a tampered mirror. + fn corrupt(mut self) -> Self { + self.asset_bytes = b"something else entirely".to_vec(); + self + } + + fn manifest(&self) -> String { + format!( + "{} {ASSET_X86_64}\n{} checksums-are-not-self-describing\n", + checksums::hex(&checksums::sha256(&self.manifest_of)), + checksums::hex(&checksums::sha256(b"noise")), + ) + } + + fn fetched(&self) -> Vec { + self.fetched.lock().unwrap().clone() + } +} + +impl AssetFetcher for FakeRelease { + fn get(&self, url: &str) -> Result, String> { + self.fetched.lock().unwrap().push(url.to_string()); + if let Some(e) = &self.fail { + return Err(e.clone()); + } + if url.ends_with(CHECKSUMS_ASSET) { + return Ok(self.manifest().into_bytes()); + } + if url.ends_with(ASSET_X86_64) { + return Ok(self.asset_bytes.clone()); + } + Err(format!("404: {url}")) + } +} + +struct FakeUser { + decision: InstallDecision, + asked: Mutex>, +} + +impl FakeUser { + fn approving() -> Self { + Self { + decision: InstallDecision::Approve, + asked: Mutex::new(Vec::new()), + } + } + fn declining() -> Self { + Self { + decision: InstallDecision::Decline, + asked: Mutex::new(Vec::new()), + } + } + fn asked(&self) -> Vec { + self.asked.lock().unwrap().clone() + } +} + +impl InstallConfirm for FakeUser { + fn confirm(&self, request: &InstallRequest) -> InstallDecision { + self.asked.lock().unwrap().push(request.clone()); + self.decision + } +} + +fn installer<'a>( + remote: &'a FakeRemote, + release: &'a FakeRelease, + user: &'a FakeUser, + host: &str, +) -> Installer<'a> { + Installer::new(remote, release, user, host) + .with_version(VERSION) + .with_timeouts(Duration::from_millis(200), Duration::from_millis(10)) +} + +// --------------------------------------------------------------------------- +// The happy path. +// --------------------------------------------------------------------------- + +/// All six steps on a machine that has never seen tty7: identify it, find +/// nothing installed, download and verify, ask once, publish atomically, and +/// launch a daemon. +#[test] +fn first_install_runs_all_six_steps() { + let remote = FakeRemote::new(); + let release = FakeRelease::new(); + let user = FakeUser::approving(); + + let report = installer(&remote, &release, &user, "me@fresh-box:22") + .run() + .expect("a clean install must succeed"); + + assert_eq!(report.asset, ASSET_X86_64); + assert_eq!(report.paths.binary, BINARY); + assert!(report.installed, "bytes were transferred"); + assert!(report.confirmed, "a new machine is confirmed once"); + assert!( + report.launched, + "nothing was serving, so a daemon was started" + ); + assert!(report.mismatch.is_none()); + + let installed = remote + .file(BINARY) + .expect("the binary is at its final path"); + assert_eq!(installed.bytes, SERVER_BYTES, "the verified bytes landed"); + assert_eq!(installed.mode, 0o755, "and are executable"); + assert!( + remote.file(TEMP).is_none(), + "the temp name is consumed by the rename" + ); + + // Both release artifacts were fetched from the same tag. + assert_eq!( + release.fetched(), + vec![ + format!("https://github.com/l0ng-ai/tty7/releases/download/v{VERSION}/checksums.txt"), + format!("https://github.com/l0ng-ai/tty7/releases/download/v{VERSION}/{ASSET_X86_64}"), + ] + ); +} + +/// **Atomic replacement.** The final path must only ever be produced by +/// renaming a temp that is *already* executable — never written to directly, +/// and never chmod'ed after it is visible. Both would leave a window in which a +/// concurrent connect finds `tty7-server-` present and unusable. +#[test] +fn the_final_path_is_only_ever_reached_by_renaming_a_ready_temp() { + let remote = FakeRemote::new(); + let release = FakeRelease::new(); + let user = FakeUser::approving(); + installer(&remote, &release, &user, "me@fresh-box:22") + .run() + .unwrap(); + + let writes = remote.writes(); + + // Nothing writes the final path directly. + assert!( + !writes + .iter() + .any(|j| matches!(j, Journal::Put { path, .. } if path == BINARY)), + "the binary path is never written to, only renamed onto: {writes:?}" + ); + + let put = writes + .iter() + .position(|j| matches!(j, Journal::Put { path, .. } if path == TEMP)) + .expect("the bytes go to the temp path"); + let chmod = writes + .iter() + .position(|j| matches!(j, Journal::Chmod { path, mode } if path == TEMP && *mode == 0o755)) + .expect("the temp is made executable"); + let rename = writes + .iter() + .position(|j| matches!(j, Journal::Rename { from, to } if from == TEMP && to == BINARY)) + .expect("the temp is renamed onto the binary"); + + assert!(put < chmod, "bytes before mode: {writes:?}"); + assert!( + chmod < rename, + "the temp is executable before it becomes visible: {writes:?}" + ); + assert!( + !writes[rename + 1..] + .iter() + .any(|j| matches!(j, Journal::Chmod { path, .. } if path == BINARY)), + "no chmod after publication — that would be the window this ordering exists to close" + ); +} + +/// The directory chain is created outermost-first (SFTP has no `mkdir -p`) and +/// the directory that holds the binaries ends up 0700 (§16). +#[test] +fn the_install_directory_is_created_in_order_and_locked_down() { + let remote = FakeRemote::new(); + let release = FakeRelease::new(); + let user = FakeUser::approving(); + installer(&remote, &release, &user, "me@fresh-box:22") + .run() + .unwrap(); + + let mkdirs: Vec = remote + .journal() + .into_iter() + .filter_map(|j| match j { + Journal::Mkdir(p) => Some(p), + _ => None, + }) + .collect(); + assert_eq!( + mkdirs, + vec![ + "/home/me/.local", + "/home/me/.local/share", + "/home/me/.local/share/tty7", + BIN_DIR, + ] + ); + assert_eq!(remote.file(BIN_DIR).unwrap().mode, 0o700); +} + +// --------------------------------------------------------------------------- +// sha256 (§16, §17) — the failure path §18 names. +// --------------------------------------------------------------------------- + +/// **A checksum mismatch aborts and writes nothing.** Not a retry, not an +/// unverified install, not a partially-written temp left behind: the remote +/// filesystem must be untouched, and the user must not even have been asked +/// (there is nothing to consent to). +#[test] +fn a_sha256_mismatch_aborts_before_touching_the_remote() { + let remote = FakeRemote::new(); + let release = FakeRelease::new().corrupt(); + let user = FakeUser::approving(); + + let err = installer(&remote, &release, &user, "me@fresh-box:22") + .run() + .expect_err("bytes that fail verification must never be installed"); + + match err { + InstallError::Checksum(ChecksumError::Mismatch { + ref expected, + ref actual, + .. + }) => assert_ne!(expected, actual), + other => panic!("expected a checksum mismatch, got {other}"), + } + + assert!( + remote.writes().is_empty(), + "nothing may be written after a failed verification: {:?}", + remote.writes() + ); + assert!(remote.file(TEMP).is_none()); + assert!(remote.file(BINARY).is_none()); + assert!( + user.asked().is_empty(), + "there is nothing to ask about — the download already failed its own check" + ); + assert_eq!( + release.fetched().len(), + 2, + "and it is not retried: one manifest fetch, one asset fetch, then stop" + ); +} + +/// A release with no line for our asset is the same class of failure: stop, +/// do not install something unverified. +#[test] +fn a_release_missing_our_asset_aborts() { + let remote = FakeRemote::new(); + let mut release = FakeRelease::new(); + // Manifest describes a payload nobody serves, under a different name. + release.manifest_of = b"unrelated".to_vec(); + let user = FakeUser::approving(); + + let err = installer(&remote, &release, &user, "me@fresh-box:22") + .run() + .unwrap_err(); + assert!(matches!(err, InstallError::Checksum(_)), "got {err}"); + assert!(remote.writes().is_empty()); +} + +// --------------------------------------------------------------------------- +// Consent (§12). +// --------------------------------------------------------------------------- + +/// The prompt has to carry everything §12 asks it to say: which path, how big, +/// and where the bytes came from. +#[test] +fn the_confirmation_states_path_size_and_origin() { + let remote = FakeRemote::new(); + let release = FakeRelease::new(); + let user = FakeUser::approving(); + installer(&remote, &release, &user, "me@fresh-box:22") + .run() + .unwrap(); + + let asked = user.asked(); + assert_eq!(asked.len(), 1, "asked exactly once"); + let request = &asked[0]; + assert_eq!(request.host, "me@fresh-box:22"); + assert_eq!(request.remote_path, BINARY); + assert_eq!(request.asset, ASSET_X86_64); + assert_eq!( + request.size_bytes, + SERVER_BYTES.len() as u64, + "the size quoted is the verified byte count, not a Content-Length promise" + ); + assert!(request.source_url.contains("github.com")); + assert!(request.source_url.contains(ASSET_X86_64)); + assert_eq!( + request.sha256, + checksums::hex(&checksums::sha256(SERVER_BYTES)) + ); + assert_eq!(request.version, VERSION); +} + +/// Declining writes nothing and says so. The bytes were already downloaded and +/// verified by then; that is fine, they never left the client. +#[test] +fn declining_installs_nothing() { + let remote = FakeRemote::new(); + let release = FakeRelease::new(); + let user = FakeUser::declining(); + + let err = installer(&remote, &release, &user, "me@fresh-box:22") + .run() + .unwrap_err(); + assert!(matches!(err, InstallError::Declined { .. }), "got {err}"); + assert!( + err.to_string().contains(BINARY), + "the message names the path" + ); + assert!(remote.writes().is_empty()); + assert!(remote.file(BINARY).is_none()); +} + +/// **With no UI attached the default is to refuse, not to proceed.** A daemon +/// running headless must not decide on the user's behalf that writing binaries +/// to their servers is acceptable. +#[test] +fn the_default_confirmation_declines() { + let request = InstallRequest { + host: "me@somewhere:22".into(), + version: VERSION.into(), + asset: ASSET_X86_64, + source_url: "https://example/x".into(), + remote_path: BINARY.into(), + size_bytes: 42, + sha256: "00".repeat(32), + }; + assert_eq!( + DenyInstall.confirm(&request), + InstallDecision::Decline, + "no UI means no consent means no install" + ); +} + +/// A machine tty7 has already written to is upgraded silently — the consent was +/// about "may tty7 put binaries here", and it was given. +#[test] +fn upgrading_a_known_machine_does_not_ask_again() { + let remote = FakeRemote::new().with_previous_install("26.7.4"); + let release = FakeRelease::new(); + let user = FakeUser::declining(); // would refuse if asked + + let report = installer(&remote, &release, &user, "me@known-box:22") + .run() + .expect("a silent upgrade must not need consent"); + + assert!(report.installed); + assert!(!report.confirmed); + assert!( + user.asked().is_empty(), + "no prompt on a machine we already use" + ); + // The older binary is still there: versioned paths coexist. + assert!( + remote + .file(&format!("{BIN_DIR}/tty7-server-26.7.4")) + .is_some() + ); + assert!(remote.file(BINARY).is_some()); +} + +// --------------------------------------------------------------------------- +// Skipping work. +// --------------------------------------------------------------------------- + +/// The common path: the right version is already installed and a daemon is +/// serving. No download, no prompt, no write, no launch. +#[test] +fn an_up_to_date_machine_downloads_nothing() { + let remote = FakeRemote::new(); + remote.preinstall(BINARY, 0o755); + let remote = remote.serving(BINARY); + let release = FakeRelease::new(); + let user = FakeUser::declining(); + + let report = installer(&remote, &release, &user, "me@current-box:22") + .run() + .unwrap(); + + assert!(!report.installed); + assert!(!report.launched); + assert!(report.mismatch.is_none()); + assert!(release.fetched().is_empty(), "no network at all"); + assert!(remote.writes().is_empty()); +} + +/// A binary that is present but not executable is a crashed install (the rename +/// landed, the chmod did not). Reinstalling beats launching something the kernel +/// will refuse with `Exec format error`'s equally opaque cousin, `Permission +/// denied`. +#[test] +fn a_present_but_unexecutable_binary_is_reinstalled() { + let remote = FakeRemote::new(); + remote.preinstall(BINARY, 0o644); + let release = FakeRelease::new(); + let user = FakeUser::approving(); + + let report = installer(&remote, &release, &user, "me@half-installed:22") + .run() + .unwrap(); + + assert!(report.installed, "a non-executable binary is not usable"); + assert_eq!(remote.file(BINARY).unwrap().mode, 0o755); +} + +// --------------------------------------------------------------------------- +// Refusals and write failures (§17). +// --------------------------------------------------------------------------- + +/// An architecture we do not publish for is refused before anything is +/// downloaded or written, and the message quotes the machine string verbatim. +#[test] +fn an_unsupported_machine_is_refused_before_any_work() { + for (uname, expect_linux) in [("Linux armv7l", true), ("Darwin arm64", false)] { + let mut remote = FakeRemote::new(); + remote.uname = format!("{uname}\n"); + let release = FakeRelease::new(); + let user = FakeUser::approving(); + + let err = installer(&remote, &release, &user, "me@odd-box:22") + .run() + .unwrap_err(); + match err { + InstallError::Unsupported(ref target) => { + assert_eq!(target.raw(), uname); + assert_eq!( + matches!(target, UnsupportedTarget::UnknownMachine { .. }), + expect_linux + ); + } + other => panic!("{uname} must be refused, got {other}"), + } + assert!(err.to_string().contains(uname), "the refusal quotes itself"); + assert!(release.fetched().is_empty(), "nothing downloaded"); + assert!(remote.writes().is_empty(), "nothing written"); + } +} + +/// **A failed remote write reports the path and the server's reason, and is not +/// retried anywhere else** (§17). A full disk must not become "let me try +/// /tmp". +#[test] +fn a_failed_write_names_the_path_and_does_not_fall_back() { + let mut remote = FakeRemote::new(); + remote.put_error = Some("4: Failure (no space left on device)".to_string()); + let release = FakeRelease::new(); + let user = FakeUser::approving(); + + let err = installer(&remote, &release, &user, "me@full-disk:22") + .run() + .unwrap_err(); + + match err { + InstallError::Write { + ref path, + ref reason, + } => { + assert_eq!(path, TEMP, "the exact path that failed"); + assert!(reason.contains("no space left"), "the server's own reason"); + } + other => panic!("expected a write failure, got {other}"), + } + let message = err.to_string(); + assert!(message.contains(TEMP), "{message}"); + assert!(message.contains("no space left"), "{message}"); + + // One attempt at one path. No second put, no alternative directory. + let puts: Vec<_> = remote + .journal() + .into_iter() + .filter(|j| matches!(j, Journal::Put { .. })) + .collect(); + assert_eq!(puts.len(), 1, "not retried: {puts:?}"); + assert!(remote.file(BINARY).is_none()); +} + +/// A download failure names the URL, so "which release did it even look for" is +/// answerable from the message alone. +#[test] +fn a_download_failure_names_the_url() { + let remote = FakeRemote::new(); + let mut release = FakeRelease::new(); + release.fail = Some("connection refused".to_string()); + let user = FakeUser::approving(); + + let err = installer(&remote, &release, &user, "me@offline:22") + .run() + .unwrap_err(); + match err { + InstallError::Download { ref url, .. } => assert!(url.contains(&format!("v{VERSION}"))), + other => panic!("expected a download failure, got {other}"), + } + assert!(remote.writes().is_empty()); +} + +// --------------------------------------------------------------------------- +// Step 6: the daemon. +// --------------------------------------------------------------------------- + +/// Nothing serving → launch, then confirm by re-probing rather than by trusting +/// the shell's exit status. +#[test] +fn a_daemon_is_launched_when_the_socket_answers_nothing() { + let remote = FakeRemote::new(); + remote.preinstall(BINARY, 0o755); + let release = FakeRelease::new(); + let user = FakeUser::declining(); + + let report = installer(&remote, &release, &user, "me@idle-box:22") + .run() + .unwrap(); + assert!(report.launched); + + let journal = remote.journal(); + let launched = journal.iter().position(|j| *j == Journal::Launch).unwrap(); + assert!( + journal[..launched] + .iter() + .any(|j| matches!(j, Journal::Exec(c) if c.contains("--stdio --bridge"))), + "the socket is probed before anything is launched" + ); + assert!( + journal[launched + 1..] + .iter() + .any(|j| matches!(j, Journal::Exec(c) if c.contains("--stdio --bridge"))), + "and re-probed after, because a shell's exit status says nothing about the daemon" + ); +} + +/// A binary that will not stay up fails with a message naming it, rather than +/// leaving the caller to discover it on the first frame. +#[test] +fn a_daemon_that_never_answers_is_an_error() { + let mut remote = FakeRemote::new(); + remote.launch_works = false; + remote.preinstall(BINARY, 0o755); + let release = FakeRelease::new(); + let user = FakeUser::declining(); + + let err = installer(&remote, &release, &user, "me@broken-box:22") + .run() + .unwrap_err(); + match err { + InstallError::Launch { ref reason } => assert!(reason.contains(BINARY), "{reason}"), + other => panic!("expected a launch failure, got {other}"), + } +} + +/// **Version mismatch: keep the old daemon, record the mismatch.** It owns every +/// live pane on that machine; ending them at connect time is the user's call, +/// not the installer's — exactly as `spawn::ensure_running` treats the local +/// daemon. +#[test] +fn an_older_running_daemon_is_kept_and_reported() { + let remote = FakeRemote::new() + .with_previous_install("26.7.4") + .serving(&format!("{BIN_DIR}/tty7-server-26.7.4")); + let release = FakeRelease::new(); + let user = FakeUser::approving(); + + let report = installer(&remote, &release, &user, "me@mismatch-box:22") + .run() + .expect("a mismatch is not a failure — the old daemon still works"); + + assert!(report.installed, "our version is installed alongside it"); + assert!(!report.launched, "but the running daemon is left alone"); + let mismatch = report.mismatch.expect("the mismatch is reported"); + assert_eq!(mismatch.running_version.as_deref(), Some("26.7.4")); + assert_eq!(mismatch.wanted_version, VERSION); + + // And it reaches the GUI's take-once queue. + let queued = take_mismatched_remote_daemons(); + assert!( + queued.iter().any(|m| m.host == "me@mismatch-box:22"), + "the keep-or-restart prompt has something to raise: {queued:?}" + ); +} + +/// A daemon we cannot identify (no readable `/proc`, a hand-placed binary) is +/// not a mismatch. Having no opinion must never be reported as a disagreement, +/// or every locked-down container would prompt on connect. +#[test] +fn an_unidentifiable_running_daemon_is_not_a_mismatch() { + let remote = FakeRemote::new(); + remote.preinstall(BINARY, 0o755); + let remote = remote.serving(""); + let release = FakeRelease::new(); + let user = FakeUser::declining(); + + let report = installer(&remote, &release, &user, "me@opaque-box:22") + .run() + .unwrap(); + assert!(report.mismatch.is_none()); +} + +/// Restart is the other branch of the prompt: stop what is running, start ours. +#[test] +fn restart_replaces_the_running_daemon() { + let remote = FakeRemote::new() + .with_previous_install("26.7.4") + .serving(&format!("{BIN_DIR}/tty7-server-26.7.4")); + remote.preinstall(BINARY, 0o755); + let release = FakeRelease::new(); + let user = FakeUser::declining(); + + installer(&remote, &release, &user, "me@restart-box:22") + .restart_daemon() + .expect("restart must succeed"); + + let journal = remote.journal(); + let killed = journal + .iter() + .position(|j| matches!(j, Journal::Exec(c) if c == TERMINATE_RUNNING_COMMAND)) + .expect("the old daemon is asked to stop"); + let launched = journal.iter().position(|j| *j == Journal::Launch).unwrap(); + assert!(killed < launched, "stop before start — one socket, not two"); + assert!(*remote.daemon_running.lock().unwrap()); +} + +// --------------------------------------------------------------------------- +// Remote command construction. +// --------------------------------------------------------------------------- + +/// The launch detaches the daemon from the SSH session and gives it no stream to +/// hold open. Without either half, closing the channel would kill it (SIGHUP to +/// the session's group) or the channel would never close (inherited stdout). +#[test] +fn the_launch_command_detaches_and_closes_every_stream() { + let cmd = launch_command("/home/me/.local/share/tty7/bin/tty7-server-26.7.5"); + assert!(cmd.contains("setsid"), "{cmd}"); + assert!( + cmd.contains("nohup"), + "a busybox image may have no setsid: {cmd}" + ); + assert!(cmd.contains("--daemon"), "{cmd}"); + assert!(cmd.contains("< /dev/null"), "{cmd}"); + assert!(cmd.contains("> /dev/null 2>&1"), "{cmd}"); + assert!( + cmd.trim_end().ends_with("fi"), + "both branches background it: {cmd}" + ); +} + +/// Every command interpolates a remote path, and home directories with spaces +/// or apostrophes exist. Unquoted, `/home/o'brien/...` would end the string +/// mid-path and run whatever followed. +#[test] +fn remote_paths_are_shell_quoted() { + assert_eq!(shell_quote("/home/me/bin"), "'/home/me/bin'"); + assert_eq!( + shell_quote("/home/my box/tty7-server"), + "'/home/my box/tty7-server'" + ); + assert_eq!(shell_quote("/home/o'brien/x"), r"'/home/o'\''brien/x'"); + // A path that tries to break out stays one argument. The invariant that + // makes it safe: after the outer quotes, every remaining `'` belongs to a + // `'\''` escape — so there is no point at which the shell is outside a + // quoted string and could see `;` as a separator. + let quoted = shell_quote("/tmp/x'; rm -rf ~; echo '"); + let inner = quoted + .strip_prefix('\'') + .and_then(|s| s.strip_suffix('\'')) + .expect("wrapped in single quotes"); + assert!( + !inner.replace(r"'\''", "\u{0}").contains('\''), + "every interior quote is escaped, so nothing escapes the quoting: {quoted}" + ); +} + +/// The launch command embeds a quoted path, so a hostile-looking home directory +/// cannot turn into a second command. +#[test] +fn the_launch_command_quotes_its_binary() { + let cmd = launch_command("/home/me/a b/tty7-server-1.0.0"); + assert!(cmd.contains("'/home/me/a b/tty7-server-1.0.0'"), "{cmd}"); +} + +/// The `/proc` sweep must survive a machine with no tty7-server running (the +/// common case) without the loop's failure becoming the command's — a `set -e` +/// login shell would otherwise report the probe as a broken connection. +#[test] +fn the_running_exe_probe_cannot_fail_the_command() { + assert!(RUNNING_EXE_COMMAND.trim_end().ends_with("true")); + assert!(TERMINATE_RUNNING_COMMAND.trim_end().ends_with("true")); + // It looks only at our own install shape, so it can never terminate + // something that merely happens to mention tty7. + assert!(TERMINATE_RUNNING_COMMAND.contains("*/tty7-server-*")); +} + +// `connection_label` is now `ConnectionKey::as_str()` verbatim, so what used to +// be tested here — peeling the label out of the derived `Debug` — no longer +// exists. The key's own construction (including the jump chain, which is what +// keeps two hosts behind different bastions from sharing a label) is covered by +// `daemon::ssh::tests`, next to the `base_spec()` helper that builds one. + +/// `ExecOutput`'s failure summary prefers what the remote said over a bare +/// number, because "Permission denied" is actionable and "exit status 1" is not. +#[test] +fn exec_failures_quote_stderr_when_there_is_any() { + let with_stderr = ExecOutput { + status: Some(127), + stdout: String::new(), + stderr: "sh: uname: not found\nmore noise\n".into(), + }; + assert_eq!(with_stderr.failure_reason(), "sh: uname: not found"); + + let silent = ExecOutput { + status: Some(127), + stdout: String::new(), + stderr: " \n".into(), + }; + assert_eq!(silent.failure_reason(), "exit status 127"); + + let killed = ExecOutput { + status: None, + stdout: String::new(), + stderr: String::new(), + }; + assert!(killed.failure_reason().contains("killed")); + assert!(!killed.success()); +} + +// --------------------------------------------------------------------------- +// `BundledOrRelease` — installing from a local copy instead of a release. +// --------------------------------------------------------------------------- + +/// With no bundle configured this is the release download, unchanged. Pinned +/// because it is the path every ordinary user takes, and the whole feature is +/// only acceptable if it is inert until asked for. +#[test] +fn without_a_bundle_the_source_is_the_plain_download() { + let release = FakeRelease::new(); + let source = BundledOrRelease { + fetch: &release, + bundled: None, + }; + let loaded = source.load("26.7.5", ASSET_X86_64).expect("downloads"); + assert_eq!(loaded.bytes, SERVER_BYTES); + assert_eq!( + release.fetched().len(), + 2, + "the manifest and the asset, i.e. the verified path" + ); +} + +/// With one, the bytes come off the disk and **nothing is fetched** — which is +/// the point on an air-gapped client, behind a TLS-intercepting proxy, or on +/// any build with no published release (every developer build). +#[test] +fn a_bundle_is_used_instead_of_downloading() { + let dir = std::env::temp_dir().join(format!("tty7-bundle-src-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join(ASSET_X86_64), b"\x7fELF local build").unwrap(); + + let release = FakeRelease::new(); + let source = BundledOrRelease { + fetch: &release, + bundled: Some(wsl::BundledServerBinary::in_dirs(vec![dir.clone()])), + }; + let loaded = source.load("26.7.5", ASSET_X86_64).expect("loads locally"); + assert_eq!(loaded.bytes, b"\x7fELF local build"); + assert!( + release.fetched().is_empty(), + "a local install must not touch the network" + ); + assert!( + loaded.origin.contains(&dir.display().to_string()), + "the prompt names where the bytes came from: {}", + loaded.origin + ); + let _ = std::fs::remove_dir_all(&dir); +} + +/// A configured directory that lacks *this* asset fails, and does **not** +/// quietly download instead. Someone who pointed at a directory meant to +/// install from it; silently reaching for the network would defeat whichever +/// reason they had — and on an air-gapped box it would fail far from the cause. +#[test] +fn a_bundle_that_lacks_the_asset_does_not_fall_back_to_the_network() { + let dir = std::env::temp_dir().join(format!("tty7-bundle-empty-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + + let release = FakeRelease::new(); + let source = BundledOrRelease { + fetch: &release, + bundled: Some(wsl::BundledServerBinary::in_dirs(vec![dir.clone()])), + }; + let err = source.load("26.7.5", ASSET_X86_64).expect_err("no binary"); + assert!(matches!(err, InstallError::MissingBundled { .. }), "{err}"); + assert!( + err.to_string().contains(&dir.display().to_string()), + "the error names where it looked: {err}" + ); + assert!( + release.fetched().is_empty(), + "no silent fallback to the network" + ); + let _ = std::fs::remove_dir_all(&dir); +} + +/// The path the installer publishes to is **absolute and version-qualified**, +/// and that is what the session-channel fallback has to exec. +/// +/// Observed for real: the transport exec'd the bare name `tty7-server`, which +/// is a `command not found` on a machine where the install had just succeeded — +/// nothing puts `~/.local/share/tty7/bin` on a non-interactive `PATH`, and the +/// file there is not even called `tty7-server`. The remote process died at +/// once, taking the pane with it. +#[test] +fn the_published_path_is_absolute_and_version_qualified() { + assert!( + BINARY.starts_with('/'), + "a relative path would resolve against whatever directory the exec landed in" + ); + assert!( + BINARY.ends_with(&format!("tty7-server-{}", client_version())), + "the filename carries the version, so the bare name never names it: {BINARY}" + ); + assert_ne!( + BINARY.rsplit('/').next(), + Some("tty7-server"), + "if this ever becomes the bare name, `PATH` lookup would start working by accident \ + and the reason for using the absolute path would be forgotten" + ); + + let remote = FakeRemote::new(); + let release = FakeRelease::new(); + let user = FakeUser::approving(); + let report = installer(&remote, &release, &user, "me@fresh-box:22") + .run() + .expect("install"); + assert_eq!( + report.paths.binary, BINARY, + "this is the string `ensure_remote_server` hands the transport" + ); +} diff --git a/crates/tty7-core/src/daemon/install/wsl.rs b/crates/tty7-core/src/daemon/install/wsl.rs new file mode 100644 index 00000000..a5e2064c --- /dev/null +++ b/crates/tty7-core/src/daemon/install/wsl.rs @@ -0,0 +1,1917 @@ +//! WSL — a distribution on *this* machine as a remote workspace host +//! (design §7.3, §12 and decision D9). +//! +//! ## Why WSL is its own transport instead of "just another SSH host" +//! +//! D9: requiring the user to install and configure an `sshd` inside a +//! distribution that is already running on their own computer is absurd, and it +//! also breaks the automatic-install story — there would be nothing to install +//! *onto* until the user had already done the hard part by hand. So a WSL host +//! is reached by spawning `wsl.exe -d -- --stdio` and treating +//! that child's stdin/stdout as the link. **No SSH, no authentication, no +//! network, no host key, no port.** +//! +//! ## The layering, and where the untestable part is confined +//! +//! | Layer | What it is | Tested here | +//! |---|---|---| +//! | Command construction | [`wsl_args`], the `*_script` builders, [`shell_quote`] | ✅ pure | +//! | Output decoding | [`decode_wsl_text`], [`parse_distro_list`], [`parse_stat`], [`parse_list`] | ✅ pure | +//! | Binary discovery | [`bundled_search_dirs`], [`BundledServerBinary`] | ✅ against a temp dir | +//! | The install state machine | [`super::Installer`], shared verbatim with SSH | ✅ against a fake [`RemoteOps`] | +//! | Actually spawning `wsl.exe` | [`WslRemoteOps`]'s `invoke` | ❌ needs Windows + WSL | +//! +//! Everything above the last row runs in this crate's test suite on any OS. The +//! last row is deliberately the thinnest thing that could work: build an argv, +//! spawn, feed stdin, read stdout, decode. It is also **the only part that has +//! never been executed** — see the module's tests for exactly which strings the +//! untested layer is expected to produce. +//! +//! Note that none of this is `#[cfg(windows)]`. `wsl.exe` is spawned by name +//! like any other program, so every line here compiles (and the pure parts run) +//! on macOS and Linux; on those systems the spawn simply fails with "not found", +//! which is the honest answer. +//! +//! ## Two decisions worth writing down +//! +//! **Scripts travel on stdin, not on the command line.** Every probe runs as +//! `wsl.exe -d -- sh -s` with the script written to the child's stdin. +//! The alternative, `sh -c "