Skip to content

refactor(cli): replace hand-rolled terminal code with crossterm - #127

Merged
thebrandonlucas merged 1 commit into
masterfrom
brandon/dev-2904-voltage-cli-replace-hand-rolled-terminal-code-with-crossterm
Nov 5, 2026
Merged

thebrandonlucas merged 1 commit into
masterfrom
brandon/dev-2904-voltage-cli-replace-hand-rolled-terminal-code-with-crossterm

Conversation

@thebrandonlucas

@thebrandonlucas thebrandonlucas commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor

Fixes DEV-2904

Problem

terminal.rs kept an approved unsafe exception: TerminalModes called libc::tcgetattr and libc::tcsetattr to put echo back when Ctrl-C cancelled the hidden API-key prompt, because rpassword restores echo only when its read returns. The restore was Unix-only. The picker, live-watch, and status-line guards also wrote escape sequences as literal bytes, although crossterm, which the CLI already uses for the width and the pickers, has a command for each.

Change

Before After
rpassword reads the secret from /dev/tty with echo and signals off crossterm raw mode reads key events on the detached thread; Enter (or a pasted newline) ends the read, Backspace and Ctrl-U edit it
TerminalModes saves and restores termios with unsafe libc calls (Unix only) A RawMode guard calls crossterm::terminal::disable_raw_mode, which restores the modes crossterm saved when raw mode went on. It covers the hidden prompt and inquire's pickers on every platform
A typed Ctrl-C: rpassword raises SIGINT, the prompt waits for the handler A typed Ctrl-C returns Interrupted before any resource change was submitted (exit 130), the same as the pickers
No limit on the typed key At most 1,000 bytes (MAX_HIDDEN_BYTES), far above an API key. The buffer is allocated at that size up front, so typing never reallocates and leaves no unzeroized copy. One more character ends the read with The secret is longer than 1000 bytes (exit 2) and saves nothing
\x1b[?2004l\x1b[?25h, \x1b[?1049h…, \x1b[H\x1b[2J, \r\x1b[2K as literals DisableBracketedPaste, cursor::Show/Hide, Enter/LeaveAlternateScreen, MoveTo, Clear, rendered through a terminal::ansi helper
  • Raw mode is turned on in the async function before the prompt is printed and the read thread starts, so the guard is in place before the terminal changes and nothing typed after the prompt is echoed. Dropping the guard when raw mode is already off does nothing.
  • ansi() uses Command::write_ansi, not execute!. On Windows, execute! can call the console API directly, which would reorder the sequence with the buffered bytes around it. The bytes written are unchanged, except that home is ESC[1;1H instead of ESC[H.
  • The approved exception is removed from docs/quality.md. rpassword is removed (−22 lines in Cargo.lock). The crossterm entry now lists events and bracketed-paste; inquire already turned both on, so the dependency graph does not change.
  • unsafe and libc in src/ are now limited to check_owner and O_NOFOLLOW in config.rs. The integration tests still use libc to open and inspect pseudo-terminals.

User-visible, so there is a CHANGELOG line: the hidden prompt is written to stderr instead of /dev/tty, Ctrl-U clears it, and the restore works on every platform.

Try it on staging

Setup: Running against staging. This saves an API key under a throwaway account name and then logs it out.

alias voltage="$PWD/scripts/voltage-staging"
voltage --build
voltage login --account me --credential-store file
voltage context --account me

Use the organization and environment from context:

voltage auth import-key --account try-key --org ORG_ID --env ENV_ID --credential-store file

At Environment API key:, type a few characters and press Ctrl-C. Expect: nothing you typed is shown, Interrupted before any resource change was submitted appears, echo $? prints 130, and typing in the shell echoes again (no stty sane needed).

Run the command again and type wrong, press Ctrl-U, type a real or dummy key, use Backspace once, and press Enter. Expect: the key is not shown, and the command reports imported. Then remove it:

voltage logout --account try-key

Run it once more and paste more than 1,000 characters at the prompt, for example after printf 'k%.0s' $(seq 1001) | pbcopy (macOS). Expect: The secret is longer than 1000 bytes, echo $? prints 2, the shell echoes again, and voltage logout --account try-key reports no such account.

To check the picker guard, run voltage wallets list --account me without --org. If your login sees more than one organization, a picker opens. Press Ctrl-C there, and check that the shell still echoes and shows its cursor.

Validation

  • Branch updated with a merge of master (conflicts: CHANGELOG, kept both lines; Cargo.toml, kept master's webbrowser = "=1.2.5" and dropped rpassword). On the head: cargo fmt --check, cargo clippy --locked --all-targets -- -D warnings, cargo machete --with-metadata, cargo test --locked (203 unit, 151 integration), and python3 scripts/check-coverage.py all pass.
  • RustSec audit: the earlier failure was RUSTSEC-2026-0385 in ctutils 0.4.2 (non-constant-time masks), pulled in through digest by sha2 and hkdf/secret-service/keyring. master already moved it to the fixed 0.4.5 in fix(deps): update ctutils to 0.4.5 for RUSTSEC-2026-0385 #113, so the master merge fixes it; cargo audit on the head reports no vulnerabilities (only the existing allowed unmaintained/yanked warnings). No ignore was added.
  • Review fix (3f75e38, limit lowered in 08ed105): the hidden prompt is bounded at 1,000 bytes and its buffer never reallocates. New unit test a_hidden_prompt_stops_at_its_limit_without_reallocating and integration test the_hidden_key_prompt_refuses_a_key_over_its_limit_and_saves_nothing (exit 2, modes restored, no credential).
  • CI fix (161b3d3): the prompt is now printed after raw mode is on. Before, on Linux, keys typed in that gap went through the canonical line editor, which echoes them and drops input past 4,095 bytes, so the new over-limit test hung on both Linux runners.
  • CI fix (08ed105): crossterm reads the terminal 1,024 bytes per readiness event, and on Linux the rest of a longer burst waits for more input. The first limit (8 KiB) was over that, so the over-limit test still hung on Linux. 1,000 bytes keeps a paste one byte over the limit within one read.
  • New unit tests: type_hidden key handling (typing, Shift, Backspace, Ctrl-U, ignored Alt/Ctrl chords, arrows, and key releases; Enter, Ctrl-J, and Ctrl-C), the read-result mapping (secret, interruption, and the transport error for a terminal that cannot enter raw mode or be read), and the ansi byte output.
  • New integration test the_hidden_key_prompt_saves_the_edited_key_without_showing_it: a pseudo-terminal types a key with Ctrl-U and Backspace edits. It checks the saved key, that the terminal output never contains it, and that the terminal's local modes match their values before the run.
  • The existing pseudo-terminal tests still pass unchanged: SIGINT and typed Ctrl-C at the hidden prompt restore echo and exit 130, and the picker restores modes and bracketed paste. With the guard's disable_raw_mode call removed, all three hidden-prompt tests fail, so the guard is what restores the terminal.
  • CRAP (scripts/crap-report.sh, cargo-llvm-cov 0.8.5, knots 1.16.0, on the head): 854 of 1067 functions measured, 0 violations. The highest score is 18.10, wait_for_payment in src/api.rs, which this PR does not change. The new functions score read_hidden_keys 5.0, type_hidden 5.0, read_hidden 3.01, hidden 2.0, and the rest 1–2.
  • Overlap with feat(cli): pick the wallet, organization, or environment to show on a terminal #126: only CHANGELOG.md and tests/cli_integration.rs; a trial merge conflicts only on adjacent CHANGELOG lines and the suite passes. Landing this one first is preferred, so feat(cli): pick the wallet, organization, or environment to show on a terminal #126's new picker tests run against the new guard.

Limits

  • On Linux, crossterm reads a paste longer than 1,024 bytes in 1,024-byte parts, each after more input arrives. Any paste past the 1,000-byte limit is refused before that matters.

  • style.rs still writes the OSC 8 hyperlink sequence by hand, because neither crossterm 0.29 nor anstyle has a hyperlink command. The wordmark.rs escapes are test assertions only.

  • Checks not run: the Windows and Linux restore paths were not tested by hand. CI builds and tests them, but there is no pseudo-terminal test on Windows.

Stack

Stack #135, layer 5 of 9 (bottom targets master):

# PR Change
1 #97 Staging setup and a Try it section for each PR
2 #120 --all on organizations, environments, and profiles list
3 #128 Warn when a profile or flag overrides a set VOLTAGE_* variable
4 #129 voltage context --format env
5 #127 (this PR) Replace hand-rolled terminal code with crossterm
6 #126 Pick the wallet, organization, or environment to show
7 #130 Prefilled GitHub issue links from voltage feedback and panics
8 #123 voltage environments create
9 #131 Replace flake.nix with a Kaifile

The layers are chained with merge commits, because history is not rewritten. Use squash and merge, bottom first.

@tee8z tee8z left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Only one finding. Everything else holds up. Raw mode is on before the prompt is printed, the guard restores the terminal on cancel, the buffer is preallocated and zeroized, and the key handling has tests for every branch. Removing the unsafe exception is a good trade.

Finding: the rest of an over-long paste reaches the shell. When type_hidden returns TooLong, read_hidden_keys returns at once. crossterm reads at most 1,024 bytes per read, so with a paste of, say, 3,000 bytes, about 2,000 bytes are still in the terminal's input queue. Raw mode is then turned off and the process exits, and the shell's line editor reads those bytes as typed input. They appear on the command line, and if the paste ends in a newline they run as a command and land in shell history. rpassword read to the end of the line, so this is new. A real API key never gets this long, but a mistaken paste of a file that holds secrets would.

I found this by reading the code and crossterm's 1,024-byte read. I have not reproduced it on a PTY. The existing test sends exactly 1,001 bytes, which all fit in one read, so it cannot see this case.

Fix: drain pending input while raw mode is still on (suggestion inline). A test could extend the_hidden_key_prompt_refuses_a_key_over_its_limit_and_saves_nothing to send a few thousand bytes, then check that the PTY has nothing left to read after the CLI exits.

Not reviewed as a finding (judgment calls):

  • The prompt now goes to stderr, not /dev/tty, so auth import-key ... 2>log on a terminal shows no prompt and looks hung. The CHANGELOG states the move. If that case matters, can_prompt could also require that stderr is a terminal and point to --stdin otherwise.
  • On Windows, AltGr characters arrive with CONTROL | ALT, so type_hidden drops them. That is harmless for an alphanumeric key.

CRAP: not run on this head yet; I will add the result when it finishes. CI is green.

Comment thread src/terminal.rs Resolved
@tee8z

tee8z commented Nov 4, 2026

Copy link
Copy Markdown
Contributor

CRAP on 9dfa9d0: 869 of 1084 functions measured, 0 violations. Highest: 18.10 wait_for_payment (unchanged), then 18.00 endpoint (unchanged).

Base automatically changed from brandon/dev-2957-voltage-cli-no-way-to-hand-a-profiles-scope-to-another-tool to master November 4, 2026 22:59
@thebrandonlucas

Copy link
Copy Markdown
Contributor Author

On the two judgment calls:

  • Prompt on stderr when stderr is redirected: I left it as is. auth import-key ... 2>log on a terminal is unusual, and the CHANGELOG states the move. If it comes up, the fix is to require a terminal on stderr in can_prompt and point to --stdin otherwise.
  • AltGr on Windows: agreed that it is harmless for an alphanumeric key, so no change.

CRAP on 7b77dd7: 870 of 1086 functions measured, 0 violations.

@thebrandonlucas
thebrandonlucas requested a review from tee8z November 5, 2026 12:55
The hidden API-key prompt reads keys in crossterm's raw mode instead of
rpassword, and a guard turns raw mode off through crossterm, which restores
the saved terminal modes when Ctrl-C cancels a read that cannot return. This
removes the unsafe tcgetattr/tcsetattr exception, the rpassword dependency,
and the Unix-only restore: pickers and the hidden prompt now restore the
terminal on every platform. A typed Ctrl-C at the hidden prompt exits 130
with the same interruption message as before.

The picker, live-watch, and status-line escape sequences come from
crossterm commands instead of literal bytes; the bytes written are the
same, except that home is now ESC[1;1H.
@thebrandonlucas
thebrandonlucas force-pushed the brandon/dev-2904-voltage-cli-replace-hand-rolled-terminal-code-with-crossterm branch from 78298d3 to 5f64b27 Compare November 5, 2026 15:09

@tee8z tee8z left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed 5f64b27 against master. No blocking findings in this layer.

The hidden reader enables raw mode before showing the prompt, bounds the buffer, rejects overlong input, and flushes the remaining paste before restoring modes. Linux PTY tests cover editing, typed Ctrl-C, SIGINT, over-limit input, and terminal restoration.

Local validation passed: cargo fmt --check, cargo clippy --locked --all-targets -- -D warnings, cargo machete --with-metadata, cargo test --locked (209 unit + 153 integration), and python3 scripts/check-coverage.py. Required scripts/crap-report.sh also passed: 871/1088 functions measured, 217 unmeasured, zero scores >=20; highest 18.10 (wait_for_payment).

Limits: no live staging credential import or manual Windows/macOS terminal run; current cross-platform GitHub checks pass.

@thebrandonlucas
thebrandonlucas merged commit 1e13659 into master Nov 5, 2026
8 checks passed
@thebrandonlucas
thebrandonlucas deleted the brandon/dev-2904-voltage-cli-replace-hand-rolled-terminal-code-with-crossterm branch November 5, 2026 18:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants