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>
204 lines
8.0 KiB
Python
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")
|