diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..776ca0f --- /dev/null +++ b/LICENSE @@ -0,0 +1,32 @@ +MIT License + +Copyright (c) 2026 Ryan Malloy + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +--- + +This package also distributes a vendored third-party component under a +different licence. See LICENSE-arsdk-xml for the full text. + + src/mcbebop/arsdk-xml/common.xml + src/mcbebop/arsdk-xml/ardrone3.xml + Copyright (C) 2014 Parrot SA, BSD-3-Clause. + +The combined distribution is therefore "MIT AND BSD-3-Clause". diff --git a/LICENSE-arsdk-xml b/LICENSE-arsdk-xml new file mode 100644 index 0000000..4902bf8 --- /dev/null +++ b/LICENSE-arsdk-xml @@ -0,0 +1,37 @@ +The files src/mcbebop/arsdk-xml/common.xml and src/mcbebop/arsdk-xml/ardrone3.xml +are Parrot SA's own ARSDK protocol definitions, redistributed unmodified except +as recorded in src/mcbebop/arsdk-xml/PROVENANCE.md. Upstream: +https://github.com/Parrot-Developers/arsdk-xml + +The licence text below is reproduced from the header of those files. + +---------------------------------------------------------------------- + +Copyright (C) 2014 Parrot SA + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: +* Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. +* Redistributions in binary form must reproduce the above copyright +notice, this list of conditions and the following disclaimer in +the documentation and/or other materials provided with the +distribution. +* Neither the name of Parrot nor the names +of its contributors may be used to endorse or promote products +derived from this software without specific prior written +permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS +FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE +COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, +INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, +BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS +OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED +AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT +OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF +SUCH DAMAGE. diff --git a/README.md b/README.md index d3e88bb..6cc15c8 100644 --- a/README.md +++ b/README.md @@ -84,7 +84,12 @@ uvx mcbebop claude mcp add mcbebop -- uvx mcbebop ``` -## Credits +## Licence -`arsdk-xml/` is Parrot SA's own protocol definition, BSD-3-Clause. See -`arsdk-xml/PROVENANCE.md`. +This package is MIT (`LICENSE`), and it vendors one third-party component: +`src/mcbebop/arsdk-xml/` is Parrot SA's own protocol definition, BSD-3-Clause +(`LICENSE-arsdk-xml`). It ships in both the sdist and the wheel because nothing +here can decode a single command without it. What that snapshot is and how it +differs from upstream is recorded in `arsdk-xml/PROVENANCE.md`. + +So the distribution as a whole is `MIT AND BSD-3-Clause`. diff --git a/pyproject.toml b/pyproject.toml index d76a476..e7bed5f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,11 @@ version = "2026.10.02" description = "MCP server for the Parrot Bebop 2 drone: telemetry, camera, files, and every ARSDK command" readme = "README.md" requires-python = ">=3.12" -license = "MIT" +# Our own code is MIT. The vendored arsdk-xml/ is Parrot SA's, BSD-3-Clause, +# and it ships in both artifacts because the package cannot decode a single +# command without it, so the distribution as a whole is both. +license = "MIT AND BSD-3-Clause" +license-files = ["LICENSE", "LICENSE-arsdk-xml"] authors = [{name = "Ryan Malloy", email = "ryan@supported.systems"}] keywords = ["mcp", "drone", "parrot", "bebop", "arsdk", "fastmcp"] classifiers = [ @@ -35,7 +39,78 @@ requires = ["uv_build>=0.11.3,<0.12.0"] build-backend = "uv_build" [tool.uv.build-backend] -source-exclude = ["CLAUDE.md", ".env", ".env.*", ".mcp.json", "tests", "captures"] +# Err toward excluding. This repo sits next to a real aircraft, and `captures/` +# holds video of the owner's home plus `ckcm.bin` system logs that carry the +# serial, the CPU id and a real GPS fix together. None of it may ever reach an +# sdist, which is immutable and mirrored within minutes of upload. +# +# `.gitignore` does NOT protect this: it governs git, and source-exclude +# governs the sdist. A file can easily be in one and not the other, so the +# patterns below are deliberately broader than the directories that exist +# today -- a capture dropped into a new directory next year is still excluded. +# test_packaging.py asserts both halves stay in agreement. +source-exclude = [ + # Operator-private context and anything credential-shaped. + "CLAUDE.md", + ".env", + ".env.*", + ".mcp.json", + ".claude", + ".claude/**", + # Dev-only trees. Tests carry fixtures copy-pasted from a live aircraft. + "tests", + "tests/**", + "docs", + "docs/**", + "scripts", + "scripts/**", + "*.ipynb", + # Captures off the aircraft, at any depth, plus log dumps. + "captures", + "captures/**", + "**/captures/**", + "**/logs/**", + "ckcm*", + "**/ckcm*", + # Recorded media and binary blobs by extension, so a stray file in a + # directory nobody thought to list is still caught. + "*.rtpcap", + "*.raw", + "*.h264", + "*.bin", + "*.pcap", + "*.pcapng", + "*.mp4", + "*.jpg", + "*.jpeg", + "*.png", + "*.wav", + "*.ulg", + "**/*.rtpcap", + "**/*.raw", + "**/*.h264", + "**/*.bin", + "**/*.jpg", + "**/*.jpeg", + "**/*.png", + # Caches and build output. .pyc files embed the absolute build path, which + # leaks a username and the local directory layout. + "**/__pycache__", + "**/__pycache__/**", + "*.pyc", + "**/*.pyc", + ".pytest_cache", + ".pytest_cache/**", + ".ruff_cache", + ".ruff_cache/**", + ".venv", + ".venv/**", + "dist", + "dist/**", + "build", + "build/**", + "*.egg-info", +] [dependency-groups] dev = ["ruff>=0.16", "pytest>=8.0", "pytest-asyncio>=0.25"] diff --git a/src/mcbebop/sim.py b/src/mcbebop/sim.py index 5ca8899..b3d4a6a 100644 --- a/src/mcbebop/sim.py +++ b/src/mcbebop/sim.py @@ -459,7 +459,12 @@ class FakeBebop: self.emit( "common.SettingsState.ProductVersionChanged", acked=True, software="4.7.1", hardware="HW_05" ) - self.emit("common.SettingsState.ProductSerialHighChanged", acked=True, high="PI04") + # Deliberately nonsense, and please keep it that way. A real Bebop 2 + # serial is the aircraft's identity: it appears in its SSID and in the + # ckcm system log beside the CPU id and the last GPS fix. A simulator + # that volunteered a realistic-looking one would get quoted into a bug + # report as if it were a specimen, so this one reads "not a real sim". + self.emit("common.SettingsState.ProductSerialHighChanged", acked=True, high="N0TAREAL") self.emit("common.SettingsState.ProductSerialLowChanged", acked=True, low="0000000SIM") self.emit("ardrone3.SettingsState.MotorFlightsStatusChanged", acked=True, nbFlights=42, lastFlightDuration=611, totalFlightDuration=26_340) # fmt: skip diff --git a/tests/test_arsdk_session.py b/tests/test_arsdk_session.py index 7754f61..fe2b458 100644 --- a/tests/test_arsdk_session.py +++ b/tests/test_arsdk_session.py @@ -404,10 +404,12 @@ def test_probe_gives_up_on_a_closed_port_quickly(): def test_an_explicit_address_is_trusted_without_probing(): # A caller who names an address wants the connection error, not a # discovery verdict, so find() must not quietly return None here. - found = discovery.find("10.1.2.3") + # 192.0.2.0/24 is RFC 5737 documentation space, so this cannot name a host + # on whatever network the suite happens to run on. + found = discovery.find("192.0.2.7") assert found is not None - assert (found.ip, found.via) == ("10.1.2.3", "given") - assert found.address == ("10.1.2.3", 44444) + assert (found.ip, found.via) == ("192.0.2.7", "given") + assert found.address == ("192.0.2.7", 44444) # -- an event we cannot name --------------------------------------------- diff --git a/tests/test_packaging.py b/tests/test_packaging.py index e863fa5..5e754c2 100644 --- a/tests/test_packaging.py +++ b/tests/test_packaging.py @@ -1,14 +1,61 @@ -"""Guards that the package stays coherent as the streams land.""" +"""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((Path(__file__).parent.parent / "pyproject.toml").read_text()) + return tomllib.loads((ROOT / "pyproject.toml").read_text()) def test_version_matches_pyproject(): @@ -38,3 +85,54 @@ def test_vendored_xml_present_and_parses(): assert proj.find("class") is not None, f"{name} has no 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"]