Files
MeshTerm/tests/test_anonymize.py
Jean-Pierre MartineauandClaude Haiku 5.5 9e579424b4 The Anonymize preference masks the home folder and addresses in the log and diagnostics
Off by default. Each masked value keeps a tidbit (j*********u, DA:xx:xx:xx:7C:7C,
192.xxx.x.180, m******e.local), so two radios stay two radios. The log masks each
line as it is written; diagnostics take --anonymize/--no-anonymize, and the page
toggles with `a` (Shift+F3 on the PicoCalc). Refs #31.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-17 21:30:26 -04:00

204 lines
8.0 KiB
Python

# SPDX-License-Identifier: Apache-2.0
"""The masks of the ``anonymize`` preference: what each one hides, and what it keeps.
A mask has two jobs, and a test for each one is necessary. It must hide enough that a
user can post the text in public. It must also keep enough that a maintainer can still
tell two radios apart, and can still read the line. Thus each test here asserts what is
gone and what is still there.
"""
from __future__ import annotations
import logging
from pathlib import Path
import pytest
from rich.console import Console
from meshterm.core import anonymize as module
from meshterm.core import preferences
from meshterm.core.anonymize import anonymize, mask_host, mask_name, remember_host
from meshterm.core.preferences import Preferences
from meshterm.persistence.logging import configure_logging, get_logger, log_path
@pytest.fixture
def windows_home(monkeypatch: pytest.MonkeyPatch) -> str:
"""A Windows home folder for this test, whatever OS runs the suite."""
home = r"C:\Users\jpmartineau"
monkeypatch.setattr(module, "_home", lambda: home)
return home
@pytest.fixture
def posix_home(monkeypatch: pytest.MonkeyPatch) -> str:
"""A home folder outside the standard places, which only the exact rule can find."""
home = "/srv/lakeside"
monkeypatch.setattr(module, "_home", lambda: home)
return home
# --- names ----------------------------------------------------------------------------
def test_a_name_keeps_its_first_and_last_characters() -> None:
"""JP's example, and the edges of the rule: a name keeps a tidbit and its length."""
assert mask_name("jpmartineau") == "j*********u"
assert mask_name("abc") == "a*c"
assert mask_name("jp") == "j*"
assert mask_name("j") == "*"
assert mask_name("") == ""
def test_the_account_in_this_home_is_masked_in_each_spelling(windows_home: str) -> None:
"""A backslash, a slash, and the doubled backslash of a ``repr`` all reach the account."""
text = (
r"File C:\Users\jpmartineau\AppData\x.py, "
"WindowsPath('C:/Users/jpmartineau/.meshterm'), "
r"{'out': 'C:\\Users\\jpmartineau\\bug.md'}"
)
masked = anonymize(text)
assert "jpmartineau" not in masked
assert r"C:\Users\j*********u\AppData\x.py" in masked
assert "C:/Users/j*********u/.meshterm" in masked
assert r"C:\\Users\\j*********u\\bug.md" in masked
def test_a_home_outside_the_standard_places_is_masked(posix_home: str) -> None:
"""The exact rule finds the home of this machine also where no general rule looks."""
assert anonymize("cd /srv/lakeside/.meshterm") == "cd /srv/l******e/.meshterm"
# A folder that only starts with the account name is a different folder.
assert anonymize("/srv/lakesides/x") == "/srv/lakesides/x"
def test_any_account_in_a_standard_home_is_masked(posix_home: str) -> None:
"""Another account, a macOS home, and the Windows short form of a long name are masked."""
assert anonymize("/home/jp/MeshTerm/log") == "/home/j*/MeshTerm/log"
assert anonymize("/Users/someone/.meshterm") == "/Users/s*****e/.meshterm"
assert anonymize(r"C:\Users\JPMART~1\AppData\Local\Temp") == (
r"C:\Users\J******1\AppData\Local\Temp"
)
# --- radio addresses --------------------------------------------------------------------
def test_a_bluetooth_address_keeps_two_digits_at_the_start_and_four_at_the_end() -> None:
"""Two radios stay two radios, as the last four digits of two cards do."""
assert anonymize("BLE link to DA:C5:21:6B:7C:7C failed") == (
"BLE link to DA:xx:xx:xx:7C:7C failed"
)
assert anonymize("da-c5-21-6b-7c-7c") == "da-xx-xx-xx-7c-7c"
def test_an_address_that_a_colon_follows_is_still_masked() -> None:
"""A log line can put a colon after an address. The rule must not stop there."""
line = "could not open BLE companion 3C:84:27:CB:DE:81: connected to 3C:84:27:CB:DE:81"
assert anonymize(line) == (
"could not open BLE companion 3C:xx:xx:xx:DE:81: connected to 3C:xx:xx:xx:DE:81"
)
def test_a_macos_peripheral_id_is_masked_in_the_same_way() -> None:
"""A UUID names a peripheral on macOS. It keeps the same two and four digits."""
assert anonymize("1A2B3C4D-1111-2222-3333-444455556666") == (
"1Axxxxxx-xxxx-xxxx-xxxx-xxxxxxxx6666"
)
# --- host addresses ---------------------------------------------------------------------
def test_an_ipv4_address_keeps_its_first_and_last_octets() -> None:
"""The two middle octets go. The first and the last tell two hosts apart."""
assert anonymize("no response from 192.168.0.180:5000.") == (
"no response from 192.xxx.x.180:5000."
)
assert mask_host("10.0.0.5") == "10.x.x.5"
def test_a_registered_host_name_is_masked_where_it_occurs() -> None:
"""The name of a network companion is masked, also with a port and in another case."""
remember_host("lakeside-companion.lan")
masked = anonymize("couldn't connect to Lakeside-Companion.lan:5000")
assert masked == "couldn't connect to l****************n.lan:5000"
def test_a_host_keeps_the_label_that_names_its_network() -> None:
"""The last label (``.local``, ``.lan``) says what type of network it is. It stays."""
assert mask_host("meshcore.local") == "m******e.local"
assert mask_host("companion") == "c*******n"
assert mask_host("fe80::1ff:fe23:4567:890a") == "fe80::xxx:xxxx:xxxx:890a"
# --- what stays -------------------------------------------------------------------------
@pytest.mark.parametrize(
"text",
[
"2026-10-17 14:23:11 WARNING meshterm.core.connection: link lost",
"trace 2f882ad1: path=3d63f2c23d63 flags=1 timeout=8.8s -> 1297ms",
"os_build 10.0.19045.1234, firmware v1.15.0, python 3.10.11",
"path a1,3d,f2 via 3d63c642",
"could not open port 'COM20': PermissionError(13, 'Access is denied.')",
"/dev/ttyACM0 at 115200",
],
)
def test_a_line_with_no_personal_detail_is_unchanged(text: str, posix_home: str) -> None:
"""Times, hashes, versions, paths of hops, and serial ports are not personal details.
A mask that also hides these makes the log useless to the maintainer, who needs them.
"""
assert anonymize(text) == text
def test_a_masked_text_masks_to_itself(windows_home: str) -> None:
"""A text can go through the mask two times with no change. Each mask is idempotent."""
remember_host("hilltop.lan")
text = (
r"C:\Users\jpmartineau\x C:\Users\JPMART~1\y DA:C5:21:6B:7C:7C "
"192.168.0.180 hilltop.lan:5000 1A2B3C4D-1111-2222-3333-444455556666"
)
once = anonymize(text)
assert anonymize(once) == once
# --- the log ----------------------------------------------------------------------------
def test_the_log_is_masked_only_while_the_preference_is_on(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, windows_home: str
) -> None:
"""The file gets each detail by default, and the masks from the next line after a change.
The formatter reads the preference at each record. Thus the change on the Preferences
page applies at once, with no restart.
"""
prefs = Preferences()
monkeypatch.setattr(preferences, "_current", prefs)
configure_logging(Console(quiet=True), tmp_path, file_level=logging.WARNING, quiet=True)
log = get_logger("core.connection")
log.warning("BLE link to DA:C5:21:6B:7C:7C lost")
prefs.set("anonymize", True)
log.warning("BLE link to DA:C5:21:6B:7C:7C lost again")
try:
raise OSError(r"cannot read C:\Users\jpmartineau\.meshterm\x")
except OSError:
log.exception("read failed")
first, second, *rest = _read(tmp_path).splitlines()
assert "DA:C5:21:6B:7C:7C" in first
assert "DA:xx:xx:xx:7C:7C" in second
traceback = "\n".join(rest)
assert "jpmartineau" not in traceback
assert r"C:\Users\j*********u\.meshterm" in traceback
def _read(log_dir: Path) -> str:
"""The text of the log file, after each handler has written what it holds."""
for handler in get_logger().handlers:
handler.flush()
return log_path(log_dir).read_text(encoding="utf-8")