From 508af1fc27c7ef5a38afa3703a0a40dfd1e38c33 Mon Sep 17 00:00:00 2001 From: Gustavo Armelin Date: Fri, 14 Aug 2026 20:28:03 -0300 Subject: [PATCH] fix(ui): escapar markup Rich em texto que vem do sistema SSID, processo, serial, path e detalhe de security/dev entram em tags do Rich. Um `[/]` nesses valores virava MarkupError e derrubava o painel. safe_text escapa antes de interpolar. --- CHANGELOG.md | 4 ++++ macmanager/battery.py | 6 +++--- macmanager/dev.py | 6 +++--- macmanager/disk.py | 4 ++-- macmanager/network.py | 16 ++++++++-------- macmanager/security.py | 6 +++--- macmanager/system.py | 4 ++-- macmanager/ui.py | 12 ++++++++++++ macmanager/watch.py | 4 ++-- tests/test_network.py | 34 +++++++++++++++++++++++++++++++++- tests/test_system.py | 29 ++++++++++++++++++++++++++++- tests/test_ui.py | 32 +++++++++++++++++++++++++++++++- 12 files changed, 131 insertions(+), 26 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ad2349..73b14a7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -52,6 +52,10 @@ and this project adheres to [Semantic Versioning 2.0.0](https://semver.org/spec/ - Version badge in README bumped to 0.1.1. ### Fixed +- **SSID, processo, serial e paths com `[` / `]` não crasham mais a UI.** + Texto que vem do sistema passa por `safe_text` (escape do Rich) antes + de entrar em markup — um Wi-Fi `My[/Home]WiFi` ou um processo + `foo[/]bar` deixam de gerar `MarkupError`. - **`mm` e `mm watch` não morrem inteiros** quando um único coletor falha. Cada painel passa por `safe_panel`: a exceção vira um fallback ("Não foi possível coletar estes dados") e os outros painéis seguem. diff --git a/macmanager/battery.py b/macmanager/battery.py index 76abf7d..283c566 100644 --- a/macmanager/battery.py +++ b/macmanager/battery.py @@ -11,7 +11,7 @@ from rich.table import Table from macmanager.cache import cached -from macmanager.ui import bar, console, fmt_seconds, health_color, usage_color +from macmanager.ui import bar, console, fmt_seconds, health_color, safe_text, usage_color @dataclass @@ -168,7 +168,7 @@ def render_battery_panel(info: Optional[BatteryInfo] = None) -> Panel: t.add_row( "Charge", f"[{pct_color}]{info.percent:.1f}%[/] {bar(info.percent, color=pct_color)}" ) - t.add_row("State", f"{state} · source: [bold]{info.power_source}[/]") + t.add_row("State", f"{state} · source: [bold]{safe_text(info.power_source)}[/]") if ( info.time_remaining_sec is not None and info.time_remaining_sec > 0 @@ -190,7 +190,7 @@ def render_battery_panel(info: Optional[BatteryInfo] = None) -> Panel: f"[{usage_color(info.temperature_c, warn=35, crit=40)}]{info.temperature_c:.1f}°C[/]", ) if info.serial: - t.add_row("Serial", f"[dim]{info.serial}[/]") + t.add_row("Serial", f"[dim]{safe_text(info.serial)}[/]") return Panel(t, title="[bold]Battery[/]", border_style="cyan") diff --git a/macmanager/dev.py b/macmanager/dev.py index 57f9378..c799604 100644 --- a/macmanager/dev.py +++ b/macmanager/dev.py @@ -20,7 +20,7 @@ from rich.panel import Panel from rich.table import Table -from macmanager.ui import console +from macmanager.ui import console, safe_text @dataclass @@ -249,7 +249,7 @@ def render_dev_panels( summary.add_row("Detected", f"[green]{total_found}[/] of {total} known tools") git_id = _git_identity() if git_id: - summary.add_row("Git identity", git_id) + summary.add_row("Git identity", safe_text(git_id)) panels.append(Panel(summary, title="[bold]Summary[/]", border_style="cyan")) @@ -266,7 +266,7 @@ def render_dev_panels( t.add_column(style="dim") for tool in found: - t.add_row(tool.name, tool.version or "?", tool.path or "") + t.add_row(tool.name, safe_text(tool.version or "?"), safe_text(tool.path or "")) if show_missing and missing: t.add_row("", "", "") diff --git a/macmanager/disk.py b/macmanager/disk.py index decf66c..d0379b1 100644 --- a/macmanager/disk.py +++ b/macmanager/disk.py @@ -13,7 +13,7 @@ from rich.table import Table from macmanager.cache import cached -from macmanager.ui import bar, console, fmt_bytes, usage_color +from macmanager.ui import bar, console, fmt_bytes, safe_text, usage_color CLEAN_TARGETS = [ "~/Library/Caches", @@ -119,7 +119,7 @@ def render_disk_panel(info: Optional[DiskInfo] = None, full: bool = True) -> Pan total = 0 for path, size in info.cleanup: total += size - t.add_row(f"[dim]{path}[/]", f"{fmt_bytes(size)}") + t.add_row(f"[dim]{safe_text(path)}[/]", f"{fmt_bytes(size)}") t.add_row("", f"[bold cyan]Total recoverable: {fmt_bytes(total)}[/]") t.add_row( "", diff --git a/macmanager/network.py b/macmanager/network.py index bd74008..5fa0a76 100644 --- a/macmanager/network.py +++ b/macmanager/network.py @@ -13,7 +13,7 @@ from rich.table import Table from macmanager.cache import cached -from macmanager.ui import console +from macmanager.ui import console, safe_text @dataclass @@ -144,18 +144,18 @@ def render_network_panel(info: Optional[NetworkInfo] = None) -> Panel: t.add_column(justify="right", style="dim") t.add_column() - t.add_row("Interface", info.interface) - t.add_row("Local IP", info.local_ip or "[dim]—[/]") - t.add_row("Public IP", info.public_ip or "[dim]offline[/]") + t.add_row("Interface", safe_text(info.interface)) + t.add_row("Local IP", safe_text(info.local_ip) or "[dim]—[/]") + t.add_row("Public IP", safe_text(info.public_ip) or "[dim]offline[/]") if info.ssid: t.add_row("", "") - t.add_row("Wi-Fi", f"[bold]{info.ssid}[/]") + t.add_row("Wi-Fi", f"[bold]{safe_text(info.ssid)}[/]") if info.security: - t.add_row("Security", info.security) + t.add_row("Security", safe_text(info.security)) if info.channel: - t.add_row("Channel", info.channel) + t.add_row("Channel", safe_text(info.channel)) if info.tx_rate: - t.add_row("TX Rate", f"{info.tx_rate} Mbps") + t.add_row("TX Rate", f"{safe_text(info.tx_rate)} Mbps") if info.rssi is not None: label, color = _signal_quality(info.rssi) t.add_row("Signal", f"[{color}]{info.rssi} dBm · {label}[/]") diff --git a/macmanager/security.py b/macmanager/security.py index 6a4d6e8..3400fdd 100644 --- a/macmanager/security.py +++ b/macmanager/security.py @@ -17,7 +17,7 @@ from rich.table import Table from macmanager.cache import cached -from macmanager.ui import console, fmt_seconds, health_color +from macmanager.ui import console, fmt_seconds, health_color, safe_text OK, WARN, FAIL, INFO = "OK", "WARN", "FAIL", "INFO" @@ -320,9 +320,9 @@ def render_security_panel(checks: Optional[list[Check]] = None) -> Panel: t.add_column() for c in checks: - line = c.detail + line = safe_text(c.detail) if c.tip: - line += f" [dim]· {c.tip}[/]" + line += f" [dim]· {safe_text(c.tip)}[/]" t.add_row(STATUS_STYLE.get(c.status, c.status), c.name, line) return Panel( diff --git a/macmanager/system.py b/macmanager/system.py index a21528d..c57bc6e 100644 --- a/macmanager/system.py +++ b/macmanager/system.py @@ -13,7 +13,7 @@ from rich.table import Table from macmanager.cache import cached -from macmanager.ui import bar, console, fmt_bytes, fmt_seconds, usage_color +from macmanager.ui import bar, console, fmt_bytes, fmt_seconds, safe_text, usage_color @dataclass @@ -148,7 +148,7 @@ def render_system_panel(info: Optional[SystemInfo] = None) -> Panel: t.add_row("[bold]Top processes[/]", "") for p in info.top_processes: t.add_row( - f"[dim]{p['pid']:>5}[/] {p['name'][:25]}", + f"[dim]{p['pid']:>5}[/] {safe_text(p['name'][:25])}", f"CPU [bold]{p['cpu']:5.1f}%[/] MEM [bold]{fmt_bytes(p['mem'])}[/]", ) diff --git a/macmanager/ui.py b/macmanager/ui.py index 5099360..7775bd0 100644 --- a/macmanager/ui.py +++ b/macmanager/ui.py @@ -5,6 +5,7 @@ from collections.abc import Callable from rich.console import Console +from rich.markup import escape from rich.panel import Panel console = Console() @@ -31,6 +32,17 @@ def safe_panel( ) +def safe_text(value: object) -> str: + """Escapa markup Rich em texto que veio do sistema (SSID, processo, path). + + Sem isso, um SSID `My[/Home]WiFi` ou um processo `foo[/]bar` vira + `MarkupError` e derruba o painel. + """ + if value is None: + return "" + return escape(str(value)) + + def fmt_bytes(num: float) -> str: """Format bytes in KB/MB/GB/TB in a readable way.""" for unit in ("B", "KB", "MB", "GB", "TB"): diff --git a/macmanager/watch.py b/macmanager/watch.py index ccef6d0..49d39e8 100644 --- a/macmanager/watch.py +++ b/macmanager/watch.py @@ -33,7 +33,7 @@ from macmanager.network import render_network_panel from macmanager.security import check_macos_updates, run_all as run_security from macmanager.system import render_system_panel -from macmanager.ui import console, health_color, safe_panel +from macmanager.ui import console, health_color, safe_panel, safe_text def _build_layout() -> Layout: @@ -99,7 +99,7 @@ def _render_health_bar() -> Panel: count = upd.detail.split(" ")[0] upd_str = f"[yellow]{count} pending[/]" else: - upd_str = f"[dim]{upd.detail}[/]" + upd_str = f"[dim]{safe_text(upd.detail)}[/]" except Exception: upd_str = "[dim]?[/]" diff --git a/tests/test_network.py b/tests/test_network.py index 392f71b..7b43618 100644 --- a/tests/test_network.py +++ b/tests/test_network.py @@ -4,12 +4,21 @@ import subprocess from collections.abc import Iterator +from io import StringIO from types import SimpleNamespace import pytest +from rich.console import Console from macmanager.cache import clear_all -from macmanager.network import _local_ip, _run, _signal_quality, _wifi_info +from macmanager.network import ( + NetworkInfo, + _local_ip, + _run, + _signal_quality, + _wifi_info, + render_network_panel, +) class TestSignalQuality: @@ -121,3 +130,26 @@ def test_parses_ssid_from_profiler_block(self, monkeypatch: pytest.MonkeyPatch) assert info["security"] == "WPA3 Personal" assert info["rssi"] == -55 assert info["tx_rate"] == "1200" + + +class TestRenderNetworkPanel: + """SSID com markup Rich não pode derrubar `mm net` / o painel de status.""" + + def test_ssid_with_closing_tag_does_not_raise(self) -> None: + info = NetworkInfo( + interface="en0", + local_ip="192.168.1.2", + public_ip="1.2.3.4", + ssid="My[/Home]WiFi", + bssid=None, + rssi=-50, + noise=None, + channel="36", + tx_rate="1200", + security="WPA3", + ) + buf = StringIO() + Console(file=buf, force_terminal=True, width=80).print(render_network_panel(info)) + out = buf.getvalue() + assert "My" in out + assert "WiFi" in out diff --git a/tests/test_system.py b/tests/test_system.py index 4c2d27b..778bc43 100644 --- a/tests/test_system.py +++ b/tests/test_system.py @@ -2,9 +2,12 @@ from __future__ import annotations +from io import StringIO + import pytest +from rich.console import Console -from macmanager.system import _pressure_color +from macmanager.system import SystemInfo, _pressure_color, render_system_panel class TestPressureColor: @@ -28,3 +31,27 @@ def test_known_states(self, state: str, color: str) -> None: def test_unknown_states_fall_back_to_dim(self, state: str) -> None: # Case-sensitive on purpose — the source dict is keyed on exact strings. assert _pressure_color(state) == "dim" + + +class TestRenderSystemPanel: + """Nome de processo com `[/]` não pode derrubar `mm health`.""" + + def test_process_name_with_markup_does_not_raise(self) -> None: + info = SystemInfo( + cpu_percent=10.0, + load_avg_1=1.0, + load_avg_5=1.0, + load_avg_15=1.0, + cpu_count=8, + memory_total=16 * 1024**3, + memory_used=8 * 1024**3, + memory_percent=50.0, + swap_used=0, + swap_total=0, + memory_pressure="Normal", + uptime_sec=1000, + top_processes=[{"pid": 1, "name": "foo[/]bar", "cpu": 10.0, "mem": 1000}], + ) + buf = StringIO() + Console(file=buf, force_terminal=True, width=80).print(render_system_panel(info)) + assert "foo" in buf.getvalue() diff --git a/tests/test_ui.py b/tests/test_ui.py index 437d41a..51249b3 100644 --- a/tests/test_ui.py +++ b/tests/test_ui.py @@ -7,9 +7,18 @@ from __future__ import annotations import pytest +from rich.markup import escape from rich.panel import Panel -from macmanager.ui import bar, fmt_bytes, fmt_seconds, health_color, safe_panel, usage_color +from macmanager.ui import ( + bar, + fmt_bytes, + fmt_seconds, + health_color, + safe_panel, + safe_text, + usage_color, +) # --------------------------------------------------------------------------- # fmt_bytes @@ -213,3 +222,24 @@ def boom() -> Panel: with pytest.raises(SystemExit): safe_panel("Battery", boom) + + +# --------------------------------------------------------------------------- +# safe_text +# --------------------------------------------------------------------------- + + +class TestSafeText: + """Texto do sistema (SSID, processo, path) não pode entrar cru no + markup do Rich — `[/]` fecha tag e vira MarkupError.""" + + def test_escapes_rich_markup(self) -> None: + raw = "My[/Home]WiFi" + assert safe_text(raw) == escape(raw) + assert safe_text(raw).startswith("My\\[") + + def test_none_becomes_empty_string(self) -> None: + assert safe_text(None) == "" + + def test_plain_text_is_unchanged(self) -> None: + assert safe_text("en0") == "en0"