# Copyright © 2025-26 l5yth & contributors # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Unit tests for the ``DEBUG=1`` ignored-packet capture (SPEC DC1-DC3). With ``DEBUG=1`` the ingestor appends one line per dropped packet to ``ignored-meshtastic.txt`` (:mod:`data.mesh_ingestor.handlers.ignored`). A line holds the drop reason and the packet's metadata only: port, channel, sender and recipient ids, packet id, receive time and payload size. It never holds the text, the payload, the meshtastic library's ``raw`` ``MeshPacket``, other decoded content or a position's coordinates; the review found every one of them there (M12), for hidden and filtered channels, via_mqtt traffic and direct messages, MeshCore's included. The file rotates at 10 MB and keeps one previous file. The Meshtastic cases hand real ``MeshPacket`` protobufs to the pinned library through the ``radio`` fixture of ``tests/test_channel_scope_unit.py`` (SPEC CF8); the MeshCore cases run the real companion reader and event handlers (``tests/meshcore_frames.py``). Only the HTTP queue is replaced. """ from __future__ import annotations import asyncio import base64 import contextlib import json import os import subprocess import sys import time from datetime import datetime from pathlib import Path from types import SimpleNamespace import pytest REPO_ROOT = Path(__file__).resolve().parents[1] if str(REPO_ROOT) not in sys.path: # pragma: no cover - conftest adds it sys.path.insert(0, str(REPO_ROOT)) import meshcore_frames as frames # noqa: E402 - pytest puts tests/ on sys.path import data.mesh_ingestor.channels as channels # noqa: E402 import data.mesh_ingestor.config as config # noqa: E402 import data.mesh_ingestor.handlers.ignored as ignored_mod # noqa: E402 import data.mesh_ingestor.protocols.meshcore as meshcore_mod # noqa: E402 from data.mesh_ingestor.protocols.meshcore import ( # noqa: E402 _MeshcoreInterface, _make_event_handlers, ) from test_channel_scope_unit import ( # noqa: E402,F401 - shared fixtures PRIMARY, SECONDARY, SENDER, SENDER_ID, _mesh_packet, _payload, radio, scope, ) CAP = 10_000_000 """The documented size, in bytes, at which the capture file rotates (DC2).""" TEXT = _payload("TEXT_MESSAGE_APP").decode() """The text every Meshtastic probe message carries (CF8's ``_payload``).""" DM_TEXT = "the door code is 4711" """Text of the MeshCore direct message.""" HOST_NUM = 0x0D15EA5E """Node number of the ingestor's own radio, the direct messages' recipient.""" HOST_ID = "!0d15ea5e" """Canonical id of :data:`HOST_NUM`.""" MESHCORE_KEY = "aabbccddee11" + "00" * 26 """Public key of the MeshCore contact that sends the direct message.""" GATE_CASES = { "hidden-channel": ({"HIDDEN_CHANNELS": ("Secret",)}, SECONDARY, False), "disallowed-channel": ({"ALLOWED_CHANNELS": ("LongFast",)}, SECONDARY, False), "non-primary-channel": ({"PRIMARY_CHANNEL_ONLY": True}, SECONDARY, False), "via_mqtt": ({"DROP_VIA_MQTT": True}, PRIMARY, True), } """Each CF2 gate reason, with the configuration and the reception it drops: ``(config overrides, channel index, via_mqtt)``.""" LISTED_PACKET = { "from": SENDER, "fromId": SENDER_ID, "to": 0xFFFFFFFF, "toId": "^all", "id": 77, "channel": SECONDARY, "rxTime": 1_791_000_000, "decoded": {"portnum": "TEXT_MESSAGE_APP", "payload": b"hello", "text": "hello"}, } """A library-shaped packet carrying a source for every listed field.""" LISTED_ENTRY = { "reason": "hidden-channel", "id": 77, "rx_time": 1_791_000_000, "from_id": SENDER_ID, "to_id": "^all", "channel": SECONDARY, "portnum": "TEXT_MESSAGE_APP", "payload_size": 5, } """The line :data:`LISTED_PACKET` leaves, without its ``timestamp``.""" _RECORD = ignored_mod._record_ignored_packet """The production recorder, which the CF8 ``scope`` fixture replaces.""" @pytest.fixture def capture(scope, monkeypatch, tmp_path): """Run the real recorder under ``DEBUG=1``, writing into ``tmp_path``. ``scope`` (neutral filters, POSTs recorded) replaces the recorder with a list of reasons; this puts the real one back. The MeshCore capture is sent to ``tmp_path`` too, so no case writes beside the checkout. Returns: A namespace whose ``path`` is the capture file. """ path = tmp_path / "ignored-meshtastic.txt" monkeypatch.setattr(ignored_mod, "_record_ignored_packet", _RECORD) monkeypatch.setattr(ignored_mod, "_IGNORED_PACKET_LOG_PATH", path) monkeypatch.setattr( meshcore_mod, "_IGNORED_MESSAGE_LOG_PATH", tmp_path / "ignored-meshcore.txt" ) monkeypatch.setattr(config, "DEBUG", True) return SimpleNamespace(path=path) def _hear(radio, packet) -> None: """Deliver ``packet`` from the known :data:`SENDER`, as ``radio.hear`` does.""" radio.iface._getOrCreateByNum(SENDER) radio.iface._handlePacketFromRadio(packet) def _meshcore_hidden_channel_message(scope) -> None: """Hear the real reader's ``#test`` channel message, ``#test`` hidden.""" # A MeshCore radio names its own channels, as the real reader does. channels._reset_channel_cache() scope.apply(HIDDEN_CHANNELS=(frames.CHANNEL_NAME,)) hmap = _make_event_handlers(_MeshcoreInterface(target=None), "/dev/ttyUSB0") raw_frames = [frames.channel_info_frame(), frames.channel_msg_v3_frame(path_len=3)] asyncio.run(frames.feed_reader(raw_frames, hmap)) def _meshcore_direct_message(*, rostered: bool) -> None: """Hear :data:`DM_TEXT` sent to the host, its sender in the roster or not.""" iface = _MeshcoreInterface(target=None) iface.host_node_id = HOST_ID if rostered: iface._update_contact({"public_key": MESHCORE_KEY, "adv_name": "Alice"}) hmap = _make_event_handlers(iface, "/dev/ttyUSB0") event = SimpleNamespace( payload={ "sender_timestamp": frames.SENDER_TS, "text": DM_TEXT, "pubkey_prefix": MESHCORE_KEY[:12], } ) asyncio.run(hmap["CONTACT_MSG_RECV"](event)) def _b64(data: bytes) -> str: """Return ``data`` as the old capture wrote bytes: Base64 text.""" return base64.b64encode(data).decode("ascii") def _leaks(capture, **forms) -> list[str]: """Name each content form of a dropped packet the capture file holds. Parameters: capture: The :func:`capture` fixture's namespace. **forms: A name for each form and the string, or tuple of strings, any one of which in the file betrays it: the text, the payload as Base64, a coordinate. Returns: The names found, in argument order, then ``raw`` when a line carries the library's ``raw`` ``MeshPacket``. """ content = capture.path.read_text(encoding="utf-8") found = [] for name, needles in forms.items(): needles = (needles,) if isinstance(needles, str) else needles if any(needle in content for needle in needles): found.append(name) if '"raw"' in content: found.append("raw") return found def _only_entry(capture) -> dict: """Return the file's single line, parsed, its ISO ``timestamp`` removed.""" lines = capture.path.read_text(encoding="utf-8").splitlines() assert len(lines) == 1, lines entry = json.loads(lines[0]) assert datetime.fromisoformat(entry.pop("timestamp")).tzinfo is not None return entry # --------------------------------------------------------------------------- # Every drop reason the review found keeps metadata only (SPEC DC1) # --------------------------------------------------------------------------- class TestDroppedPacketsKeepNoContent: """A dropped packet's line holds its metadata, never its content.""" @pytest.mark.parametrize("reason", list(GATE_CASES)) def test_text_dropped_at_the_filter_gate(self, radio, capture, reason): """A text the CF2 gate drops leaves no text, payload or ``raw``.""" overrides, channel, via_mqtt = GATE_CASES[reason] radio.apply(**overrides) packet = _mesh_packet("TEXT_MESSAGE_APP", channel=channel, via_mqtt=via_mqtt) _hear(radio, packet) assert radio.posts == [] assert _leaks(capture, text=TEXT, payload=_b64(packet.decoded.payload)) == [] expected = { "reason": reason, "id": packet.id, "rx_time": packet.rx_time, "from_id": SENDER_ID, "to_id": "^all", "portnum": "TEXT_MESSAGE_APP", "payload_size": len(TEXT.encode()), } if channel: # proto3 omits the channel field at index 0 (SPEC CF2). expected["channel"] = channel assert _only_entry(capture) == expected def test_hidden_channel_position(self, radio, capture): """A hidden channel's position leaves no coordinates or payload.""" radio.apply("hidden") packet = _mesh_packet("POSITION_APP", channel=SECONDARY) _hear(radio, packet) assert radio.posts == [] assert ( _leaks( capture, coordinates=("525200000", "52.52", "134050000", "13.405"), payload=_b64(packet.decoded.payload), ) == [] ) entry = _only_entry(capture) assert (entry["reason"], entry["portnum"], entry["channel"]) == ( "hidden-channel", "POSITION_APP", SECONDARY, ) assert entry["payload_size"] == len(packet.decoded.payload) def test_meshtastic_direct_message(self, radio, capture): """A direct message the ingestor skips leaves no text, payload or ``raw``.""" packet = _mesh_packet("TEXT_MESSAGE_APP", channel=PRIMARY) packet.to = HOST_NUM _hear(radio, packet) assert radio.posts == [] assert _leaks(capture, text=TEXT, payload=_b64(packet.decoded.payload)) == [] entry = _only_entry(capture) assert entry["reason"] == "skipped-direct-message" assert (entry["from_id"], entry["to_id"]) == (SENDER_ID, HOST_ID) def test_meshcore_hidden_channel_message(self, scope, capture): """A MeshCore message on a hidden channel leaves no text (SC8).""" _meshcore_hidden_channel_message(scope) assert scope.posts == [] assert _leaks(capture, text=("hello path", frames.TEXT)) == [] entry = _only_entry(capture) assert (entry["reason"], entry["channel"], entry["portnum"]) == ( "hidden-channel", 0, "TEXT_MESSAGE_APP", ) assert entry["payload_size"] == len(frames.TEXT.encode()) def test_meshcore_direct_message(self, scope, capture): """A MeshCore direct message the ingestor skips leaves no text.""" _meshcore_direct_message(rostered=True) assert scope.posts == [] assert _leaks(capture, text=DM_TEXT) == [] entry = _only_entry(capture) assert entry["reason"] == "skipped-direct-message" assert (entry["from_id"], entry["to_id"]) == ("!aabbccdd", HOST_ID) assert entry["payload_size"] == len(DM_TEXT.encode()) # --------------------------------------------------------------------------- # DEBUG log lines keep metadata only (SPEC DC7) # --------------------------------------------------------------------------- def _log_line(out: str, message: str) -> str: """Return the one line of ``out`` that ``config._debug_log`` printed for ``message``, which it writes after the fields.""" [line] = [line for line in out.splitlines() if line.endswith(message)] return line class TestDebugLogLinesKeepNoContent: """A log line that describes a dropped packet prints no content.""" def test_packet_missing_from_id_prints_metadata(self, scope, capture, capsys): """A MeshCore direct message from outside the roster has no sender id; its ``Packet missing from_id`` line names the packet, not its text.""" _meshcore_direct_message(rostered=False) out = capsys.readouterr().out assert DM_TEXT not in out line = _log_line(out, "Packet missing from_id") assert f"to_id='{HOST_ID}'" in line assert f"payload_size={len(DM_TEXT.encode())}" in line assert "from_id" not in line.removesuffix("Packet missing from_id") assert scope.posts == [] assert _only_entry(capture)["reason"] == "skipped-direct-message" def test_meshcore_channel_message_names_no_sender(self, scope, capture, capsys): """The ``MeshCore channel message`` line keeps the channel and the sender id; the name parsed from a hidden channel's text stays out.""" _meshcore_hidden_channel_message(scope) out = capsys.readouterr().out assert "Alice" not in out assert "hello path" not in out line = _log_line(out, "MeshCore channel message") assert "channel=0" in line assert "from_id='!" in line # --------------------------------------------------------------------------- # The allowlist (SPEC DC1) # --------------------------------------------------------------------------- class TestCaptureAllowlist: """Only the listed fields are written; a new field is excluded by default.""" def test_unknown_field_is_left_out(self, capture): """A field the allowlist does not name never reaches the file, at the top level or inside ``decoded``; the listed fields still do.""" packet = { **LISTED_PACKET, "futureField": "zz-unlisted-zz", "decoded": { **LISTED_PACKET["decoded"], "futureSection": {"note": "zz-unlisted-zz"}, }, } ignored_mod._record_ignored_packet(packet, reason="hidden-channel") assert "zz-unlisted-zz" not in capture.path.read_text(encoding="utf-8") assert _only_entry(capture) == LISTED_ENTRY def test_allowlist_is_the_documented_metadata(self): """The allowlist names the metadata DC1 lists, in DC1's order.""" assert list(ignored_mod._CAPTURE_FIELDS) == [ "id", "rx_time", "from_id", "to_id", "channel", "portnum", "payload_size", ] def test_non_scalar_values_are_left_out(self, capture): """A listed field whose value is not a string or number is left out.""" nested = {"text": "zz-nested-zz"} packet = { "id": nested, "rxTime": ["zz-nested-zz"], "fromId": nested, "toId": b"zz-nested-zz", "channel": nested, "decoded": {"portnum": nested, "payload": nested}, } ignored_mod._record_ignored_packet(packet, reason="unsupported-port") assert "zz-nested-zz" not in capture.path.read_text(encoding="utf-8") assert _only_entry(capture) == {"reason": "unsupported-port"} def test_packet_that_is_no_mapping_leaves_its_reason(self, capture): """A packet that is not a mapping leaves the reason and nothing else.""" ignored_mod._record_ignored_packet("zz-opaque-zz", reason="unsupported-port") assert "zz-opaque-zz" not in capture.path.read_text(encoding="utf-8") assert _only_entry(capture) == {"reason": "unsupported-port"} @pytest.mark.parametrize( "packet, size", [ ({"decoded": {"payload": b"\x00\x01\x02", "text": "zz"}}, 3), ({"decoded": {"payload": _b64(b"abcd")}}, 4), ({"decoded": {"payload": {"__bytes_b64__": _b64(b"abcde")}}}, 5), ({"decoded": {"text": "grüße"}}, 7), ({"encrypted": _b64(b"\x07" * 9)}, 9), ({"encrypted": True, "decoded": {"portnum": "TEXT_MESSAGE_APP"}}, None), ({"decoded": {"payload": "not base64!"}}, None), ({}, None), ], ids=[ "library-bytes", "udp-base64", "bytes-mapping", "handler-text", "ciphertext", "encrypted-flag", "unreadable", "none", ], ) def test_payload_size(self, capture, packet, size): """The size counts the payload bytes, then the ciphertext, then the text in UTF-8; a packet with none of them has no size.""" ignored_mod._record_ignored_packet(packet, reason="no-message-payload") assert _only_entry(capture).get("payload_size") == size def test_numeric_sender_and_recipient_are_canonical(self, capture): """A numeric ``from``/``to`` is written as a ``!xxxxxxxx`` id.""" packet = {"from": SENDER, "fromId": "!0badbeef", "to": HOST_NUM} ignored_mod._record_ignored_packet(packet, reason="skipped-direct-message") entry = _only_entry(capture) assert (entry["from_id"], entry["to_id"]) == (SENDER_ID, HOST_ID) # --------------------------------------------------------------------------- # Rotation (SPEC DC2) # --------------------------------------------------------------------------- def _fill(path: Path, size: int) -> None: """Make ``path`` ``size`` bytes long without writing them (a sparse file).""" with path.open("wb") as handle: handle.truncate(size) _WRITER = """ import json, os, sys, time from pathlib import Path repo, path, cap, count, ready, go = sys.argv[1:] sys.path.insert(0, repo) import data.mesh_ingestor.config as config import data.mesh_ingestor.handlers.ignored as ignored config.DEBUG = True ignored._IGNORED_PACKET_LOG_PATH = Path(path) ignored._IGNORED_PACKET_LOG_MAX_BYTES = int(cap) moved, discarded = [], [] replace = os.replace def spy(src, dst): if os.path.exists(dst): data = Path(dst).read_bytes() discarded.append(data.rstrip(b"\\n").rfind(b"\\n") + 1) replace(src, dst) moved.append(os.stat(dst).st_size) os.replace = spy errors = [] Path(ready).touch() while not os.path.exists(go): time.sleep(0.001) for number in range(int(count)): try: ignored._record_ignored_packet({"id": number}, reason="hidden-channel") except OSError as exc: errors.append(type(exc).__name__) print(json.dumps({"errors": errors, "moved": moved, "discarded": discarded})) """ """A capture writer run as its own process: after the shared start it writes ``count`` lines to ``path`` at a cap of ``cap`` bytes and prints the errors it caught, the size of each file it rotated into ``.1``, and for each ``.1`` it replaced the offset of that file's last line.""" def _open_files() -> set[str]: """Return the path of every file this process holds open (Linux).""" names = set() for fd in os.listdir("/proc/self/fd"): with contextlib.suppress(OSError): names.add(os.readlink(f"/proc/self/fd/{fd}")) return names class TestCaptureRotation: """The file rotates at 10 MB and keeps exactly one previous file; it is created, with its directory, on the first write.""" def test_write_that_reaches_the_cap_rotates(self, capture): """The write that takes the file to 10 MB moves it to ``.1``; the next write starts a new file.""" previous = capture.path.with_name("ignored-meshtastic.txt.1") _fill(capture.path, CAP - 1) ignored_mod._record_ignored_packet({"id": 1}, reason="hidden-channel") assert previous.exists() assert previous.stat().st_size >= CAP assert not capture.path.exists() ignored_mod._record_ignored_packet({"id": 2}, reason="hidden-channel") assert _only_entry(capture) == {"reason": "hidden-channel", "id": 2} def test_write_below_the_cap_appends(self, capture): """A write that leaves the file under 10 MB appends to it.""" _fill(capture.path, CAP - 1000) ignored_mod._record_ignored_packet({"id": 3}, reason="hidden-channel") assert not capture.path.with_name("ignored-meshtastic.txt.1").exists() size = capture.path.stat().st_size assert CAP - 1000 < size < CAP with capture.path.open("rb") as handle: handle.seek(CAP - 1000) assert json.loads(handle.read())["id"] == 3 def test_only_one_previous_file_is_kept(self, capture, tmp_path): """A second rotation replaces the previous file; nothing else is kept.""" previous = capture.path.with_name("ignored-meshtastic.txt.1") previous.write_text("older capture\n", encoding="utf-8") _fill(capture.path, CAP - 1) ignored_mod._record_ignored_packet({"id": 4}, reason="hidden-channel") # The 14-byte older file is gone: ``.1`` is now the rotated 10 MB one. assert previous.stat().st_size >= CAP assert sorted(p.name for p in tmp_path.iterdir()) == [ "ignored-meshtastic.txt.1" ] def test_write_creates_a_missing_directory(self, capture, monkeypatch, tmp_path): """A capture path whose directory is missing is created on the write.""" path = tmp_path / "gone" / "ignored-meshtastic.txt" monkeypatch.setattr(ignored_mod, "_IGNORED_PACKET_LOG_PATH", path) ignored_mod._record_ignored_packet({"id": 5}, reason="hidden-channel") assert json.loads(path.read_text(encoding="utf-8"))["id"] == 5 def test_two_processes_share_one_rotation(self, tmp_path): """Two ingestors writing one checkout's capture never fail on a rotation, and every rotation moves a full file to ``.1``: never a fresh one over it (SPEC DC2).""" path = tmp_path / "ignored-meshtastic.txt" cap, count, go = 20_000, 10_000, tmp_path / "go" ready = [tmp_path / f"ready-{index}" for index in range(2)] writers = [ subprocess.Popen( [sys.executable, "-c", _WRITER, str(REPO_ROOT), str(path)] + [str(cap), str(count), str(flag), str(go)], cwd=tmp_path, stdout=subprocess.PIPE, text=True, ) for flag in ready ] deadline = time.monotonic() + 60 while not all(flag.exists() for flag in ready): assert time.monotonic() < deadline, "the writers never started" time.sleep(0.01) go.touch() reports = [json.loads(writer.communicate(timeout=120)[0]) for writer in writers] assert [writer.returncode for writer in writers] == [0, 0] assert [report["errors"] for report in reports] == [[], []] moved = [size for report in reports for size in report["moved"]] assert moved, "no rotation happened" assert min(moved) >= cap assert path.with_name("ignored-meshtastic.txt.1").stat().st_size >= cap # A file is rotated by the write that filled it, so every line but its # last went in below the cap: no line was appended after a rotation. discarded = [offset for report in reports for offset in report["discarded"]] assert discarded, "no previous file was replaced" assert max(discarded) < cap @pytest.mark.parametrize("fresh", [True, False], ids=["new-file", "no-file"]) def test_writer_that_waited_appends_to_the_current_file( self, capture, monkeypatch, fresh ): """A writer that waited for the lock while another process rotated the full file appends to the file now at the path, never to the rotated one, which the next rotation would discard.""" fcntl = pytest.importorskip("fcntl") previous = capture.path.with_name("ignored-meshtastic.txt.1") _fill(capture.path, CAP) waits = [] def rotated_while_waiting(fd, operation): """On the first wait let another process rotate the full file.""" if not waits: waits.append(fd) os.replace(capture.path, previous) if fresh: capture.path.write_text("fresh\n", encoding="utf-8") fcntl.flock(fd, operation) monkeypatch.setattr( ignored_mod, "fcntl", SimpleNamespace(LOCK_EX=fcntl.LOCK_EX, flock=rotated_while_waiting), ) ignored_mod._record_ignored_packet({"id": 6}, reason="hidden-channel") assert previous.stat().st_size == CAP lines = capture.path.read_text(encoding="utf-8").splitlines() assert lines[:-1] == (["fresh"] if fresh else []) assert json.loads(lines[-1])["id"] == 6 def test_without_fcntl_no_handle_is_open_at_the_rename(self, capture, monkeypatch): """Without ``fcntl`` (Windows) the file is closed before it is renamed, as Windows refuses to rename an open file.""" if not os.path.isdir("/proc/self/fd"): pytest.skip("needs /proc to list the open files") monkeypatch.setattr(ignored_mod, "fcntl", None) replace, seen = os.replace, [] def checked_replace(src, dst): """Record whether either file is open in this process, then rename.""" files = _open_files() seen.append( (os.path.realpath(src) in files, os.path.realpath(dst) in files) ) replace(src, dst) monkeypatch.setattr(os, "replace", checked_replace) _fill(capture.path, CAP - 1) ignored_mod._record_ignored_packet({"id": 8}, reason="hidden-channel") assert seen == [(False, False)] assert capture.path.with_name("ignored-meshtastic.txt.1").stat().st_size >= CAP def test_with_fcntl_the_rename_runs_under_the_lock(self, capture, monkeypatch): """With ``fcntl`` the file is renamed while this process holds its lock, so no other process appends to it or rotates it meanwhile.""" fcntl = pytest.importorskip("fcntl") replace, held = os.replace, [] def checked_replace(src, dst): """Try to take the lock from another handle, then rename.""" with open(src, "ab") as probe: try: fcntl.flock(probe.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) held.append(False) except BlockingIOError: held.append(True) replace(src, dst) monkeypatch.setattr(os, "replace", checked_replace) _fill(capture.path, CAP - 1) ignored_mod._record_ignored_packet({"id": 11}, reason="hidden-channel") assert held == [True] assert capture.path.with_name("ignored-meshtastic.txt.1").stat().st_size >= CAP @pytest.mark.parametrize("locks", [True, False], ids=["fcntl", "no-fcntl"]) def test_refused_rename_is_retried_at_the_next_write( self, capture, monkeypatch, locks ): """A rename the system refuses, as Windows does while another process holds the file open, is no error; the next write rotates the file.""" if not locks: monkeypatch.setattr(ignored_mod, "fcntl", None) previous = capture.path.with_name("ignored-meshtastic.txt.1") replace, refusals = os.replace, [PermissionError(13, "in use")] def refusing_once(src, dst): """Refuse the first rename, then rename.""" if refusals: raise refusals.pop() replace(src, dst) monkeypatch.setattr(os, "replace", refusing_once) _fill(capture.path, CAP - 1) ignored_mod._record_ignored_packet({"id": 9}, reason="hidden-channel") assert not previous.exists() assert capture.path.stat().st_size >= CAP ignored_mod._record_ignored_packet({"id": 10}, reason="hidden-channel") assert previous.stat().st_size >= CAP assert not capture.path.exists() def test_rotating_a_file_already_gone_is_no_error(self, capture): """A capture file removed before its rotation leaves nothing to move.""" ignored_mod._rotate(capture.path) assert not capture.path.with_name("ignored-meshtastic.txt.1").exists() def test_without_file_locks_one_process_still_rotates(self, capture, monkeypatch): """Where ``fcntl`` is missing (not POSIX), rotation works per process: the write that fills the file rotates it, the next one appends.""" monkeypatch.setattr(ignored_mod, "fcntl", None) _fill(capture.path, CAP - 1) ignored_mod._record_ignored_packet({"id": 7}, reason="hidden-channel") assert capture.path.with_name("ignored-meshtastic.txt.1").stat().st_size >= CAP assert not capture.path.exists() ignored_mod._record_ignored_packet({"id": 12}, reason="hidden-channel") assert _only_entry(capture) == {"reason": "hidden-channel", "id": 12} def test_meshcore_capture_rotates_the_same_way(self, capture, tmp_path): """``ignored-meshcore.txt`` rotates at 10 MB too and keeps one previous file; its lines keep the content SC8 gives them.""" path = tmp_path / "ignored-meshcore.txt" previous = path.with_name("ignored-meshcore.txt.1") _fill(path, CAP - 1) frame = {"payload_typename": "GRP_TXT", "msg_hash": 7} meshcore_mod._record_meshcore_message(frame, source="auto:RX_LOG_DATA") assert previous.exists() assert previous.stat().st_size >= CAP assert not path.exists() meshcore_mod._record_meshcore_message(frame, source="auto:RX_LOG_DATA") [line] = path.read_text(encoding="utf-8").splitlines() assert json.loads(line)["message"] == frame