#!/usr/bin/env python3 """Build-time license text; never reads app data or a password-store repository.""" import json from pathlib import Path import platform import re import subprocess import sys import tarfile import zipfile from android_ci import ROOT, CARGO, download def rust_notices(data, expected): covered = set() sections = [] for license in data["licenses"]: if not license["text"].strip(): raise RuntimeError("Empty Rust license text") users = [] for used in license["used_by"]: crate = used["crate"] key = (crate["name"], crate["version"]) covered.add(key) users.append(f"{key[0]} {key[1]} — {', '.join(crate['authors'])}\n" f"https://crates.io/crates/{key[0]}/{key[1]} (source archive)\n" f"{crate.get('repository') or ''}") sections.append(license["name"] + "\n" + "\n".join(sorted(users)) + "\n\n" + license["text"]) if expected - covered: raise RuntimeError("Rust normal dependency is missing a license notice: " + repr(sorted(expected - covered))) return "\n\n".join(sorted(set(sections))) def android_notices(rows, policy): common = (ROOT / "android/licenses/Apache-2.0.txt").read_text() extras = { "androidx.camera:camera-core:1.6.2": ("Apache-2.0 AND BSD-3-Clause", ["libyuv-LICENSE.txt", "libyuv-PATENTS.txt"]), "net.java.dev.jna:jna:5.19.1": ("Apache-2.0 OR LGPL-2.1-or-later", ["jna-LICENSE.txt", "libffi.txt"]), "org.checkerframework:checker-qual:3.43.0": ("MIT", ["checker-qual.txt"]), "rustls:rustls-platform-verifier:0.1.1": ("MIT OR Apache-2.0", []), } sections = ["Apache License 2.0 (selected where a dual license permits it)\n\n" + common] for row in sorted(rows, key=lambda r: r["component"]): component = row["component"] license = policy.get(component) if license is None or (license != "Apache-2.0" and extras.get(component, (None,))[0] != license): raise RuntimeError("Android notice needs review: " + component) notices = [] with zipfile.ZipFile(row["path"]) as archive: for name in sorted(archive.namelist()): if re.search(r"(?i)(?:^|/)(?:licen[cs]e|notice|copying|copyright|al2\.0)(?:[._-].*)?$", name): entry = archive.getinfo(name) if entry.is_dir() or entry.file_size > 1024 * 1024: raise RuntimeError("Unexpected Android notice archive entry") notices.append(archive.read(name).decode("utf-8")) for name in extras.get(component, (None, []))[1]: notices.append((ROOT / "android/licenses" / name).read_text()) sections.append(component + "\nLicense: " + license + "\n\n" + "\n\n".join(sorted(set(notices)))) return "\n\n".join(dict.fromkeys(sections)) def generate(inventory, output): if platform.machine() not in ("arm64", "aarch64"): raise RuntimeError("License tools require the supported native ARM build host") scratch = ROOT / "android/build/license-tools" scratch.mkdir(parents=True, exist_ok=True) archive_path = scratch / "cargo-about.tar.gz" spec = json.loads((ROOT / "android/license-tool.json").read_text())[platform.system()] download(spec, archive_path) with tarfile.open(archive_path) as archive: members = [m for m in archive.getmembers() if Path(m.name).name == "cargo-about" and m.isfile()] if len(members) != 1 or members[0].size > 100 * 1024 * 1024: raise RuntimeError("Unexpected cargo-about package") binary = scratch / "cargo-about" binary.write_bytes(archive.extractfile(members[0]).read()) binary.chmod(0o755) data = json.loads(subprocess.check_output([str(binary), "generate", "--locked", "--fail", "--manifest-path", str(ROOT / "crates/android/Cargo.toml"), "--config", str(ROOT / "android/about.toml"), "--format", "json"])) expected = set() workspace = json.loads(subprocess.check_output([CARGO, "metadata", "--locked", "--no-deps", "--format-version", "1"])) local_names = {package["name"] for package in workspace["packages"] if package["source"] is None} for triple in ("aarch64-linux-android", "armv7-linux-androideabi"): tree = subprocess.check_output([CARGO, "tree", "--locked", "-p", "ironstorage-android", "--target", triple, "--edges", "normal", "--prefix", "none", "--format", "{p}"], text=True) for line in tree.splitlines(): match = re.match(r"([^ ]+) v([^ ]+)", line) if match and match[1] not in local_names: expected.add((match[1], match[2])) rows = json.loads(inventory.read_text()) if not rows: raise RuntimeError("Android release notice inventory is empty") text = "IronStorage — Third-party notices\n\nAndroid release libraries\n\n" text += android_notices(rows, json.loads((ROOT / "android/dependency-policy.json").read_text())["android"]) text += "\n\nRust ARM libraries\n\n" + rust_notices(data, expected) output.parent.mkdir(parents=True, exist_ok=True) output.write_text(text, encoding="utf-8") print(f"Packaged full notices for {len({r['component'] for r in rows})} Android and {len(expected)} Rust components") if __name__ == "__main__": generate(Path(sys.argv[1]), Path(sys.argv[2]))