Skip to content

Commit c16cccc

Browse files
authored
Merge pull request #240 from Maxteabag/worktree-conn-string-stdin
feat(cli): stdin support for --password / --url / --ssh-password (closes #158)
2 parents 993bf0c + 7e33973 commit c16cccc

4 files changed

Lines changed: 304 additions & 4 deletions

File tree

sqlit/cli.py

Lines changed: 93 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -241,6 +241,69 @@ def _parse_float_value(value: str | None, default: float) -> float:
241241
return default
242242

243243

244+
def _add_stdin_secret_flags(parser: argparse.ArgumentParser, *, include_ssh: bool) -> None:
245+
"""Attach --password-stdin (and optionally --ssh-password-stdin) to a parser."""
246+
parser.add_argument(
247+
"--password-stdin",
248+
dest="password_stdin",
249+
action="store_true",
250+
help="Read the password from stdin (one line, trailing newline stripped)",
251+
)
252+
if include_ssh:
253+
parser.add_argument(
254+
"--ssh-password-stdin",
255+
dest="ssh_password_stdin",
256+
action="store_true",
257+
help="Read the SSH password from stdin (one line, trailing newline stripped)",
258+
)
259+
260+
261+
def _resolve_stdin_secrets(args: argparse.Namespace) -> None:
262+
"""Populate args.password / args.url / args.ssh_password from stdin if requested.
263+
264+
Recognised stdin-trigger attrs: ``password_stdin``, ``url_stdin``,
265+
``ssh_password_stdin``. At most one may be set per invocation — stdin
266+
is a single stream and we read one line from it. The corresponding
267+
cleartext flag must not also be set.
268+
"""
269+
from sqlit.domains.connections.domain.stdin_secret import (
270+
StdinSecretError,
271+
read_secret_from_stdin,
272+
)
273+
274+
requests: list[tuple[str, str]] = []
275+
if getattr(args, "password_stdin", False):
276+
requests.append(("password", "password"))
277+
if getattr(args, "url_stdin", False):
278+
requests.append(("url", "url"))
279+
if getattr(args, "ssh_password_stdin", False):
280+
requests.append(("ssh_password", "ssh-password"))
281+
282+
if not requests:
283+
return
284+
285+
if len(requests) > 1:
286+
flags = ", ".join(f"--{label}-stdin" for _, label in requests)
287+
raise SystemExit(
288+
f"Error: only one of {flags} may be used per invocation "
289+
f"(stdin can only feed one secret)."
290+
)
291+
292+
attr, label = requests[0]
293+
existing = getattr(args, attr, None)
294+
if existing:
295+
raise SystemExit(
296+
f"Error: --{label} and --{label}-stdin are mutually exclusive."
297+
)
298+
299+
try:
300+
value = read_secret_from_stdin(label=label)
301+
except StdinSecretError as exc:
302+
raise SystemExit(f"Error: {exc}")
303+
304+
setattr(args, attr, value)
305+
306+
244307
def _resolve_startup_log_path(argv: list[str]) -> Path | None:
245308
env_profile = os.environ.get("SQLIT_PROFILE_STARTUP") == "1"
246309
env_exit = os.environ.get("SQLIT_PROFILE_STARTUP_EXIT") == "1"
@@ -430,7 +493,16 @@ def main() -> int:
430493
parser.add_argument("--port", help="Temporary connection port")
431494
parser.add_argument("--database", help="Temporary connection database name")
432495
parser.add_argument("--username", help="Temporary connection username")
433-
parser.add_argument("--password", help="Temporary connection password")
496+
parser.add_argument(
497+
"--password",
498+
help="Temporary connection password (or use --password-stdin to read from stdin)",
499+
)
500+
parser.add_argument(
501+
"--password-stdin",
502+
dest="password_stdin",
503+
action="store_true",
504+
help="Read the password from stdin (one line, trailing newline stripped)",
505+
)
434506
parser.add_argument("--file-path", help="Temporary connection file path (SQLite/DuckDB)")
435507
parser.add_argument(
436508
"--auth-type",
@@ -568,13 +640,22 @@ def main() -> int:
568640
add_parser.add_argument(
569641
"--url",
570642
metavar="URL",
571-
help="Connection URL (e.g., postgresql://user:pass@host:5432/db). Requires --name.",
643+
help=(
644+
"Connection URL (e.g., postgresql://user:pass@host:5432/db). "
645+
"Requires --name. Use --url-stdin to read it from stdin instead."
646+
),
647+
)
648+
add_parser.add_argument(
649+
"--url-stdin",
650+
dest="url_stdin",
651+
action="store_true",
652+
help="Read the connection URL from stdin (one line, trailing newline stripped)",
572653
)
573654
add_parser.add_argument(
574655
"--name",
575656
"-n",
576657
dest="url_name",
577-
help="Connection name (required when using --url)",
658+
help="Connection name (required when using --url / --url-stdin)",
578659
)
579660
add_provider_parsers = add_parser.add_subparsers(dest="provider", metavar="PROVIDER")
580661
for db_type in get_supported_db_types():
@@ -587,6 +668,7 @@ def main() -> int:
587668
add_schema_arguments(provider_parser, schema, include_name=True, name_required=True)
588669
provider_parser.add_argument("--password-command", dest="password_command", help="Shell command to retrieve the database password")
589670
provider_parser.add_argument("--ssh-password-command", dest="ssh_password_command", help="Shell command to retrieve the SSH password")
671+
_add_stdin_secret_flags(provider_parser, include_ssh=True)
590672
provider_parser.add_argument(
591673
"--alert",
592674
metavar="MODE",
@@ -601,7 +683,11 @@ def main() -> int:
601683
edit_parser.add_argument("--port", "-P", help="Port")
602684
edit_parser.add_argument("--database", "-d", help="Database name")
603685
edit_parser.add_argument("--username", "-u", help="Username")
604-
edit_parser.add_argument("--password", "-p", help="Password")
686+
edit_parser.add_argument(
687+
"--password",
688+
"-p",
689+
help="Password (or use --password-stdin to read from stdin)",
690+
)
605691
edit_parser.add_argument(
606692
"--auth-type",
607693
"-a",
@@ -611,6 +697,7 @@ def main() -> int:
611697
edit_parser.add_argument("--file-path", help="Database file path (SQLite only)")
612698
edit_parser.add_argument("--password-command", dest="password_command", help="Shell command to retrieve the database password")
613699
edit_parser.add_argument("--ssh-password-command", dest="ssh_password_command", help="Shell command to retrieve the SSH password")
700+
_add_stdin_secret_flags(edit_parser, include_ssh=True)
614701
edit_parser.add_argument(
615702
"--alert",
616703
metavar="MODE",
@@ -632,6 +719,7 @@ def main() -> int:
632719
add_schema_arguments(provider_parser, schema, include_name=True, name_required=False)
633720
provider_parser.add_argument("--password-command", dest="password_command", help="Shell command to retrieve the database password")
634721
provider_parser.add_argument("--ssh-password-command", dest="ssh_password_command", help="Shell command to retrieve the SSH password")
722+
_add_stdin_secret_flags(provider_parser, include_ssh=True)
635723
provider_parser.add_argument(
636724
"--alert",
637725
metavar="MODE",
@@ -705,6 +793,7 @@ def main() -> int:
705793

706794
with startup_span("cli_parse_args"):
707795
args = parser.parse_args(filtered_argv[1:]) # Skip program name
796+
_resolve_stdin_secrets(args)
708797
log_startup_step("cli_parse_end")
709798

710799
with startup_span("runtime_build"):
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
"""Read a secret (password, connection URL, ...) from stdin.
2+
3+
Used by the `--password-stdin` / `--url-stdin` / `--ssh-password-stdin`
4+
flags so callers can pipe credentials in instead of passing them on the
5+
command line, where they'd be visible to other users via ``ps`` or
6+
``/proc/<pid>/cmdline``.
7+
"""
8+
9+
from __future__ import annotations
10+
11+
import sys
12+
from typing import TextIO
13+
14+
15+
class StdinSecretError(Exception):
16+
"""Raised when a secret can't be read from stdin."""
17+
18+
19+
def read_secret_from_stdin(
20+
*,
21+
label: str = "secret",
22+
stream: TextIO | None = None,
23+
) -> str:
24+
"""Read one line from stdin and strip the trailing newline.
25+
26+
Refuses to read when stdin is a TTY — there's no plausible
27+
non-interactive workflow for that, and silently waiting on user
28+
input would be confusing when the caller intended a piped value.
29+
Use ``label`` to make the error point at the offending flag (e.g.
30+
``password``, ``url``).
31+
"""
32+
source: TextIO = stream if stream is not None else sys.stdin
33+
if source.isatty():
34+
raise StdinSecretError(
35+
f"Refusing to read {label} from stdin: stdin is a TTY. "
36+
f"Pipe the value in, e.g. `echo $SECRET | sqlit ... --{label}-stdin`."
37+
)
38+
39+
line = source.readline()
40+
if line == "":
41+
raise StdinSecretError(f"No {label} received on stdin (EOF).")
42+
43+
if line.endswith("\r\n"):
44+
return line[:-2]
45+
if line.endswith("\n"):
46+
return line[:-1]
47+
return line

tests/cli/test_cli_main.py

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,24 @@
11
from __future__ import annotations
22

3+
import subprocess
4+
import sys
35
from pathlib import Path
46

57
from tests.conftest import run_cli
68

79

10+
def _run_cli_with_stdin(*args: str, stdin: str, env_config_dir: Path) -> subprocess.CompletedProcess:
11+
"""Invoke the sqlit CLI with a piped stdin payload."""
12+
cmd = [sys.executable, "-m", "sqlit.cli", *args]
13+
return subprocess.run(
14+
cmd,
15+
input=stdin,
16+
capture_output=True,
17+
text=True,
18+
env={"SQLIT_CONFIG_DIR": str(env_config_dir), "PATH": __import__("os").environ.get("PATH", "")},
19+
)
20+
21+
822
def test_cli_connections_list_empty(tmp_path: Path, monkeypatch):
923
settings_path = tmp_path / "settings.json"
1024
settings_path.write_text('{"allow_plaintext_credentials": true}', encoding="utf-8")
@@ -15,3 +29,92 @@ def test_cli_connections_list_empty(tmp_path: Path, monkeypatch):
1529

1630
assert result.returncode == 0
1731
assert "No saved connections." in result.stdout
32+
33+
34+
def test_url_stdin_creates_connection(tmp_path: Path):
35+
settings_path = tmp_path / "settings.json"
36+
settings_path.write_text('{"allow_plaintext_credentials": true}', encoding="utf-8")
37+
38+
result = _run_cli_with_stdin(
39+
"connections", "add", "--url-stdin", "--name", "StdinURL",
40+
stdin="sqlite:///tmp/sqlit-stdin-test.db\n",
41+
env_config_dir=tmp_path,
42+
)
43+
44+
assert result.returncode == 0, result.stderr
45+
assert "StdinURL" in result.stdout
46+
47+
48+
def test_url_stdin_rejects_when_url_also_provided(tmp_path: Path):
49+
settings_path = tmp_path / "settings.json"
50+
settings_path.write_text('{"allow_plaintext_credentials": true}', encoding="utf-8")
51+
52+
result = _run_cli_with_stdin(
53+
"connections", "add",
54+
"--url", "sqlite:///tmp/a.db",
55+
"--url-stdin",
56+
"--name", "X",
57+
stdin="sqlite:///tmp/b.db\n",
58+
env_config_dir=tmp_path,
59+
)
60+
61+
assert result.returncode != 0
62+
assert "mutually exclusive" in (result.stderr + result.stdout)
63+
64+
65+
def test_password_stdin_mutex_with_password(tmp_path: Path):
66+
settings_path = tmp_path / "settings.json"
67+
settings_path.write_text('{"allow_plaintext_credentials": true}', encoding="utf-8")
68+
69+
result = _run_cli_with_stdin(
70+
"connect", "postgresql",
71+
"--name", "X",
72+
"--server", "localhost",
73+
"--port", "5432",
74+
"--database", "d",
75+
"--username", "u",
76+
"--password", "cleartext",
77+
"--password-stdin",
78+
stdin="frompipe\n",
79+
env_config_dir=tmp_path,
80+
)
81+
82+
assert result.returncode != 0
83+
assert "mutually exclusive" in (result.stderr + result.stdout)
84+
85+
86+
def test_multiple_stdin_flags_rejected(tmp_path: Path):
87+
settings_path = tmp_path / "settings.json"
88+
settings_path.write_text('{"allow_plaintext_credentials": true}', encoding="utf-8")
89+
90+
result = _run_cli_with_stdin(
91+
"connections", "edit", "Nonexistent",
92+
"--password-stdin",
93+
"--ssh-password-stdin",
94+
stdin="x\n",
95+
env_config_dir=tmp_path,
96+
)
97+
98+
assert result.returncode != 0
99+
output = result.stderr + result.stdout
100+
assert "only one" in output and "stdin" in output
101+
102+
103+
def test_password_stdin_eof_errors_cleanly(tmp_path: Path):
104+
settings_path = tmp_path / "settings.json"
105+
settings_path.write_text('{"allow_plaintext_credentials": true}', encoding="utf-8")
106+
107+
result = _run_cli_with_stdin(
108+
"connect", "postgresql",
109+
"--name", "X",
110+
"--server", "localhost",
111+
"--port", "5432",
112+
"--database", "d",
113+
"--username", "u",
114+
"--password-stdin",
115+
stdin="",
116+
env_config_dir=tmp_path,
117+
)
118+
119+
assert result.returncode != 0
120+
assert "EOF" in (result.stderr + result.stdout)

tests/unit/test_stdin_secret.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
"""Tests for the stdin-secret reader used by --password-stdin / --url-stdin."""
2+
3+
from __future__ import annotations
4+
5+
import io
6+
from unittest.mock import patch
7+
8+
import pytest
9+
10+
from sqlit.domains.connections.domain.stdin_secret import (
11+
StdinSecretError,
12+
read_secret_from_stdin,
13+
)
14+
15+
16+
class _FakeStream(io.StringIO):
17+
def __init__(self, contents: str, *, isatty: bool = False) -> None:
18+
super().__init__(contents)
19+
self._isatty = isatty
20+
21+
def isatty(self) -> bool: # type: ignore[override]
22+
return self._isatty
23+
24+
25+
class TestReadSecretFromStdin:
26+
def test_strips_trailing_newline(self) -> None:
27+
assert read_secret_from_stdin(stream=_FakeStream("secret\n")) == "secret"
28+
29+
def test_strips_crlf(self) -> None:
30+
assert read_secret_from_stdin(stream=_FakeStream("secret\r\n")) == "secret"
31+
32+
def test_preserves_internal_spaces(self) -> None:
33+
assert read_secret_from_stdin(stream=_FakeStream("a b c\n")) == "a b c"
34+
35+
def test_no_trailing_newline_is_returned_verbatim(self) -> None:
36+
assert read_secret_from_stdin(stream=_FakeStream("naked")) == "naked"
37+
38+
def test_only_reads_first_line(self) -> None:
39+
stream = _FakeStream("first\nsecond\n")
40+
assert read_secret_from_stdin(stream=stream) == "first"
41+
42+
def test_refuses_tty(self) -> None:
43+
with pytest.raises(StdinSecretError, match="TTY"):
44+
read_secret_from_stdin(stream=_FakeStream("ignored\n", isatty=True))
45+
46+
def test_refuses_empty_stream(self) -> None:
47+
with pytest.raises(StdinSecretError, match="EOF"):
48+
read_secret_from_stdin(stream=_FakeStream(""))
49+
50+
def test_label_appears_in_tty_error(self) -> None:
51+
with pytest.raises(StdinSecretError, match="url"):
52+
read_secret_from_stdin(label="url", stream=_FakeStream("x", isatty=True))
53+
54+
def test_label_appears_in_eof_error(self) -> None:
55+
with pytest.raises(StdinSecretError, match="ssh-password"):
56+
read_secret_from_stdin(label="ssh-password", stream=_FakeStream(""))
57+
58+
def test_defaults_to_sys_stdin(self) -> None:
59+
with patch("sqlit.domains.connections.domain.stdin_secret.sys") as mock_sys:
60+
mock_sys.stdin = _FakeStream("from-real-stdin\n")
61+
assert read_secret_from_stdin() == "from-real-stdin"

0 commit comments

Comments
 (0)