Skip to content

Commit 139c530

Browse files
committed
don't persist password-change SQL to history file
Leveraging the new is_password_change() detection, avoid persisting password-change statements to the history file, but leave them available for navigation in the current session. Wrap the sqlglot.tokenize() call for is_password_change() in a try block, since it could throw if given invalid SQL. Add and improve tests for sql_utils.py.
1 parent 7f25681 commit 139c530

File tree

5 files changed

+133
-1
lines changed

5 files changed

+133
-1
lines changed

changelog.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ Features
88
* Allow more characters in passwords read from a file.
99
* Show sponsors and contributors separately in startup messages.
1010
* Add support for expired password (sandbox) mode (#440)
11+
* Don't persist password-change statements to history file.
1112

1213

1314
Bug Fixes

mycli/packages/ptoolkit/history.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33

44
from prompt_toolkit.history import FileHistory
55

6+
from mycli.packages.sql_utils import is_password_change
7+
68
_StrOrBytesPath = Union[str, bytes, os.PathLike]
79

810

@@ -15,6 +17,13 @@ def __init__(self, filename: _StrOrBytesPath) -> None:
1517
self.filename = filename
1618
super().__init__(filename)
1719

20+
def append_string(self, string: str) -> None:
21+
"Add string to the history."
22+
self._loaded_strings.insert(0, string)
23+
if is_password_change(string):
24+
return
25+
self.store_string(string)
26+
1827
def load_history_with_timestamp(self) -> list[tuple[str, str]]:
1928
"""
2029
Load history entries along with their timestamps.

mycli/packages/sql_utils.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -485,7 +485,11 @@ def classify_sandbox_statement(text: str) -> tuple[str | None, str | None]:
485485
if not stripped:
486486
return ('quit', None)
487487

488-
tokens = list(sqlglot.tokenize(stripped, dialect='mysql'))
488+
try:
489+
tokens = list(sqlglot.tokenize(stripped, dialect='mysql'))
490+
except sqlglot.errors.TokenError:
491+
tokens = []
492+
489493
if not tokens:
490494
return ('quit', None)
491495

test/pytests/test_ptoolkit_history.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from pathlib import Path
44

5+
from mycli.packages.ptoolkit import history as history_module
56
from mycli.packages.ptoolkit.history import FileHistoryWithTimestamp
67

78

@@ -13,6 +14,29 @@ def test_file_history_with_timestamp_sets_filename(tmp_path: Path) -> None:
1314
assert history.filename == history_path
1415

1516

17+
def test_append_string_caches_and_stores_non_password_statement(tmp_path: Path, monkeypatch) -> None:
18+
history = FileHistoryWithTimestamp(tmp_path / 'history.txt')
19+
stored: list[str] = []
20+
monkeypatch.setattr(history, 'store_string', stored.append)
21+
22+
history.append_string('SELECT 1')
23+
24+
assert history._loaded_strings[0] == 'SELECT 1'
25+
assert stored == ['SELECT 1']
26+
27+
28+
def test_append_string_does_not_store_password_change(tmp_path: Path, monkeypatch) -> None:
29+
history = FileHistoryWithTimestamp(tmp_path / 'history.txt')
30+
stored: list[str] = []
31+
monkeypatch.setattr(history, 'store_string', stored.append)
32+
monkeypatch.setattr(history_module, 'is_password_change', lambda string: True)
33+
34+
history.append_string("SET PASSWORD = 'secret'")
35+
36+
assert history._loaded_strings[0] == "SET PASSWORD = 'secret'"
37+
assert stored == []
38+
39+
1640
def test_load_history_with_timestamp_returns_empty_when_file_is_missing(tmp_path: Path) -> None:
1741
history = FileHistoryWithTimestamp(tmp_path / 'missing-history.txt')
1842

test/pytests/test_sql_utils.py

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
# type: ignore
22

3+
from types import SimpleNamespace
4+
35
import pytest
46
import sqlparse
57
from sqlparse.sql import Identifier, IdentifierList, Token, TokenList
@@ -563,6 +565,98 @@ def split(self):
563565
assert need_completion_reset('ignored') is False
564566

565567

568+
def test_classify_sandbox_statement_treats_token_error_as_quit(monkeypatch):
569+
def raise_token_error(*_args, **_kwargs):
570+
raise sql_utils.sqlglot.errors.TokenError('bad token')
571+
572+
monkeypatch.setattr(sql_utils.sqlglot, 'tokenize', raise_token_error)
573+
574+
assert sql_utils.classify_sandbox_statement('`') == ('quit', None)
575+
576+
577+
def test_classify_sandbox_statement_treats_empty_tokens_as_quit(monkeypatch):
578+
monkeypatch.setattr(sql_utils.sqlglot, 'tokenize', lambda *_args, **_kwargs: [])
579+
580+
assert sql_utils.classify_sandbox_statement('ignored') == ('quit', None)
581+
582+
583+
def test_find_password_after_eq_returns_none_for_non_string_token() -> None:
584+
token_type = sql_utils.sqlglot.tokens.TokenType
585+
tokens = [
586+
SimpleNamespace(token_type=token_type.EQ, text='='),
587+
SimpleNamespace(token_type=token_type.VAR, text='CURRENT_USER'),
588+
]
589+
590+
assert sql_utils._find_password_after_eq(tokens) is None
591+
592+
593+
@pytest.mark.parametrize(
594+
('text', 'expected'),
595+
[
596+
('', ('quit', None)),
597+
(' ', ('quit', None)),
598+
('quit', ('quit', None)),
599+
('exit', ('quit', None)),
600+
('\\q', ('quit', None)),
601+
("ALTER USER 'root'@'localhost' IDENTIFIED BY 'new'", ('alter_user', 'new')),
602+
('ALTER USER root IDENTIFIED WITH mysql_native_password', ('alter_user', None)),
603+
("SET PASSWORD = 'newpass'", ('set_password', 'newpass')),
604+
('SELECT 1', (None, None)),
605+
],
606+
)
607+
def test_classify_sandbox_statement(text: str, expected: tuple[str | None, str | None]) -> None:
608+
assert sql_utils.classify_sandbox_statement(text) == expected
609+
610+
611+
@pytest.mark.parametrize(
612+
('text', 'expected'),
613+
[
614+
('', True),
615+
(' ', True),
616+
("ALTER USER 'root'@'localhost' IDENTIFIED BY 'new'", True),
617+
('alter user root identified by "pw"', True),
618+
("SET PASSWORD = 'newpass'", True),
619+
("set password = 'newpass'", True),
620+
('quit', True),
621+
('exit', True),
622+
('\\q', True),
623+
('SELECT 1', False),
624+
('DROP TABLE t', False),
625+
('USE mydb', False),
626+
('SHOW DATABASES', False),
627+
],
628+
)
629+
def test_is_sandbox_allowed(text: str, expected: bool) -> None:
630+
assert sql_utils.is_sandbox_allowed(text) is expected
631+
632+
633+
@pytest.mark.parametrize(
634+
('text', 'expected'),
635+
[
636+
("ALTER USER 'root'@'localhost' IDENTIFIED BY 'new'", True),
637+
("SET PASSWORD = 'newpass'", True),
638+
('SELECT 1', False),
639+
('quit', False),
640+
],
641+
)
642+
def test_is_password_change(text: str, expected: bool) -> None:
643+
assert sql_utils.is_password_change(text) is expected
644+
645+
646+
@pytest.mark.parametrize(
647+
('text', 'expected'),
648+
[
649+
("ALTER USER 'root'@'localhost' IDENTIFIED BY 'newpass'", 'newpass'),
650+
("SET PASSWORD = 'secret123'", 'secret123'),
651+
("ALTER USER root IDENTIFIED BY 'p@ss w0rd!'", 'p@ss w0rd!'),
652+
('ALTER USER root IDENTIFIED WITH mysql_native_password', None),
653+
('SELECT 1', None),
654+
],
655+
)
656+
def test_extract_new_password(text: str, expected: str | None) -> None:
657+
assert sql_utils.extract_new_password(text) == expected
658+
659+
566660
@pytest.mark.parametrize(
567661
('status_plain', 'expected'),
568662
[

0 commit comments

Comments
 (0)