Files
mcbebop/tests/test_packaging.py
T
rsp2k c5e84c47ae Pre-publish privacy audit: scrub the simulator serial, harden the sdist, add licences
Audited the package against the two-stage procedure before a first PyPI
publish. The sdist and wheel were already tight, but three things needed
fixing and the controls needed to become real rather than documented.

The simulator volunteered high="PI04" as its product serial, which is the
real Bebop 2 serial prefix. Nothing unique to one aircraft, but a realistic
prefix invites being quoted into a bug report as a specimen, so it now reads
"N0TAREAL" / "0000000SIM" with a comment saying why it is nonsense on
purpose. The low half and the 500.0 no-fix GPS sentinel were already fake.

Hardened [tool.uv.build-backend] source-exclude well past the directories
that exist today: captures at any depth, log dumps, recorded media by
extension, caches, and anything credential-shaped. .gitignore governs git
and source-exclude governs the sdist; a capture can sit in one and not the
other, which is how this kind of data reaches an immutable index. Verified
the broad patterns do not over-reach: arsdk-xml/ with PROVENANCE.md and
tools/logs.py both still ship.

Added LICENSE (MIT) and LICENSE-arsdk-xml (Parrot SA's BSD-3-Clause), and
corrected the declared licence to "MIT AND BSD-3-Clause". The vendored XML
ships in both artifacts because nothing here decodes a command without it,
so MIT alone understated what is in the box. Both texts now appear in the
artifacts and in the metadata.

test_packaging.py grows privacy guards that fail on a serial prefix, a P7
CPU id, any MAC, a high-precision coordinate, an absolute home path, or any
private address other than the drone's own documented 192.168.42.0/24. Each
pattern was checked against the real identifiers to confirm it bites, since
a guard that passes on an empty tree proves nothing.

Example address in test_arsdk_session.py moved to RFC 5737 space.

504 tests pass, ruff clean.
2026-10-03 11:21:13 -06:00

139 lines
6.0 KiB
Python

"""Guards that the package stays coherent as the streams land.
The second half of this file is a privacy guard rather than a coherence one.
This package was written against a real aircraft parked at a real address, so
the things that identify it -- the serial, the SoC CPU id, the Wi-Fi BSSID, the
first GPS fix -- all passed through this working tree at some point. PyPI is
immutable per version and sdists are mirrored within minutes, so the audit that
catches a stray identifier has to run before every publish, not after one.
"""
import re
import tomllib
from pathlib import Path
import mcbebop
from mcbebop.server import build_server
ROOT = Path(__file__).parent.parent
# Every pattern here is a shape that identifies the owner's aircraft or
# network. The comment on each says what it is, so a future hit is diagnosable
# rather than mysterious.
_FORBIDDEN = (
# A Bebop 2 serial's high half. The full serial also appears in the
# aircraft's SSID, which makes it a locator and not just a label.
(re.compile(r"PI04"), "a real Bebop 2 serial prefix"),
# The P7 SoC CPU id: 'P7' then a long uppercase run. 'P7ID', the command
# name in ardrone3.xml, is too short to match and is meant to be here.
(re.compile(r"P7[0-9A-Z]{8,}"), "a P7 SoC CPU id"),
# Any MAC address at all, not just a Parrot OUI.
(re.compile(r"\b[0-9A-Fa-f]{2}(?::[0-9A-Fa-f]{2}){5}\b"), "a MAC address"),
# A plausible residential coordinate. The simulator reports ARSDK's 500.0
# no-fix sentinel instead, which has no decimal places to match.
(re.compile(r"\b[0-9]{1,3}\.[0-9]{6,}"), "a high-precision coordinate"),
# An absolute build path leaks a username and the local layout.
(re.compile(r"/home/[a-z]"), "an absolute home-directory path"),
)
# 192.168.42.0/24 is the drone's own fixed network and is in Parrot's public
# documentation, so it is expected. Any other private address is somebody's
# real LAN; use RFC 5737 documentation space in examples instead.
_PRIVATE_IP = re.compile(
r"\b(?:10\.\d{1,3}\.\d{1,3}\.\d{1,3}"
r"|172\.(?:1[6-9]|2\d|3[01])\.\d{1,3}\.\d{1,3}"
r"|192\.168\.\d{1,3}\.\d{1,3})\b"
)
_ALLOWED_IP = re.compile(r"\b192\.168\.42\.\d{1,3}\b")
def _shipped_files() -> list[Path]:
"""Every text file that reaches an artifact, which is src/ plus the docs."""
files = [ROOT / "README.md", ROOT / "pyproject.toml"]
files += [p for p in (ROOT / "src").rglob("*") if p.is_file() and "__pycache__" not in p.parts]
return files
def _pyproject() -> dict:
return tomllib.loads((ROOT / "pyproject.toml").read_text())
def test_version_matches_pyproject():
# importlib normalises 2026.10.02 to 2026.10.2; compare as release tuples.
declared = _pyproject()["project"]["version"]
assert tuple(int(p) for p in declared.split(".")) == tuple(int(p) for p in mcbebop.__version__.split("."))
def test_entry_point_is_importable():
module, _, attr = _pyproject()["project"]["scripts"]["mcbebop"].partition(":")
mod = __import__(module, fromlist=[attr])
assert callable(getattr(mod, attr))
def test_server_builds():
assert build_server() is not None
def test_vendored_xml_present_and_parses():
import xml.etree.ElementTree as ET
root = Path(__file__).parent.parent / "src" / "mcbebop" / "arsdk-xml"
total = 0
for name in ("common.xml", "ardrone3.xml"):
proj = ET.parse(root / name).getroot()
# Parrot's element is <class>; pyparrot renamed it and we reverted that.
assert proj.find("class") is not None, f"{name} has no <class> elements"
total += sum(len(c.findall("cmd")) for c in proj.iter("class"))
assert total == 264, f"expected 264 commands, found {total}"
# -- privacy guards ------------------------------------------------------
def test_no_shipped_file_carries_an_aircraft_identifier():
hits = []
for path in _shipped_files():
text = path.read_text(encoding="utf-8", errors="replace")
for lineno, line in enumerate(text.splitlines(), 1):
for pattern, what in _FORBIDDEN:
if pattern.search(line):
hits.append(f"{path.relative_to(ROOT)}:{lineno} looks like {what}: {line.strip()[:90]}")
assert not hits, "identifying data in a file that ships:\n" + "\n".join(hits)
def test_no_shipped_file_names_a_private_network_but_the_drones_own():
hits = []
for path in _shipped_files():
text = path.read_text(encoding="utf-8", errors="replace")
for lineno, line in enumerate(text.splitlines(), 1):
for found in _PRIVATE_IP.finditer(line):
if not _ALLOWED_IP.fullmatch(found.group()):
hits.append(f"{path.relative_to(ROOT)}:{lineno} names {found.group()}")
assert not hits, (
"a private address other than the drone's own 192.168.42.0/24; "
"use RFC 5737 documentation space:\n" + "\n".join(hits)
)
def test_sdist_excludes_captures_and_dev_trees():
"""The sdist exclusions are a separate mechanism from .gitignore.
A file can be gitignored and still swept into an sdist, which is how
captures reach PyPI. Assert the directories that hold real data off the
aircraft are named in both places.
"""
excluded = set(_pyproject()["tool"]["uv"]["build-backend"]["source-exclude"])
ignored = (ROOT / ".gitignore").read_text().split()
for name in ("captures", "tests"):
assert name in excluded or f"{name}/**" in excluded, f"{name} is not excluded from the sdist"
for name in ("captures",):
assert f"{name}/" in ignored or name in ignored, f"{name} is not gitignored"
def test_declared_licence_files_exist():
"""A licence named in metadata but absent from the tree ships a lie."""
declared = _pyproject()["project"]["license-files"]
assert declared, "license-files must name the licence texts so they ship"
for name in declared:
assert (ROOT / name).is_file(), f"{name} is declared in license-files but missing"
# The vendored XML is Parrot's and the expression has to say so.
assert "BSD-3-Clause" in _pyproject()["project"]["license"]