Files
voculinux/tests/test_installer_tty_prompts.py
T
e9598d64ce fix(installer): keep piped script on stdin and prompt via /dev/tty (#935)
* fix(installer): keep piped script on stdin and prompt via /dev/tty

`curl | bash` left bash reading the installer itself from stdin, and the
detection block's `exec < /dev/tty` (from #315) closed that pipe about 300
lines into a 3000-line script. curl died with error 23 writing to the
closed pipe, and bash read the rest of the installer from the terminal as
commands instead of finishing the script.

stdin now stays on the pipe and a reachable terminal is recorded in
TERMINAL_PROMPTS. Every interactive prompt goes through read_prompt, which
reads /dev/tty explicitly only for that read, so user input still works in
a terminal while the script body keeps flowing through the pipe. The
interactive-mode gate now checks TERMINAL_PROMPTS rather than `-t 0`,
which also lets `--interactive` work under a pipe.

Regression coverage: the remote-install gate's second run now pipes the
bootstrap in under a `script` pty (the literal `cat | bash` shape a
curl user takes), and new tests run install.sh in a pty with the script on
a pipe to assert the mode and engine prompts still answer on the terminal.

Fixes #933

* fix(installer): drain the piped bootstrap before the handoff exec

A curl|bash bootstrap leaves its unread tail on stdin; the tagged installer
reads its commands from a file, so nothing drained the pipe and a still-
streaming curl reported error 23 when the installer exited. Drain stdin to
EOF before exec (Greptile P2 on #935).

Gate: drip-feed the piped run so the producer is mid-write at the handoff
like real curl, and run it under pipefail so a producer killed early fails
the run. Tests: pass --tag so a slow release lookup cannot eat the window,
wait for the engine prompt before Ctrl+C, and quote the installer path.

* test(installer): propagate a killed producer's status in the piped gate

A cat dying mid-drip (SIGPIPE when the consumer abandons the pipe) was
swallowed by the loop and the final rm, so drip returned success and
pipefail could not see the download failure the arm exists to catch.

---------

Co-authored-by: Jatin K Malik <jatinkrmalik@gmail.com>
2026-10-11 17:37:51 -07:00

157 lines
6.1 KiB
Python

"""Regression guards for the curl|bash terminal-prompt path (issue #933).
`curl ... | bash` leaves bash reading the installer itself from stdin, so the
script must never redirect fd 0 away from the pipe: the old `exec < /dev/tty`
closed the pipe mid-download (curl error 23) and made bash read the rest of
the installer from the terminal as commands. Interactive prompts now pull
from /dev/tty per-read through `read_prompt`, and `TERMINAL_PROMPTS` records
whether a controlling terminal exists instead of testing `-t 0` on stdin.
The behavioral tests run the real install.sh under a pseudo-terminal exactly
the way a curl user runs it — script text on a pipe, /dev/tty on a terminal.
A saved file or a headless pipe exercises different branches and proves
nothing about this one.
"""
import os
import re
import shlex
import shutil
import subprocess
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).resolve().parents[1]
INSTALLER = REPO_ROOT / "install.sh"
MODULE_DIR = REPO_ROOT / "install.d"
#: A `read -p` (optionally `read_prompt -p`) anywhere in the installer tree.
PROMPT_LINE = re.compile(r"^\s*(read|read_prompt)\s+-p\s")
SCRIPT = shutil.which("script")
needs_pty = pytest.mark.skipif(
SCRIPT is None,
reason="script(1) is needed to give a piped installer a controlling terminal",
)
def _installer_sources() -> dict[Path, str]:
sources = {INSTALLER: INSTALLER.read_text(encoding="utf-8")}
for module in MODULE_DIR.glob("*.sh"):
sources[module] = module.read_text(encoding="utf-8")
return sources
def _pty_env(tmp_path: Path) -> dict:
"""Isolate the installer's log and scratch writes inside the test tmp dir."""
env = os.environ.copy()
env["HOME"] = str(tmp_path)
env["XDG_STATE_HOME"] = str(tmp_path / ".local" / "state")
env["TMPDIR"] = str(tmp_path)
return env
def test_stdin_is_never_redirected_off_the_pipe() -> None:
"""No fd-0 redirect may run while bash still reads the script from stdin."""
for path, source in _installer_sources().items():
for line in source.splitlines():
stripped = line.strip()
if stripped.startswith("#"):
continue
assert (
"exec <" not in stripped
), f"{path.name}: `{stripped}` steals stdin from the piped script"
assert "exec 0<" not in stripped
def test_every_interactive_read_uses_read_prompt() -> None:
"""All `read -p` sites go through read_prompt so answers come from /dev/tty."""
source = INSTALLER.read_text(encoding="utf-8")
helper = re.search(r"read_prompt\(\)\s*\{(.*?)\}", source, re.S)
assert helper is not None, "install.sh does not define read_prompt"
assert "< /dev/tty" in helper.group(1), "read_prompt does not read /dev/tty"
for path, source in _installer_sources().items():
bare = [line for line in source.splitlines() if PROMPT_LINE.match(line)]
leftovers = [line.strip() for line in bare if "read_prompt" not in line]
assert not leftovers, f"{path.name} prompts straight off stdin: {leftovers}"
def test_terminal_probe_selects_ask_or_auto() -> None:
"""A reachable /dev/tty asks for a mode; no terminal falls back to --auto."""
source = INSTALLER.read_text(encoding="utf-8")
assert "{ true < /dev/tty; } 2>/dev/null" in source
assert 'INTERACTIVE_MODE="ask"' in source
assert 'NON_INTERACTIVE="yes"' in source
# Interactive mode is gated on a reachable terminal, not on a tty stdin:
# under curl|bash stdin stays the pipe even while prompts work.
assert '"$TERMINAL_PROMPTS" != "yes"' in source
@needs_pty
@pytest.mark.timeout(60)
def test_piped_installer_reaches_past_the_stdin_probe(tmp_path: Path) -> None:
"""`cat install.sh | bash -s -- --help` under a pty prints usage.
On the broken version bash never got past the early stdin redirect: it
read the rest of the script from the terminal instead of the pipe, so no
installer output appeared at all.
"""
transcript = tmp_path / "help.typescript"
inner = f"cat {shlex.quote(str(INSTALLER))} | bash -s -- --help"
subprocess.run(
[SCRIPT, "-ec", f"bash -c {shlex.quote(inner)}", str(transcript)],
stdin=subprocess.DEVNULL,
capture_output=True,
timeout=45,
check=False,
)
output = transcript.read_text(encoding="utf-8", errors="replace")
assert "Vocalinux Installer" in output
assert "Usage:" in output
@needs_pty
@pytest.mark.timeout(90)
def test_piped_installer_prompts_answer_on_the_terminal(tmp_path: Path) -> None:
"""Prompts read /dev/tty while bash keeps reading the script from the pipe.
Feed "1" (interactive) early — pty input queues until the mode prompt
reads it — then Ctrl+C once the engine menu is up. Reaching "Choose
engine" proves the tty answer was accepted while the script still flowed
through the pipe. --tag skips the release-tag lookup so a slow GitHub
cannot eat the window before the first prompt appears.
"""
transcript = tmp_path / "interactive.typescript"
feeder = tmp_path / "feed-input.sh"
feeder.write_text(
"#!/bin/sh\n"
# Queued until the "Choose mode" prompt reads /dev/tty.
"sleep 1; printf '1\\n'\n"
# Interrupt only once the engine menu is on screen: a blind delay
# loses to a slow runner and Ctrl+C's a healthy installer mid-prompt.
"for _ in $(seq 1 60); do\n"
f" grep -q 'Choose engine' {shlex.quote(str(transcript))} 2>/dev/null && break\n"
" sleep 1\n"
"done\n"
"printf '\\003'\n",
encoding="utf-8",
)
feeder.chmod(0o755)
subprocess.run(
f"{shlex.quote(str(feeder))} | {shlex.quote(str(SCRIPT))} -ec "
+ shlex.quote(f"cat {shlex.quote(str(INSTALLER))} | bash -s -- --tag=v0.0.0-test")
+ f" {shlex.quote(str(transcript))}",
shell=True,
stdin=subprocess.DEVNULL,
capture_output=True,
timeout=75,
check=False,
cwd=REPO_ROOT,
env=_pty_env(tmp_path),
)
output = transcript.read_text(encoding="utf-8", errors="replace")
assert "Choose mode" in output
assert "Choose engine" in output