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.
139 lines
6.0 KiB
Python
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"]
|