Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ Internal
---------
* Improve test coverage for DSN variable expansion.
* Test on all platforms in the Publish GitHub Action.
* Remove unused support for writing `.mylogin.cnf` files.


1.76.0 (2026/06/20)
Expand Down
50 changes: 0 additions & 50 deletions mycli/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -138,56 +138,6 @@ def open_mylogin_cnf(name: str) -> TextIOWrapper | None:
return TextIOWrapper(plaintext)


# TODO reuse code between encryption an decryption
def encrypt_mylogin_cnf(plaintext: IO[str]) -> BytesIO:
"""Encryption of .mylogin.cnf file, analogous to calling
mysql_config_editor.

Code is based on the python implementation by Kristian Koehntopp
https://github.com/isotopp/mysql-config-coder

"""

def realkey(key: bytes) -> bytes:
"""Create the AES key from the login key."""
rkey = bytearray(16)
for i in range(len(key)):
rkey[i % 16] ^= key[i]
return bytes(rkey)

def encode_line(plaintext: str, real_key: bytes, buf_len: int) -> bytes:
aes = AES.new(real_key, AES.MODE_ECB)
text_len = len(plaintext)
pad_len = buf_len - text_len
pad_chr = bytes(chr(pad_len), "utf8")
plaintext_b = plaintext.encode() + pad_chr * pad_len
encrypted_text = b"".join([aes.encrypt(plaintext_b[i : i + 16]) for i in range(0, len(plaintext_b), 16)])
return encrypted_text

LOGIN_KEY_LENGTH = 20
key = os.urandom(LOGIN_KEY_LENGTH)
real_key = realkey(key)

outfile = BytesIO()

outfile.write(struct.pack("i", 0))
outfile.write(key)

while True:
line = plaintext.readline()
if not line:
break
real_len = len(line)
pad_len = (int(real_len / 16) + 1) * 16

outfile.write(struct.pack("i", pad_len))
x = encode_line(line, real_key, pad_len)
outfile.write(x)

outfile.seek(0)
return outfile


def read_and_decrypt_mylogin_cnf(f: BinaryIO) -> BytesIO | None:
"""Read and decrypt the contents of .mylogin.cnf.

Expand Down
7 changes: 0 additions & 7 deletions test/features/connection.feature
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,3 @@ Feature: connect to a database:
When we run mycli without arguments "host port"
When we query "status"
Then status contains "via UNIX socket"

Scenario: run mycli with mylogin.cnf configuration
When we create mylogin.cnf file
When we run mycli with arguments "login_path=test_login_path" without arguments "host port user pass defaults_file"
Then we are logged in


16 changes: 1 addition & 15 deletions test/features/steps/connection.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,10 @@
# type: ignore

import io
import os

from behave import then, when
import wrappers

from mycli.config import encrypt_mylogin_cnf
from test.features.environment import MYLOGIN_CNF_PATH, get_db_name_from_context
from test.features.environment import get_db_name_from_context
from test.features.steps.utils import parse_cli_args_to_dict
from test.utils import HOST, PASSWORD, PORT, USER

TEST_LOGIN_PATH = "test_login_path"

Expand All @@ -31,15 +26,6 @@ def status_contains(context, expression):
context.atprompt = True


@when("we create mylogin.cnf file")
def step_create_mylogin_cnf_file(context):
os.environ.pop("MYSQL_TEST_LOGIN_FILE", None)
mylogin_cnf = f"[{TEST_LOGIN_PATH}]\nhost = {HOST}\nport = {PORT}\nuser = {USER}\npassword = {PASSWORD}\n"
with open(MYLOGIN_CNF_PATH, "wb") as f:
input_file = io.StringIO(mylogin_cnf)
f.write(encrypt_mylogin_cnf(input_file).read())


@then("we are logged in")
def we_are_logged_in(context):
db_name = get_db_name_from_context(context)
Expand Down
11 changes: 0 additions & 11 deletions test/pytests/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@
from mycli.config import (
_remove_pad,
create_default_config,
encrypt_mylogin_cnf,
get_mylogin_cnf_path,
log,
open_mylogin_cnf,
Expand Down Expand Up @@ -244,16 +243,6 @@ def test_open_mylogin_cnf_error_paths(monkeypatch, tmp_path, caplog) -> None:
assert 'Unable to read login path file.' in caplog.text


def test_encrypt_mylogin_cnf_round_trip() -> None:
plaintext = StringIO('[client]\nuser=test\npassword=secret\n')

encrypted = encrypt_mylogin_cnf(plaintext)
decrypted = read_and_decrypt_mylogin_cnf(encrypted)

assert isinstance(encrypted, BytesIO)
assert decrypted.read().decode('utf8') == '[client]\nuser=test\npassword=secret\n'


def test_read_and_decrypt_mylogin_cnf_error_branches(caplog) -> None:
incomplete_key = BytesIO(struct.pack('i', 0) + b'a')
with caplog.at_level(logging.ERROR, logger='mycli.config'):
Expand Down
Loading