panda-rpki-oss/tools/dependency_notices.py
yuyr 591bed90a8
Some checks failed
ci / rust (push) Has been cancelled
ci / docker (push) Has been cancelled
ci / audit (push) Has been cancelled
Harden first-release validation and dependency hygiene
2026-09-10 09:53:45 +08:00

87 lines
4.1 KiB
Python

"""Reproduce notices from locked Cargo archives and pinned upstream supplements."""
import argparse
import hashlib
import json
import subprocess
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
HEADER = """# Third-party notices
Panda RPKI is distributed under BSD-3-Clause; dependencies retain their own
licenses. The table below records the locked Cargo dependency graph, including
build-time and platform-specific packages that may not be linked on Linux.
Upstream license expressions are reproduced as declared by each package.
The accompanying [license texts](docs/third-party-licenses.txt) retain notices
from package archives, including bundled native code. These files must accompany
binary distributions. Container OS package notices remain under
`/usr/share/doc/` and `/usr/share/common-licenses/` in the Debian image.
Python cryptography and OpenSSL are external test tools, not bundled source.
Consult their installed license notices when distributing a test environment.
No production trust-anchor or repository data is included in this source tree.
| Package | Locked version | Declared license |
| --- | --- | --- |
"""
def generate():
metadata = json.loads(subprocess.check_output(
["cargo", "metadata", "--locked", "--format-version", "1"], cwd=ROOT))
supplements = json.loads((ROOT / "tools/license_supplements.json").read_text())
rows, texts = [], []
for package in sorted(metadata["packages"], key=lambda p: (p["name"], p["version"])):
if package["id"] in metadata["workspace_members"]:
continue
name, version = package["name"], package["version"]
source = Path(package["manifest_path"]).parent
candidates = sorted(p for p in source.rglob("*") if p.is_file()
and (p.name.upper().startswith(("LICENSE", "LICENCE", "COPYING", "COPYRIGHT", "NOTICE", "AUTHORS"))
or "LICENSES" in p.relative_to(source).parts)
and p.stat().st_size < 500_000)
contents = []
for path in candidates:
try:
contents.append((str(path.relative_to(source)), path.read_text(encoding="utf-8")))
except UnicodeDecodeError:
continue
if not contents:
entry = supplements.get(f"{name}@{version}")
if entry is None:
raise RuntimeError(f"Missing license texts: {name}@{version}; review upstream sources")
data = subprocess.check_output([
"curl", "--fail", "--silent", "--show-error", "--location",
"--proto", "=https", "--proto-redir", "=https", "--connect-timeout", "15",
"--max-time", "120", entry["url"]])
if hashlib.sha256(data).hexdigest() != entry["sha256"]:
raise RuntimeError(f"Supplement checksum mismatch: {name}@{version}")
contents.append(("LICENSE (upstream package revision)", data.decode("utf-8")))
rows.append(f"| {name} | {version} | {package['license'] or 'See upstream license'} |")
for label, content in contents:
texts.append(f"\n{'=' * 72}\n{name} {version}{label}\n{'=' * 72}\n{content}\n")
return {
ROOT / "THIRD_PARTY_NOTICES.md": HEADER + "\n".join(rows) + "\n",
ROOT / "docs/third-party-licenses.txt":
"Third-party license texts from locked package archives.\n" + "".join(texts),
}
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--check", action="store_true", help="Fail if tracked notices differ; do not write files")
args = parser.parse_args()
outputs = generate() # Resolve everything before writing any output.
for path, content in outputs.items():
if args.check:
if not path.exists() or path.read_bytes() != content.encode("utf-8"):
raise SystemExit(f"Outdated notices: {path.name}; run tools/dependency_notices.py")
else:
path.write_bytes(content.encode("utf-8"))
print("Dependency notices are current." if args.check else "Dependency notices generated.")
if __name__ == "__main__":
main()