From 9e579424b493ee92ef62c6970e457157c4cc5bf4 Mon Sep 17 00:00:00 2001 From: Jean-Pierre Martineau Date: Sat, 17 Oct 2026 21:30:26 -0400 Subject: [PATCH] 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 --- .github/ISSUE_TEMPLATE/bug.yml | 7 + docs/cli/README.md | 30 ++++ docs/development/writing-style.md | 3 + docs/guide/connecting.md | 4 +- docs/guide/features.md | 2 +- meshterm/cli.py | 6 + meshterm/core/anonymize.py | 259 ++++++++++++++++++++++++++++++ meshterm/core/connection.py | 6 + meshterm/core/preferences.py | 12 ++ meshterm/persistence/logging.py | 28 +++- meshterm/tools/diagnostics.py | 102 +++++++++++- meshterm/ui/diagnostics.py | 127 ++++++++++++--- tests/test_anonymize.py | 203 +++++++++++++++++++++++ tests/test_diagnostics.py | 139 +++++++++++++++- tests/test_gallery.py | 30 +++- 15 files changed, 920 insertions(+), 38 deletions(-) create mode 100644 meshterm/core/anonymize.py create mode 100644 tests/test_anonymize.py diff --git a/.github/ISSUE_TEMPLATE/bug.yml b/.github/ISSUE_TEMPLATE/bug.yml index faadafa..1a9f66a 100644 --- a/.github/ISSUE_TEMPLATE/bug.yml +++ b/.github/ISSUE_TEMPLATE/bug.yml @@ -22,6 +22,13 @@ body: The **Diagnostics** page in the menu saves the same file next to the log. Press `s`, or F3 on the PicoCalc. + The file and the log can show your account name (in the path of your home folder) + and the address of your radio. To mask them, add `--anonymize` to the command, or + press `a` on the page. To mask them in the log too, turn on **Anonymize** on the + Preferences page (`meshterm preferences set anonymize on`) before you make the + problem occur. Each masked value keeps a few characters, so that I can still tell + two radios apart. + The rest of this form asks only for the information that the file cannot know. - type: textarea diff --git a/docs/cli/README.md b/docs/cli/README.md index 0b3ef12..93e24b0 100644 --- a/docs/cli/README.md +++ b/docs/cli/README.md @@ -1756,6 +1756,7 @@ console_font 6x12 6x12 name_overlap_days 7 7 Grey a name two nodes used within this many days section_spacing_min_rows 24 24 A blank line between sections and between chat authors log_level WARNING WARNING How much MeshTerm writes to its log file (next launch) +anonymize off off Mask the home folder and addresses in the log and diagnostics ``` **`DESCRIPTION`** says what each preference does, because the name of a key does not give @@ -1868,6 +1869,7 @@ tx_power_dbm 20 error - config_dir C:\Users\you\.meshterm +anonymized no db_size_kb 41280 log_level DEBUG log_size_kb 96 @@ -1919,6 +1921,29 @@ public without reading it. `tests/test_diagnostics.py` plants real secrets in ea that holds one. It fails if one of them, or a field that is only *named* like one, reaches either face. +**`--anonymize` masks the personal details, and keeps a tidbit of each one.** Some facts +are not secrets, but they are about you and your machine: the home folder in +`config_dir`, which has your account name, and the address of a radio or a host that an +`error` can repeat. `--anonymize` masks them in the way that a card number prints as +`51xx xxxx xxxx 4571`: + +| Detail | As it is | Masked | +| --- | --- | --- | +| The account name in a home folder | `C:\Users\jpmartineau` | `C:\Users\j*********u` | +| A Bluetooth address | `DA:C5:21:6B:7C:7C` | `DA:xx:xx:xx:7C:7C` | +| An IPv4 address | `192.168.0.180` | `192.xxx.x.180` | +| The name of a network companion | `meshcore.local` | `m******e.local` | + +Each masked value keeps its first and its last characters, and its length. Thus two radios +in one report stay two different radios, and a maintainer can match the address in the +report with the address in the log. The `anonymized` fact says which form the report is. + +The **Anonymize** preference (`meshterm preferences set anonymize on`) makes the masked +report the default, and `--no-anonymize` overrides it for one run. The preference also +masks the log file, `meshterm.log`, as MeshTerm writes each line, so you can attach the +log with the report. A line that MeshTerm wrote before you turned the preference on stays +as it is. Node names, message text, and keys are not masked. + With `--json`, the five blocks of facts merge into one flat object, and the two listings keep a key of their own: @@ -1954,6 +1979,11 @@ to the report. The save writes `meshterm-diagnostics.md` into the config directo the log, and it answers in a dialog that names the path. If the write fails, the same dialog says so, in red, and the page stays readable. +`a` changes the page between the report as it is and the anonymized report, and the title +says `Diagnostics · anonymized` while the masks are visible. The page opens in the form +that the **Anonymize** preference gives, and `s` saves the form that you see. On the +PicoCalc, the key is **Shift+F3**, behind `Save`. + #### `meshterm platform` A smaller command than `diagnostics`. It shows which UI flavour a run resolves to, and diff --git a/docs/development/writing-style.md b/docs/development/writing-style.md index ffd7cb9..bfb13d3 100644 --- a/docs/development/writing-style.md +++ b/docs/development/writing-style.md @@ -281,6 +281,9 @@ and in quoted text. | save | Do an explicit save: the user's Save, or the device's save command | | | record | One stored data item | | | delete | Remove stored data permanently | purge (except the Purge feature) | +| personal detail | A value that identifies the user or the equipment of the user, but is not a secret: the account name in a home folder, the address of a radio, the address of a host | PII, private data | +| mask | Replace most characters of a personal detail, and keep its first and last characters: `j*********u`, `DA:xx:xx:xx:7C:7C` | redact, scrub, obfuscate | +| anonymize | Mask each personal detail in a text: the `anonymize` preference does it to the log and the diagnostics | | | remove | Take an item out of a collection or a text | drop, strip | | clear | Make empty | | | download | Get a file from the network | fetch | diff --git a/docs/guide/connecting.md b/docs/guide/connecting.md index cb5d050..208d04d 100644 --- a/docs/guide/connecting.md +++ b/docs/guide/connecting.md @@ -20,7 +20,9 @@ You see the same message wherever the connection fails: MeshTerm also writes each failure to the log file, `~/.meshterm/meshterm.log` (refer to [Where MeshTerm keeps its state](../cli/README.md#where-meshterm-keeps-its-state)). Attach the log file to a bug report. To get more detail, set the **Log detail** preference to -`DEBUG` (`meshterm preferences set log_level DEBUG`), and run the command again. +`DEBUG` (`meshterm preferences set log_level DEBUG`), and run the command again. The log +can show your account name and the address of your radio. To mask them as MeshTerm writes +each line, turn on the **Anonymize** preference (`meshterm preferences set anonymize on`). Sometimes MeshTerm meets a failure that it has no name for. Then the message is the error in its own words, and a second line tells where the log file is. The log has the full diff --git a/docs/guide/features.md b/docs/guide/features.md index 18cbeab..30cfd20 100644 --- a/docs/guide/features.md +++ b/docs/guide/features.md @@ -67,7 +67,7 @@ only describe it. The written pages are at the end. | Feature | What it does | Scripted | | --- | --- | --- | | **⚙ Preferences** | How MeshTerm behaves, in eight groups: Device, Sending, TX optimize, Watchtower, Map, History, Display, and Diagnostics. The page stages your changes, and you save them with one action at the bottom. MeshTerm writes only the values that you changed to `preferences.toml`. [Configuring MeshTerm](configuration.md) has the detail. | `meshterm preferences show / set / reset` | -| **🩺 Diagnostics** | One block of information that each bug report starts with. It shows which build you have and how you installed it, the operating system, the terminal and its size, and the radio and its link. It also shows the verdicts from boot time that no other screen shows (icons, powerline, platform flavour, and the reasons), and how much history MeshTerm stored. It has nothing private: no keys, passwords, positions, or contact names. It describes the mesh only as counts for each table and the span of time that the tables cover. Press `s` to save the block beside the log, to attach to a report. | `meshterm diagnostics [--out PATH]` | +| **🩺 Diagnostics** | One block of information that each bug report starts with. It shows which build you have and how you installed it, the operating system, the terminal and its size, and the radio and its link. It also shows the verdicts from boot time that no other screen shows (icons, powerline, platform flavour, and the reasons), and how much history MeshTerm stored. It has nothing private: no keys, passwords, positions, or contact names. It describes the mesh only as counts for each table and the span of time that the tables cover. Press `a` to anonymize it: your account name in the home folder and the addresses of your radio and hosts are masked, and each keeps a tidbit (`j*********u`, `DA:xx:xx:xx:7C:7C`). Press `s` to save the block beside the log, to attach to a report. The **Anonymize** preference opens the page anonymized, and masks the log too. | `meshterm diagnostics [--out PATH] [--anonymize]` | | **📖 About MeshTerm** | What MeshTerm is, where it came from, and the terms of its licence. It includes the map credit, the font credit, and the licence URL. | `meshterm about` | | **👤 About the author** | The person behind MeshTerm, on the mesh and away from it. | `meshterm about-author` | | **🔗 Join Discord** | The community server. The page has one invite link and a QR code of the link, which a phone can read. | `meshterm discord` | diff --git a/meshterm/cli.py b/meshterm/cli.py index d16e9f8..9a66ba6 100644 --- a/meshterm/cli.py +++ b/meshterm/cli.py @@ -35,6 +35,7 @@ from .core.preferences import ( Preferences, report_dropped, ) +from .core.preferences import install as install_preferences from .core.selection import DeviceSelectionError from .persistence.logging import configure_logging, get_logger, level_from_name, log_path from .persistence.repository import Repository @@ -319,6 +320,11 @@ def main_callback( # registry would be a behaviour that a user cannot find. MeshTerm sets it and never # saves it, because it is a claim about this run. prefs.set("cli_time_format", "absolute") + # The log file reads the ``anonymize`` preference at each record, through the + # process-wide set. The context installs the same set again later. If MeshTerm installs + # it only there, the records before the context (the dropped preferences below) are not + # masked when the user asked for it. + install_preferences(prefs) configure_logging( # The stdout of a scripted run carries its answer and nothing else. Thus the log # records go to stderr, and they do not mix with the data that a caller parses. diff --git a/meshterm/core/anonymize.py b/meshterm/core/anonymize.py new file mode 100644 index 0000000..ab1a5d6 --- /dev/null +++ b/meshterm/core/anonymize.py @@ -0,0 +1,259 @@ +# SPDX-License-Identifier: Apache-2.0 +r"""Anonymize a text: mask each personal detail in it, and keep a tidbit of each one. + +The log and the diagnostics block go into public bug reports. A user who reads their own +log needs each detail as it is, so MeshTerm masks nothing by default. When the +``anonymize`` preference is on, :func:`anonymize` masks three types of personal detail: + +- **The account name in a home folder.** ``C:\Users\jpmartineau`` becomes + ``C:\Users\j*********u``, and ``/home/jp`` becomes ``/home/j*``. +- **The address of a radio.** A Bluetooth address ``DA:C5:21:6B:7C:7C`` becomes + ``DA:xx:xx:xx:7C:7C``. A macOS peripheral id (a UUID) keeps its first two and its last + four hex digits in the same way. +- **The address of a host.** An IPv4 address ``192.168.0.180`` becomes ``192.xxx.x.180``. + The name of a network companion (``meshcore.local``) becomes ``m******e.local``, because + MeshTerm remembers each host that it opens (:func:`remember_host`). + +**A mask keeps a tidbit, and never removes the value** (JP, 2026-10-17). A card number +prints as ``51xx xxxx xxxx 4571``, and a masked value keeps its first and its last +characters in the same way. Thus two radios in one log stay two different radios, and a +maintainer can still match the address in a log line with the address in the +diagnostics. Also, a value keeps its length and its separators, so the line keeps its +shape. A digit masks to ``x``, the mark that a person expects in a number. A letter +masks to ``*``, because an ``x`` in a name is a letter. + +The masks are idempotent. A masked value does not match its own rule again, or it masks +to itself. Thus a text can go through :func:`anonymize` two times with no change. + +Node names, message text, and keys are not personal details in this sense, and this module +does not mask them. The diagnostics never contain them (refer to +:mod:`meshterm.tools.diagnostics`). +""" + +from __future__ import annotations + +import ipaddress +import re +import sys +import threading +from functools import lru_cache +from pathlib import Path + +#: The mark that replaces a masked letter of a name. +NAME_MASK = "*" + +#: The mark that replaces a masked digit of a number or an address. +DIGIT_MASK = "x" + +_HEX = frozenset("0123456789abcdefABCDEF") + +#: A Bluetooth address: six hex pairs, all with the same separator. The lookarounds keep a +#: longer run of hex pairs from matching in its middle. They refuse a separator only when +#: a hex digit is on its far side, because a log line can put a colon after an address +#: ("BLE companion DA:C5:21:6B:7C:7C: connected to ..."). +_MAC = re.compile( + r"(?``, ``/home/``, +#: and ``/Users/``. The separators match in each spelling: a backslash, a slash, and +#: the doubled backslash of a ``repr``. This rule also masks the short form of a long +#: account name on Windows (``JPMART~1``), which the exact rule cannot know. The account +#: can contain ``*``, so that a name that is already masked masks to itself. +_HOME_ROOT = re.compile( + r"(?:(?[^\\/\s'\"<>|:,;()\[\]]+)" +) + +#: The hosts that :func:`remember_host` registered, and the pattern that finds them. +_hosts: dict[str, str] = {} +_host_pattern: re.Pattern[str] | None = None +_hosts_lock = threading.Lock() + + +def mask_name(name: str) -> str: + """Mask a name, and keep its first and its last characters: ``j*********u``. + + The masked name has the same length as the name. A name of two characters keeps only + its first character, and a name of one character keeps nothing. + + Args: + name: The name to mask. + + Returns: + The masked name. + """ + if len(name) <= 1: + return NAME_MASK * len(name) + if len(name) == 2: + return name[0] + NAME_MASK + return name[0] + NAME_MASK * (len(name) - 2) + name[-1] + + +def mask_host(host: str) -> str: + """Mask a host: an IP address as a number, or a host name as a name. + + An IPv4 address keeps its first and its last octets (``192.xxx.x.180``). An IPv6 + address keeps its first and its last groups. A host name keeps its last label, which is + the domain that tells a maintainer what type of network it is (``.local``, ``.lan``), + and masks each other label as a name. A host name of one label is masked as a name. + + Args: + host: The host, with no port and no brackets. + + Returns: + The masked host. + """ + try: + address = ipaddress.ip_address(host) + except ValueError: + labels = host.split(".") + if len(labels) == 1: + return mask_name(host) + return ".".join([mask_name(label) for label in labels[:-1]] + [labels[-1]]) + if address.version == 4: + return _IPV4.sub(_mask_ipv4, host) + groups = host.split(":") + return ":".join([groups[0]] + [_mask_digits(group) for group in groups[1:-1]] + [groups[-1]]) + + +def remember_host(host: str) -> None: + """Register a network host, so that :func:`anonymize` masks it each time it occurs. + + No pattern can find a host name in free text. Thus MeshTerm registers each host that it + opens, and the mask finds that exact text. :class:`~meshterm.core.connection.MeshCoreDevice` + calls this function for each TCP companion. An IPv4 address needs no registration, + because its own rule finds it. + + Args: + host: A host name or an IP address. Brackets around an IPv6 address are removed. + """ + global _host_pattern + text = host.strip().strip("[]") + if not text or _IPV4.fullmatch(text): + return + with _hosts_lock: + if text.lower() in _hosts: + return + _hosts[text.lower()] = mask_host(text) + # The longest host first, so that a host never masks a part of a longer host. A + # colon can follow a host, because the port comes after it ("meshcore.local:5000"). + alternatives = "|".join(re.escape(h) for h in sorted(_hosts, key=len, reverse=True)) + _host_pattern = re.compile(rf"(? str: + """Mask each personal detail in ``text``: home folders, radio addresses, and hosts. + + Refer to the module docstring for each rule. The home folder of this machine is masked + also when it is not in the standard place (``/root``, or a folder that a user chose). + + Args: + text: Any text: a log record with its traceback, or a value of the diagnostics. + + Returns: + The text with each personal detail masked. + """ + home = _home() + if home: + text = _exact_home_rule(home, sys.platform == "win32").sub(_mask_account, text) + text = _HOME_ROOT.sub(_mask_account, text) + text = _MAC.sub(_mask_address, text) + text = _UUID.sub(_mask_address, text) + text = _IPV4.sub(_mask_ipv4, text) + hosts = _host_pattern + if hosts is not None: + text = hosts.sub(lambda m: _hosts.get(m.group(0).lower(), m.group(0)), text) + return text + + +def _home() -> str: + """The home folder of this machine, or ``""`` when the OS cannot say where it is.""" + try: + return str(Path.home()) + except (RuntimeError, KeyError, OSError): + return "" + + +@lru_cache(maxsize=4) +def _exact_home_rule(home: str, fold_case: bool) -> re.Pattern[str]: + """The pattern that finds ``home`` in a text, with the separators in each spelling. + + The last folder of the home path is the account name. Only that folder is masked, so + the masked path still shows where MeshTerm keeps its data. A home with no folder in it + (``/``) gives a pattern that never matches. + + Args: + home: The home folder of this machine. + fold_case: Whether to ignore case, because Windows paths ignore it. + + Returns: + A pattern with the group ``account``. + """ + folders = [part for part in re.split(r"[\\/]+", home) if part] + if not folders: + return re.compile(r"(?!)") + separator = r"(?:\\|/)+" + lead = separator if home[:1] in ("/", "\\") else "" + head = separator.join(re.escape(part) for part in folders[:-1]) + pattern = ( + rf"(?{re.escape(folders[-1])})(?![\w-])" + ) + return re.compile(pattern, re.IGNORECASE if fold_case else 0) + + +def _mask_account(match: re.Match[str]) -> str: + """Mask the ``account`` group of a home-folder match, and keep the rest of the match.""" + whole = match.group(0) + start = match.start("account") - match.start() + end = match.end("account") - match.start() + return whole[:start] + mask_name(match.group("account")) + whole[end:] + + +def _mask_address(match: re.Match[str]) -> str: + """Mask a radio address: keep the first two and the last four hex digits.""" + return _mask_digits(match.group(0), keep_head=2, keep_tail=4) + + +def _mask_ipv4(match: re.Match[str]) -> str: + """Mask the two middle octets of an IPv4 address, or keep a match that is not one.""" + octets = match.groups() + if any(int(octet) > 255 for octet in octets): + return match.group(0) + first, second, third, last = octets + return ".".join((first, _mask_digits(second), _mask_digits(third), last)) + + +def _mask_digits(text: str, *, keep_head: int = 0, keep_tail: int = 0) -> str: + """Replace the hex digits of ``text`` with :data:`DIGIT_MASK`, but the ones to keep. + + The separators stay where they are, so the value keeps its shape. + + Args: + text: The value to mask. + keep_head: How many digits to keep at the start. + keep_tail: How many digits to keep at the end. + + Returns: + The masked value. + """ + digits = [i for i, char in enumerate(text) if char in _HEX] + masked = set(digits[keep_head : len(digits) - keep_tail]) + return "".join(DIGIT_MASK if i in masked else char for i, char in enumerate(text)) + + +__all__ = ["DIGIT_MASK", "NAME_MASK", "anonymize", "mask_host", "mask_name", "remember_host"] diff --git a/meshterm/core/connection.py b/meshterm/core/connection.py index 278e13f..e5f90d7 100644 --- a/meshterm/core/connection.py +++ b/meshterm/core/connection.py @@ -2099,6 +2099,12 @@ class MeshCoreDevice(Device): self._ble_device = ble_device self._host = host self._tcp_port = tcp_port + if host: + # No pattern can find a host name in a log line. Thus each host that MeshTerm + # opens is registered here, and the ``anonymize`` preference can mask it. + from .anonymize import remember_host + + remember_host(host) self._mc = None # type: ignore[var-annotated] # meshcore.MeshCore #: The ``bleak.BleakClient`` of a BLE link. MeshTerm keeps it because ``meshcore`` #: releases its own reference at the moment the peripheral disappears. Refer to diff --git a/meshterm/core/preferences.py b/meshterm/core/preferences.py index f561253..382e3f5 100644 --- a/meshterm/core/preferences.py +++ b/meshterm/core/preferences.py @@ -549,6 +549,18 @@ PREFERENCES: tuple[PrefSpec, ...] = ( choices=_LOG_LEVEL_CHOICES, relaunch=True, ), + PrefSpec( + key="anonymize", + label="Anonymize", + help="Mask the home folder and addresses in the log and diagnostics", + group="Diagnostics", + value_type="bool", + # Off: the person who reads the log is usually its owner, who needs each detail as + # it is. A user who posts the log and the diagnostics in public turns it on (refer + # to meshterm.core.anonymize). The log reads it at each record, so a change applies + # at once and not at the next start. + default=False, + ), ) _BY_KEY: dict[str, PrefSpec] = {spec.key: spec for spec in PREFERENCES} diff --git a/meshterm/persistence/logging.py b/meshterm/persistence/logging.py index b1a6569..50bc23c 100644 --- a/meshterm/persistence/logging.py +++ b/meshterm/persistence/logging.py @@ -18,6 +18,12 @@ space on a handheld, and it is too large to attach to a bug report. A preference sets how much MeshTerm writes: ``log_level``, with the default ``WARNING``. Thus the file has the problems, not a full account of a session that works. When you look for the cause of a problem, set it lower, to ``INFO`` or ``DEBUG``. + +A second preference, ``anonymize``, masks the personal details of each line before it +goes into the file: the account name in a home folder, the address of a radio, and the +address of a host (refer to :mod:`meshterm.core.anonymize`). The mask applies as each line +is written, so the file never holds what it masks. A line that was written before the +preference was turned on stays as it is. """ from __future__ import annotations @@ -146,7 +152,7 @@ def configure_logging( encoding="utf-8", ) file_handler.setLevel(file_level) - file_handler.setFormatter(logging.Formatter(_LINE_FORMAT, datefmt=_TIME_FORMAT)) + file_handler.setFormatter(_FileFormatter(_LINE_FORMAT, datefmt=_TIME_FORMAT)) logger.addHandler(file_handler) _quiet_library_console(file_handler) @@ -154,6 +160,26 @@ def configure_logging( return logger +class _FileFormatter(logging.Formatter): + """The line format of the file, with the personal details masked when the user asks. + + The formatter masks the whole formatted text: the message, and the traceback under + it. A traceback holds the most home folders, because each frame names its file. The + formatter reads the ``anonymize`` preference at each record, so a change on the + Preferences page applies to the next line. The mask is a function of the text only, + so it does not change the record. The console handler gets the same record and shows + it as it is, because the console belongs to the user who reads it. + """ + + def format(self, record: logging.LogRecord) -> str: + """Format ``record`` as one line (and its traceback), masked if ``anonymize`` is on.""" + from ..core.anonymize import anonymize + from ..core.preferences import current + + text = super().format(record) + return anonymize(text) if current().anonymize else text + + #: Third-party loggers that must never write to the terminal, because their records print #: over the full-screen TUI. This module routes them to the file handler instead, so that #: the records stay available for debugging. ``meshcore`` is the main cause: it calls diff --git a/meshterm/tools/diagnostics.py b/meshterm/tools/diagnostics.py index 6b7bb3c..56c4862 100644 --- a/meshterm/tools/diagnostics.py +++ b/meshterm/tools/diagnostics.py @@ -28,10 +28,18 @@ The tool asks the device, but the device is not necessary. A report about a radi does not connect is the report that it is most necessary to file. Thus, when the tool cannot get to the device, that failure becomes a fact in the block (``connected`` and ``error``), and not an error that replaces the block. + +**Anonymized on request.** Some facts are about the machine of the user, and not about a +secret: the home folder in ``config_dir``, and the address of a radio or a host that an +``error`` can repeat. The ``anonymize`` preference masks them, and keeps a tidbit of each +one (refer to :mod:`meshterm.core.anonymize`). ``--anonymize`` and ``--no-anonymize`` +override the preference for one run, and the page has a key that does the same. The +``anonymized`` fact tells the person who reads the report which form it is. """ from __future__ import annotations +from dataclasses import replace from datetime import datetime from pathlib import Path from typing import TYPE_CHECKING, Any @@ -43,6 +51,9 @@ from .base import Tool, ToolResult, register if TYPE_CHECKING: # pragma: no cover - typing only from ..ui.report import Report +#: The fact that tells whether the personal details of the report are masked. +ANONYMIZED = "anonymized" + #: The name of the saved file, in the config directory, next to the log that it goes with #: in a bug report. The name includes the app name, and is not only ``diagnostics.md``. #: The reason: the only thing that occurs to this file is that a person moves it to a @@ -63,13 +74,17 @@ class DiagnosticsTool(Tool): order = 7 # after Preferences (the row that changes MeshTerm), and before the pages def register_cli(self, app: Any) -> None: - """Register the command, with the one option that changes the answer into a file. + """Register the command, with the option that writes a file and the mask override. ``--out`` has the same spelling as ``config export-key --out``. We did not make a second word to write an answer to a path. Plain redirection continues to work, and the option does not replace it. The option adds a file whose columns have the full width, and not the width that the terminal had at that time. That is the difference between an attachment that a person can read and a folded one. + + ``--anonymize`` and ``--no-anonymize`` override the ``anonymize`` preference for + this run, in the way that ``--absolute`` overrides ``cli_time_format``. With + neither, the preference decides. """ import typer @@ -80,8 +95,13 @@ class DiagnosticsTool(Tool): out: Path | None = typer.Option( None, "--out", help="Write the block to this file instead of printing it" ), + anonymize: bool | None = typer.Option( + None, + "--anonymize/--no-anonymize", + help="Mask the home folder and addresses (default: the anonymize preference)", + ), ) -> None: - run_tool_command(self, {"out": out}) + run_tool_command(self, {"out": out, "anonymize": anonymize}) async def prompt_params(self, ctx: AppContext) -> dict[str, Any] | None: """Open the page in the menu. The return value ``None`` ends with no run row. @@ -91,6 +111,10 @@ class DiagnosticsTool(Tool): person looked at their own version number, the log gets a line for each look. A save from the page is also not a run. + The page gets both forms of the document, so that its key can change from one to + the other with no second read of the device. It opens in the form that the + ``anonymize`` preference gives. + Args: ctx: The shared application context. @@ -99,8 +123,15 @@ class DiagnosticsTool(Tool): """ from ..ui.diagnostics import open_diagnostics_page - report = await build_report(ctx) - await open_diagnostics_page(ctx, markdown_source(report), save_path=default_path(ctx)) + report = await build_report(ctx, anonymize=False) + when = datetime.now().astimezone() + await open_diagnostics_page( + ctx, + markdown_source(report, when=when), + save_path=default_path(ctx), + anonymized_source=markdown_source(anonymized(report), when=when), + anonymized=_anonymizes(ctx), + ) return None async def run(self, ctx: AppContext, params: dict[str, Any]) -> ToolResult: @@ -113,11 +144,13 @@ class DiagnosticsTool(Tool): Args: ctx: The shared application context. params: ``out``: a destination path, or ``None`` to print the block. + ``anonymize``: ``True`` or ``False`` to override the preference, or + ``None`` to obey it. Returns: A :class:`ToolResult` that holds the block, or the path of the written file. """ - report = await build_report(ctx) + report = await build_report(ctx, anonymize=params.get("anonymize")) out = params.get("out") if out is None: return ToolResult(summary={"tool": self.name}, report=report) @@ -131,7 +164,7 @@ class DiagnosticsTool(Tool): ) -async def build_report(ctx: AppContext) -> Report: +async def build_report(ctx: AppContext, *, anonymize: bool | None = None) -> Report: """Collect all the diagnostic facts, as the report that the two faces render. Seven blocks, in the order that is most useful to the user: what this program is, the @@ -147,11 +180,13 @@ async def build_report(ctx: AppContext) -> Report: Args: ctx: The shared application context. + anonymize: ``True`` to mask the personal details (refer to :func:`anonymized`), + ``False`` to keep them, or ``None`` to obey the ``anonymize`` preference. Returns: The report of seven blocks. """ - return ( + report = ( _app_facts(), _host_facts(), _terminal_facts(), @@ -160,6 +195,55 @@ async def build_report(ctx: AppContext) -> Report: _table_listing(ctx), _preference_listing(ctx), ) + masks = _anonymizes(ctx) if anonymize is None else anonymize + return anonymized(report) if masks else report + + +def anonymized(report: Report) -> Report: + """The same report, with each personal detail masked and the ``anonymized`` fact on. + + The mask goes through each text value of each block, and does not choose fields. A + field that someone adds later is masked on the day that it arrives, and nobody must + remember to add it to a list. A value keeps its type: a path stays a + :class:`~pathlib.Path`, so its lane renders it as before. Each value that is not text + (a count, a flag, an instant) stays as it is. + + Args: + report: A report from :func:`build_report`. + + Returns: + The masked report. ``report`` itself does not change. + """ + from ..core.anonymize import anonymize + from ..ui.report import Facts, Listing + + def mask(value: Any) -> Any: + if isinstance(value, Path): + return Path(anonymize(str(value))) + if isinstance(value, str): + return anonymize(value) + return value + + blocks: list[Any] = [] + for block in report: + if isinstance(block, Facts): + values = {key: mask(value) for key, value in block.values.items()} + if ANONYMIZED in values: + values[ANONYMIZED] = True + blocks.append(replace(block, values=values)) + elif isinstance(block, Listing): + rows = [{key: mask(value) for key, value in row.items()} for row in block.rows] + blocks.append(replace(block, rows=rows)) + else: + blocks.append(block) + return tuple(blocks) + + +def _anonymizes(ctx: AppContext) -> bool: + """Whether the ``anonymize`` preference asks for the masked report.""" + from ..core.preferences import current + + return bool((ctx.preferences or current()).anonymize) def markdown_source(report: Report, *, when: datetime | None = None) -> str: @@ -443,6 +527,7 @@ def _mesh_facts(ctx: AppContext) -> Any: caption="Storage", fields=( fields.path("config_dir", "config_dir"), + fields.flag(ANONYMIZED, ANONYMIZED), fields.integer("db_size_kb", "db_size_kb"), fields.word("log_level", "log_level"), fields.integer("log_size_kb", "log_size_kb"), @@ -452,6 +537,9 @@ def _mesh_facts(ctx: AppContext) -> Any: ), values={ "config_dir": ctx.settings.config_dir, + # Next to the value that it changes most. :func:`anonymized` sets it, so that a + # maintainer who reads `j*********u` knows that it is a mask and not a fault. + ANONYMIZED: False, "db_size_kb": _size_kb(db_path), "log_level": preferences.log_level, # How much the log holds. Thus a maintainer who asks for it knows what to expect. diff --git a/meshterm/ui/diagnostics.py b/meshterm/ui/diagnostics.py index 1eab1f2..e218281 100644 --- a/meshterm/ui/diagnostics.py +++ b/meshterm/ui/diagnostics.py @@ -25,6 +25,13 @@ page never shows it in its own body, because the body belongs to the report. The answers in a dialog, because the answer is a path that the user may have to read carefully, and because a write can fail. The page under the dialog does not change, and it is still there when the dialog closes. + +A second verb, **anonymize** (:data:`ANONYMIZE_KEY`), changes the page between the +report as it is and the report with its personal details masked. It is the menu half of +``meshterm diagnostics --anonymize``. The ``anonymize`` preference chooses the form in +which the page opens, and the key changes only this visit. The save writes the form that +is visible, so the user saves what the user reads. The title says ``anonymized`` while the +masks are visible. """ from __future__ import annotations @@ -50,17 +57,34 @@ SAVE_KEY = "s" #: is not a second implementation of the keyboard key. SAVE_ACTION = "save" +#: The keyboard key that changes the page between the report and the anonymized report. A +#: bare letter, for the same reason as :data:`SAVE_KEY`. +ANONYMIZE_KEY = "a" + +#: The action that :data:`ANONYMIZE_KEY` and its chip on the lane both dispatch. +ANONYMIZE_ACTION = "anonymize" + +_TITLE = "Diagnostics" + class DiagnosticsPage(AboutPage): - """The written diagnostics page, plus the one verb that writes it to a file. + """The written diagnostics page, plus the verbs that anonymize it and write it to a file. All of the behaviour for reading comes from :class:`~meshterm.ui.about.AboutPage`: the pager, the sticky ``##`` headings, the section jumps, and the lane that dims when there - is nothing to page. This class adds only the save, on the one slot that the About pages - leave free. + is nothing to page. This class adds the save, on the one slot that the About pages + leave free, and the anonymize key, on the Shift half of that slot. """ - def __init__(self, source: str, *, session: TuiSession, save_path: Path) -> None: + def __init__( + self, + source: str, + *, + session: TuiSession, + save_path: Path, + anonymized_source: str | None = None, + anonymized: bool = False, + ) -> None: """Show ``source`` as the page, and save it to ``save_path`` when the user asks. Args: @@ -68,24 +92,48 @@ class DiagnosticsPage(AboutPage): :func:`~meshterm.tools.diagnostics.markdown_source`. session: The session, for the dialog in which the save answers. save_path: The file to which :data:`SAVE_KEY` writes. + anonymized_source: The same document with its personal details masked, from + :func:`~meshterm.tools.diagnostics.anonymized`. ``None`` gives a page with + no anonymize key. + anonymized: Whether the page opens on ``anonymized_source``. """ - super().__init__("Diagnostics", render_markdown(source)) - self._source = source + self._sources = {False: source, True: anonymized_source} + self._anonymized = anonymized and anonymized_source is not None + super().__init__(self._title(), render_markdown(self._source)) self._session = session self._save_path = save_path self._saving = False @property - def footer_hint(self) -> str: # type: ignore[override] - """The keys of the read-only page, with the save between the pager and Esc. + def _source(self) -> str: + """The document that the page shows now, and that the save writes.""" + return self._sources[self._anonymized] or self._sources[False] or "" - The hint has the standard sentence shape: navigation first, then the action, then + @property + def _can_anonymize(self) -> bool: + """Whether the page has the second form of the document to change to.""" + return self._sources[True] is not None + + def _title(self) -> str: + """The title, with ``· anonymized`` while the masks are visible.""" + return f"{_TITLE} · anonymized" if self._anonymized else _TITLE + + @property + def footer_hint(self) -> str: # type: ignore[override] + """The keys of the read-only page, with the verbs between the pager and Esc. + + The hint has the standard sentence shape: navigation first, then the actions, then Esc. The pager atom is present only while :attr:`~Screen.content_overflows` is true, as on each read-only page. Thus the line never names a keyboard key that does - nothing. + nothing. The anonymize atom names what the key does next: ``anonymize`` on the + report as it is, and ``reveal`` on the masked report. """ scroll = "↑↓ PgUp/PgDn scroll · " if self.content_overflows else "" - return f"{scroll}{SAVE_KEY} save · Esc back" + toggle = "" + if self._can_anonymize: + verb = "reveal" if self._anonymized else "anonymize" + toggle = f"{ANONYMIZE_KEY} {verb} · " + return f"{scroll}{toggle}{SAVE_KEY} save · Esc back" @property def picocalc_lyra_lane(self): @@ -93,21 +141,41 @@ class DiagnosticsPage(AboutPage): F1/F2 are the section pair and F4/F5 are the pager. Thus F3 is the one free primary slot. On this platform, the lane is the only place that can show a bare-letter - keyboard key. Thus the chip is necessary, and not only a decoration. + keyboard key. Thus the chip is necessary, and not only a decoration. The anonymize + key goes on the Shift half of the same slot, because the two verbs are both about + what leaves the machine. Its label names what the chip does next. """ from .tui.fkeys import FPair lane = list(super().picocalc_lyra_lane) - lane[2] = FPair("Save", SAVE_ACTION) + if self._can_anonymize: + label = "Reveal" if self._anonymized else "Anon" + lane[2] = FPair("Save", SAVE_ACTION, label, ANONYMIZE_ACTION) + else: + lane[2] = FPair("Save", SAVE_ACTION) return lane def handle(self, action: str, data: str = "") -> None: - """Save on the keyboard key or the chip. Scroll, jump, or leave on all other actions.""" - if action == SAVE_ACTION or (action == "text" and data.lower() == SAVE_KEY): + """Save or anonymize on a keyboard key or a chip. Scroll, jump, or leave on all others.""" + typed = data.lower() if action == "text" else "" + if action == SAVE_ACTION or typed == SAVE_KEY: self._begin_save() return + if self._can_anonymize and (action == ANONYMIZE_ACTION or typed == ANONYMIZE_KEY): + self._toggle_anonymized() + return super().handle(action, data) + def _toggle_anonymized(self) -> None: + """Show the other form of the document, at the same scroll position. + + Both forms have the same lines in the same order, because a mask keeps the length + of each value. Thus the user stays at the same place in the page. + """ + self._anonymized = not self._anonymized + self._doc = render_markdown(self._source) + self.title = self._title() + def _begin_save(self) -> None: """Write the file and report the result in a dialog, outside the key handler. @@ -159,13 +227,22 @@ class DiagnosticsPage(AboutPage): return message -async def open_diagnostics_page(ctx: AppContext, source: str, *, save_path: Path) -> None: +async def open_diagnostics_page( + ctx: AppContext, + source: str, + *, + save_path: Path, + anonymized_source: str | None = None, + anonymized: bool = False, +) -> None: """Open the Diagnostics page full-screen, and keep it until the user leaves it. Args: ctx: The shared application context. The interactive TUI must be in use. source: The markdown document to show and to save. save_path: The file to which the save key of the page writes. + anonymized_source: The same document with its personal details masked. + anonymized: Whether the page opens on ``anonymized_source``. Raises: RuntimeError: If the call is outside the interactive menu (there is no @@ -176,7 +253,21 @@ async def open_diagnostics_page(ctx: AppContext, source: str, *, save_path: Path if not isinstance(ctx.ui, TuiUi): # pragma: no cover - the menu-only caller guards this raise RuntimeError("the Diagnostics page is only available in the menu") session = ctx.ui.session - await session.run_screen(DiagnosticsPage(source, session=session, save_path=save_path)) + page = DiagnosticsPage( + source, + session=session, + save_path=save_path, + anonymized_source=anonymized_source, + anonymized=anonymized, + ) + await session.run_screen(page) -__all__ = ["SAVE_ACTION", "SAVE_KEY", "DiagnosticsPage", "open_diagnostics_page"] +__all__ = [ + "ANONYMIZE_ACTION", + "ANONYMIZE_KEY", + "SAVE_ACTION", + "SAVE_KEY", + "DiagnosticsPage", + "open_diagnostics_page", +] diff --git a/tests/test_anonymize.py b/tests/test_anonymize.py new file mode 100644 index 0000000..6d02367 --- /dev/null +++ b/tests/test_anonymize.py @@ -0,0 +1,203 @@ +# 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") diff --git a/tests/test_diagnostics.py b/tests/test_diagnostics.py index db8c142..09d5792 100644 --- a/tests/test_diagnostics.py +++ b/tests/test_diagnostics.py @@ -465,6 +465,123 @@ def test_the_observation_span_is_absent_until_something_is_heard(tmp_path: Path) assert repo.failed_run_count() == 0 +# --- anonymized ------------------------------------------------------------------------- + +#: A Bluetooth address, in the words of a radio that failed to connect. +_RADIO = "DA:C5:21:6B:7C:7C" + + +@pytest.fixture +def home(ctx: AppContext, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + """Put the config directory in a home folder with an account name, on each OS. + + The radio refuses with its Bluetooth address in the error, so that each type of + personal detail is in the report. + """ + from meshterm.core import anonymize + + folder = tmp_path / "jpmartineau" + monkeypatch.setattr(anonymize, "_home", lambda: str(folder)) + ctx.settings.config_dir = folder / ".meshterm" + + async def refuse(self): + raise RuntimeError(f"BLE link to {_RADIO} failed to open") + + monkeypatch.setattr(AppContext, "device", refuse) + return folder + + +def test_the_report_keeps_each_detail_by_default(ctx: AppContext, home: Path) -> None: + """With the preference off, the report is as it was: the owner's own facts, unmasked.""" + values = _flat(_report(ctx)) + assert values["anonymized"] is False + assert "jpmartineau" in str(values["config_dir"]) + assert _RADIO in values["error"] + + +def test_the_preference_masks_the_home_folder_and_the_radio(ctx: AppContext, home: Path) -> None: + """With the preference on, both faces mask each detail, and keep a tidbit of it.""" + ctx.preferences.set("anonymize", True) + report = _report(ctx) + values = _flat(report) + assert values["anonymized"] is True + assert values["config_dir"].name == ".meshterm" + assert "j*********u" in str(values["config_dir"]) + assert "DA:xx:xx:xx:7C:7C" in values["error"] + for face in (_plain(report), _machine(report), markdown_source(report)): + assert "jpmartineau" not in face + assert _RADIO not in face + + +def test_an_override_wins_over_the_preference(ctx: AppContext, home: Path) -> None: + """``--anonymize`` and ``--no-anonymize`` decide for one run, in each direction.""" + assert _flat(asyncio.run(build_report(ctx, anonymize=True)))["anonymized"] is True + ctx.preferences.set("anonymize", True) + assert _flat(asyncio.run(build_report(ctx, anonymize=False)))["anonymized"] is False + + +def test_the_command_line_takes_the_override( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """The flag pair is on the command, and the preference decides when neither is given.""" + import json + + from typer.testing import CliRunner + + from meshterm.cli import app + + monkeypatch.setenv("MESHTERM_HOME", str(tmp_path / "home")) + + def anonymized(*args: str) -> bool: + result = CliRunner().invoke(app, ["--mock", "--json", "diagnostics", *args]) + assert result.exit_code == 0, result.output + return json.loads(result.stdout)["anonymized"] + + assert anonymized() is False + assert anonymized("--anonymize") is True + set_it = CliRunner().invoke(app, ["preferences", "set", "anonymize", "on"]) + assert set_it.exit_code == 0, set_it.output + assert anonymized() is True + assert anonymized("--no-anonymize") is False + + +def test_the_page_key_changes_the_form_and_the_save_writes_it( + ctx: AppContext, home: Path, tmp_path: Path +) -> None: + """The key shows the other form, the title and the hint say which, and Save writes it.""" + from meshterm.ui.diagnostics import ANONYMIZE_KEY, SAVE_KEY + + path = tmp_path / "out.md" + page = _page(ctx, save_path=path, anonymizable=True) + assert page.title == "Diagnostics" + assert f"{ANONYMIZE_KEY} anonymize" in page.footer_hint + + page.handle("text", ANONYMIZE_KEY) + assert page.title == "Diagnostics · anonymized" + assert f"{ANONYMIZE_KEY} reveal" in page.footer_hint + assert "jpmartineau" not in _body(page) + page.handle("text", SAVE_KEY) + saved = path.read_text(encoding="utf-8") + assert "j*********u" in saved and "jpmartineau" not in saved + + page.handle("text", ANONYMIZE_KEY) + assert page.title == "Diagnostics" + # The document and not the drawn lines: a long temporary path can wrap in the name. + assert "jpmartineau" in page._source + + +def test_the_page_opens_in_the_form_of_the_preference(ctx: AppContext, home: Path) -> None: + """The preference chooses the first form, and the chip on the lane names the other one.""" + from meshterm.ui.diagnostics import ANONYMIZE_ACTION + + page = _page(ctx, anonymizable=True, anonymized=True) + assert page.title == "Diagnostics · anonymized" + chip = page.picocalc_lyra_lane[2] + assert (chip.label, chip.opp_label, chip.opp_action) == ("Save", "Reveal", ANONYMIZE_ACTION) + page.handle(ANONYMIZE_ACTION) + assert page.picocalc_lyra_lane[2].opp_label == "Anon" + + # --- helpers --------------------------------------------------------------------------- @@ -576,13 +693,29 @@ class _Session: self.shown.append(message) -def _page(ctx: AppContext, *, session: _Session | None = None, save_path: Path | None = None): - """The page for the report of this context. It saves where the caller says.""" +def _page( + ctx: AppContext, + *, + session: _Session | None = None, + save_path: Path | None = None, + anonymizable: bool = False, + anonymized: bool = False, +): + """The page for the report of this context. It saves where the caller says. + + With ``anonymizable``, the page also gets the anonymized form, as the tool gives it. + """ + from meshterm.tools.diagnostics import anonymized as masked from meshterm.ui.diagnostics import DiagnosticsPage target = save_path or (ctx.settings.config_dir / "meshterm-diagnostics.md") + report = asyncio.run(build_report(ctx, anonymize=False)) return DiagnosticsPage( - markdown_source(_report(ctx)), session=session or _Session(), save_path=target + markdown_source(report), + session=session or _Session(), + save_path=target, + anonymized_source=markdown_source(masked(report)) if anonymizable else None, + anonymized=anonymized, ) diff --git a/tests/test_gallery.py b/tests/test_gallery.py index 04a6ed7..560721f 100644 --- a/tests/test_gallery.py +++ b/tests/test_gallery.py @@ -1621,20 +1621,35 @@ def _diagnostics(cols: int, rows: int) -> Screen: item hangs its wrapped text under itself at any width, and this must work in 53 columns. """ + return _diagnostics_page(cols, rows, anonymized=False) + + +def _diagnostics_anonymized(cols: int, rows: int) -> Screen: + """The Diagnostics page with its masks visible: a longer title, and the other chip.""" + return _diagnostics_page(cols, rows, anonymized=True) + + +def _diagnostics_page(cols: int, rows: int, *, anonymized: bool) -> Screen: + """The Diagnostics page with both forms of the document, as the tool opens it.""" + from datetime import datetime + + from meshterm.tools.diagnostics import anonymized as masked + from meshterm.tools.diagnostics import markdown_source from meshterm.ui.diagnostics import DiagnosticsPage + report = _diagnostics_report() + when = datetime(2026, 9, 19, 17, 0) return DiagnosticsPage( - _diagnostics_source(), + markdown_source(report, when=when), session=_GallerySession(cols, rows), save_path=Path("C:/Users/somebody/.meshterm/meshterm-diagnostics.md"), + anonymized_source=markdown_source(masked(report), when=when), + anonymized=anonymized, ) -def _diagnostics_source() -> str: - """A typical diagnostics document, built in the way that the tool builds one.""" - from datetime import datetime - - from meshterm.tools.diagnostics import markdown_source +def _diagnostics_report(): + """A typical diagnostics report, with the widest values that a real report can have.""" from meshterm.ui import fields from meshterm.ui.report import Column, Facts, Lane, Listing @@ -1691,7 +1706,7 @@ def _diagnostics_source() -> str: rows=[{"preference": "log_level", "value": "DEBUG"}], ), ) - return markdown_source(report, when=datetime(2026, 9, 19, 17, 0)) + return report def _quit_hold(cols: int, rows: int) -> Screen: @@ -1789,6 +1804,7 @@ _ENTRIES: list[_Entry] = [ _Entry("share_qr", _share_qr), _Entry("links_qr", _links_qr), _Entry("diagnostics", _diagnostics), + _Entry("diagnostics-anonymized", _diagnostics_anonymized), ] #: The combinations of (platform, cols, rows) under which each entry above renders. The