diff --git a/scripts/install-k8s.ps1 b/scripts/install-k8s.ps1 index 375c3cbd..99ad1866 100644 --- a/scripts/install-k8s.ps1 +++ b/scripts/install-k8s.ps1 @@ -4245,16 +4245,45 @@ function Print-CreateFailure { } } -# Strip ANSI CSI sequences (arrow keys, cursor moves), bracketed-paste markers, -# and C0 control characters from interactive input — they otherwise corrupt the -# name passed to `client create` into a garbage slug (mirrors common.sh's -# _strip_paste_garbage; customer-reported 2026-07-20 on the bash flow). UTF-8 -# letters survive (only < 0x20 and DEL are dropped). +# Strip ANSI escape sequences (arrow keys, cursor moves, function keys), +# bracketed-paste markers, and C0 control characters from interactive input — +# they otherwise corrupt the name passed to `client create` into a garbage slug +# (mirrors common.sh's _strip_paste_garbage and cli/internal/cli/sanitize.go; +# customer-reported 2026-07-20 on the bash flow). Two shapes carry all of it: +# CSI ESC '[' +# SS3 ESC 'O' — ESC OA/OB/OC/OD, ESC OH/OF, ESC OP..OS +# SS3 is what the SAME keys emit in DECCKM application-cursor mode, the state +# vim/less/tmux leave behind on an unclean exit (cli#516) — the hole left by the +# CSI-only fix of 2026-07-21 (client#362 / cli#364). ESC is dropped as a control +# byte but 'O' and the final byte are printable, so ESC OD ESC OA survived as +# the plausible name "ODOA" and minted a permanent namespace, where CSI residue +# cleans to empty and re-prompts. UTF-8 letters survive (only < 0x20 and DEL are +# dropped). Change this, common.sh and sanitize.go together. function ConvertTo-SanitizedInput { param([string]$Value) if (-not $Value) { return "" } - $s = $Value -replace "$([char]27)\[[0-9;]*[A-Za-z~]", "" + $esc = [char]27 + $s = $Value -replace "$esc(\[[0-9;]*|O)[A-Za-z~]", "" $s = $s.Replace("[200~", "").Replace("[201~", "") + # The floor. The strip above knows CSI, SS3 and the paste markers; it cannot + # know the escape family nobody has reported yet — and that is exactly how SS3 + # got here, one rule hand-copied into three languages with only CSI ever + # tested. So if an ESC SURVIVED the strip, this value carries a shape we do not + # recognise and its printable bytes are not trustworthy content. Require one + # alphanumeric that did not come from an escape final byte, probing with ESC + + # intermediates + AT MOST TWO final-class bytes. Two, not one and not + # unbounded: one leaves the 'D' of an unrecognised SS3-shaped pair behind and + # the floor stops firing on the very shape this is about, while unbounded + # swallows a whole ASCII name (ESC N C h e l l o) yet spares a non-Latin one, + # making keep-vs-reject depend on the script the name is written in (Bugbot, + # #736). An escape final is one byte, an intro plus a final is two, and every + # keyboard-input escape family fits in that. The probe is a yes/no only — it is + # never returned. Nothing but residue => return empty, which callers already + # treat as "no answer" (re-prompt, or auto-name). + if ($s.Contains($esc)) { + $probe = $s -replace "$esc[^A-Za-z0-9~]*[A-Za-z~]{1,2}", "" + if ($probe -notmatch '[\p{L}\p{Nd}]') { return "" } + } return (($s.ToCharArray() | Where-Object { [int]$_ -ge 32 -and [int]$_ -ne 127 }) -join "") } diff --git a/scripts/lib/common.sh b/scripts/lib/common.sh index 9de8c458..e97ecef0 100755 --- a/scripts/lib/common.sh +++ b/scripts/lib/common.sh @@ -360,9 +360,16 @@ tb_minutes_or() { # • bracketed-paste wrappers: ESC[200~ ... ESC[201~ # • arrow keys / cursor moves: ESC[A/B/C/D, ESC[1;5C, ESC[3~ (Delete), … # • function keys, modifier combos, mode-switch sequences -# All follow the ANSI CSI shape: ESC '[' -# where params ∈ [0-9;] and final ∈ [A-Za-z~]. Strip them iteratively to -# handle consecutive sequences (e.g. paste-wrappers). +# Two shapes carry all of those: +# CSI ESC '[' +# SS3 ESC 'O' — ESC OA/OB/OC/OD, ESC OH/OF, ESC OP…OS +# SS3 is what the SAME keys emit once the terminal is in DECCKM +# application-cursor mode, the state vim/less/tmux leave behind on an unclean +# exit (cli#516). It was the hole left by the CSI-only fix of 2026-07-21 +# (client#362 / cli#364): ESC is dropped as a C0 byte but 'O' and the final byte +# are printable, so ESC OD ESC OA survived as the plausible name "ODOA" and +# minted a permanent namespace — where CSI residue cleans to empty and re-prompts. +# Strip iteratively to handle consecutive sequences (e.g. paste-wrappers). # # Also handles the post-corruption case where ESC was stripped by an earlier # (buggy) sanitizer but the literal `[200~`/`[201~` markers survived. Only @@ -372,15 +379,55 @@ tb_minutes_or() { # UTF-8 bytes (0x80+) preserved so international characters survive. Lives here # (shared) so BOTH the credential path (install-client-helm.sh) and the client- # name prompt (provision.sh) sanitize identically (customer-reported 2026-07-20). +# Mirrored by cli/internal/cli/sanitize.go and install-k8s.ps1's +# ConvertTo-SanitizedInput — change all three together. _strip_paste_garbage() { local s="$1" local esc=$'\e' - local csi_pattern="${esc}\\[[0-9;]*[A-Za-z~]" - while [[ "$s" =~ $csi_pattern ]]; do + local esc_pattern="${esc}(\\[[0-9;]*|O)[A-Za-z~]" + while [[ "$s" =~ $esc_pattern ]]; do s="${s/${BASH_REMATCH[0]}/}" done s="${s//\[200\~/}" s="${s//\[201\~/}" + # The floor. The loop above knows CSI, SS3 and the paste markers; it cannot + # know the escape family nobody has reported yet — and that is exactly how SS3 + # got here, one rule hand-copied into three languages with only CSI ever + # tested. So if an ESC SURVIVED the loop, this value carries a shape we do not + # recognise and its printable bytes are not trustworthy content. Require one + # alphanumeric that did not come from an escape final byte, probing with ESC + + # intermediates + AT MOST TWO final-class bytes. Two, not one and not + # unbounded: one leaves the 'D' of an unrecognised SS3-shaped pair behind and + # the floor stops firing on the very shape this is about, while unbounded + # swallows a whole ASCII name (ESC N C h e l l o) yet spares a non-Latin one, + # making keep-vs-reject depend on the script the name is written in (Bugbot, + # #736). An escape final is one byte, an intro plus a final is two, and every + # keyboard-input escape family fits in that. The probe's output is only a + # yes/no — it is never returned. Nothing but residue ⇒ emit empty, which every + # caller already treats as "no answer" (re-prompt, or auto-name in the CLI). + if [[ "$s" == *"$esc"* ]]; then + # `sed`, NOT the `while [[ =~ ]]; do s="${s/$BASH_REMATCH/}"` loop the strip + # above uses. Pattern substitution treats BASH_REMATCH as a GLOB, and this + # pattern — unlike the CSI one, whose match can never contain `]` — can match + # a complete bracket expression: on ESC [ ; ] A the regex matches the whole + # thing, the glob `[;]A` then means ESC ';' 'A', which is NOT in the + # string, the substitution removes nothing, and the loop never terminates. + # That is a hang at the installer's name prompt. One sed pass has no glob + # semantics and no loop. + # + # And "alphanumeric" via tr, not `=~ [[:alnum:]]`: bash's regex engine is + # locale-dependent, and under the C locale the installer often runs in, + # [[:alnum:]] does not match a UTF-8 letter — which would auto-name a + # perfectly good "日本" the moment an unknown escape sat next to it. Keeping + # every byte >= 0x80 makes "is there real content here" locale-independent + # and matches what the strip itself already preserves. + local probe + probe="$(printf '%s' "$s" | LC_ALL=C sed -E "s/${esc}[^A-Za-z0-9~]*[A-Za-z~]{1,2}//g" | LC_ALL=C tr -dc '0-9A-Za-z\200-\377')" + if [[ -z "$probe" ]]; then + printf '' + return 0 + fi + fi printf '%s' "$s" | tr -d '\000-\037\177' } diff --git a/scripts/manifest.sha256 b/scripts/manifest.sha256 index aae394f5..bb504cf9 100644 --- a/scripts/manifest.sha256 +++ b/scripts/manifest.sha256 @@ -1,5 +1,5 @@ 7d4d98379601ee2f243e5889e9f718ef8e581e4a3c5ed49ade6c3b0e7f939a82 scripts/install-k8s.sh -f6bcbeaf3d10847c5b5ecfb464c5f0cd052d521d155dea061365de01a93ba293 scripts/lib/common.sh +66ea6f8d54db2168c58e438e908c707b7f4f6c2b3131872d784508e537c2d920 scripts/lib/common.sh 673683811b2b6cec2d243561a317cc265da2b603fe36698fbceb9ce6c635f3ae scripts/lib/preflight.sh c6bf113c00d68fb94f7654f2fb34db296160acd990c6a612ed02ae30076f2fe3 scripts/lib/detect-gpu.sh d8c29bc8bd1f4633300940894da0f6527ca0a1dd7a3cfcbc80aad19dfd4d88cb scripts/lib/gpu-nvidia.sh @@ -15,4 +15,4 @@ b0bf0a4966461e4257b6765dc4f12877762eee1e72f60ed4b2e322cb6291315a scripts/lib/pr 911fd0714b17357bb205fc8a8fa8e13eedc1a9632a2f63d4ead9f8d8c7ee546f scripts/lib/probe.sh a9bb43caa3e7d156d48e7f9f4473c22da0b3b42c25d84fd49f35349b25956132 scripts/lib/summary.sh 77e03332ebfab1ef759c6148a57afcf479c02c5dc6cc7b0e0e680f58e20cd364 scripts/lib/diagnose.sh -cc1314f51de8ffe40529f7d3f2aa9d9debfdfb639074b104bd938a7dd52f07ec scripts/install-k8s.ps1 +478da84ef9be6cf62c3348c86ebcef53dc9e9ac9286d1a5631fbaa4a071c5207 scripts/install-k8s.ps1 diff --git a/scripts/tests/install-client-helm.bats b/scripts/tests/install-client-helm.bats index 5387a5ac..59c31166 100644 --- a/scripts/tests/install-client-helm.bats +++ b/scripts/tests/install-client-helm.bats @@ -122,6 +122,97 @@ setup() { [ "$output" = "abcd" ] || return 1 } +# ── SS3 (cli#516) ────────────────────────────────────────────────────────── +# The CSI-only strip landed 2026-07-21 (client#362 / cli#364) and is not in +# question here. SS3 — ESC 'O' — is what the SAME keys emit once the +# terminal is in DECCKM application-cursor mode, the state vim/less/tmux leave +# behind on an unclean exit. It was worse than the CSI case it was missed +# alongside: CSI residue cleans to empty and re-prompts, while ESC OD ESC OA +# left the non-empty, plausible "ODOA" and minted a permanent namespace. +@test "_strip_paste_garbage: strips SS3 escapes around real content" { + run _strip_paste_garbage "$(printf 'na\eODme')" + [ "$output" = "name" ] || return 1 +} + +@test "_strip_paste_garbage: SS3 arrows only -> empty (caller re-prompts)" { + run _strip_paste_garbage "$(printf '\eOD\eOD\eOD\eOA\eOA\eOA')" + [ "$output" = "" ] || return 1 +} + +@test "_strip_paste_garbage: SS3 Home/End and F1/F2 -> empty" { + run _strip_paste_garbage "$(printf '\eOH\eOF')" + [ "$output" = "" ] || return 1 + run _strip_paste_garbage "$(printf '\eOP\eOQ')" + [ "$output" = "" ] || return 1 +} + +@test "_strip_paste_garbage: SS3 and CSI mixed in one value" { + run _strip_paste_garbage "$(printf 'a\eODb\e[Dc')" + [ "$output" = "abc" ] || return 1 +} + +@test "_strip_paste_garbage: a bare O is not an escape" { + run _strip_paste_garbage "OPTIMUS-01" + [ "$output" = "OPTIMUS-01" ] || return 1 +} + +# ── the post-sanitise floor ──────────────────────────────────────────────── +# An ESC that SURVIVES the strip means an escape family this helper does not +# know — which is exactly how SS3 got here. SS2 (ESC 'N' ) stands in for +# "the next family": it is not stripped above, so these exercise the floor and +# nothing else. +@test "_strip_paste_garbage: unknown escape family, residue only -> empty" { + run _strip_paste_garbage "$(printf '\eNB\eNC')" + [ "$output" = "" ] || return 1 +} + +@test "_strip_paste_garbage: unknown escape family beside real content is kept" { + run _strip_paste_garbage "$(printf 'box\eNC')" + [ "$output" = "boxNC" ] || return 1 +} + +@test "_strip_paste_garbage: the floor counts non-Latin letters as real content" { + run _strip_paste_garbage "$(printf '\eNC日本')" + [ "$output" = "NC日本" ] || return 1 +} + +# The probe's final-byte run is bounded at two, so an ASCII name after an unknown +# escape is kept just like a non-Latin one. Keep-vs-reject must not depend on the +# script the name is written in — with an unbounded `+` the whole word was +# swallowed and this input was refused while the 日本 case above was not (Bugbot). +@test "_strip_paste_garbage: the floor keeps an ASCII name after an unknown escape" { + run _strip_paste_garbage "$(printf '\eNChello')" + [ "$output" = "NChello" ] || return 1 +} + +@test "_strip_paste_garbage: truncated SS3 (ESC O, no final) -> empty" { + run _strip_paste_garbage "$(printf '\eO')" + [ "$output" = "" ] || return 1 +} + +# ESC [ ; ] A is the shape that made the floor's first draft HANG, and it is why +# the floor uses one sed pass rather than the `while [[ =~ ]]; do +# s="${s/${BASH_REMATCH[0]}/}"; done` loop the strip above uses. Pattern +# substitution treats BASH_REMATCH as a GLOB: the residue regex matches +# ESC [ ; ] A whole, but the glob `[;]A` means ESC ';' 'A' — not in the +# string — so the substitution removed nothing and the loop spun forever, at the +# installer's name prompt. (The CSI loop is safe by construction: its match can +# never contain a `]`, so it can never form a complete bracket expression.) +# Bounded like common.bats bounds its recursion guard — macOS ships no +# timeout(1), so Linux CI is the authority on the hang half. +@test "_strip_paste_garbage: ESC [ ; ] A terminates and is refused (glob-substitution hang)" { + local _to="" + command -v timeout >/dev/null 2>&1 && _to=timeout + command -v gtimeout >/dev/null 2>&1 && _to=gtimeout + if [ -n "$_to" ]; then + run "$_to" 10 bash -c 'source "$1/common.sh"; _strip_paste_garbage "$(printf "\e[;]A")"' _ "$LIB_DIR" + else + run bash -c 'source "$1/common.sh"; _strip_paste_garbage "$(printf "\e[;]A")"' _ "$LIB_DIR" + fi + [ "$status" -eq 0 ] || return 1 + [ "$output" = "" ] || return 1 +} + @test "_sanitize_workspace_name: lowercases + dashes" { run _sanitize_workspace_name "My Team_1" [ "$output" = "my-team-1" ] || return 1