166 lines
6.2 KiB
Python
166 lines
6.2 KiB
Python
"""Create a hash-backed, non-legal triage report for unresolved Flutter licenses."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import re
|
|
from datetime import UTC, datetime
|
|
from pathlib import Path
|
|
|
|
try:
|
|
from scripts.generate_component_inventory import (
|
|
digest,
|
|
flutter_license_file,
|
|
package_roots,
|
|
parse_lockfile,
|
|
)
|
|
except ModuleNotFoundError: # direct execution as `python scripts/...py`
|
|
from generate_component_inventory import (
|
|
digest,
|
|
flutter_license_file,
|
|
package_roots,
|
|
parse_lockfile,
|
|
)
|
|
|
|
|
|
def license_signatures(text: str) -> dict[str, str]:
|
|
"""Return conservative SPDX-like signatures found anywhere in the file.
|
|
|
|
Suggestions are for human triage. They do not establish provenance, package
|
|
applicability, exceptions, or permission to redistribute.
|
|
"""
|
|
normalized = re.sub(r"(?m)^\s*(?://|#|\*)\s?", "", text).lower()
|
|
normalized = re.sub(r"\s+", " ", normalized)
|
|
|
|
matches: dict[str, str] = {}
|
|
if "mozilla public license version 2.0" in normalized:
|
|
matches["MPL-2.0"] = "Mozilla Public License 2.0 title"
|
|
if "apache license" in normalized and "version 2.0" in normalized:
|
|
matches["Apache-2.0"] = "Apache License 2.0 title"
|
|
if (
|
|
"permission is hereby granted, free of charge" in normalized
|
|
and "the software is provided" in normalized
|
|
and "in no event shall" in normalized
|
|
):
|
|
matches["MIT"] = "standard MIT grant and warranty disclaimer"
|
|
if (
|
|
"redistribution and use in source and binary forms" in normalized
|
|
and "neither the name" in normalized
|
|
and "disclaimer" in normalized
|
|
):
|
|
matches["BSD-3-Clause"] = "BSD three-clause endorsement restriction"
|
|
if (
|
|
"redistribution and use in source and binary forms" in normalized
|
|
and "neither the name" not in normalized
|
|
and "disclaimer" in normalized
|
|
and "provided that the following conditions are met" in normalized
|
|
):
|
|
matches["BSD-2-Clause"] = "BSD two-clause grant and disclaimer"
|
|
return matches
|
|
|
|
|
|
def candidate_license(text: str) -> tuple[str | None, str]:
|
|
"""Suggest one SPDX identifier only when exactly one signature is present."""
|
|
matches = license_signatures(text)
|
|
if len(matches) == 1:
|
|
license_id, basis = next(iter(matches.items()))
|
|
return license_id, basis
|
|
if len(matches) > 1:
|
|
return None, "multiple license signatures occur in this file; manual review required"
|
|
return None, "no conservative license-text signature matched"
|
|
|
|
|
|
def build_report(root: Path) -> dict[str, object]:
|
|
app_root = root / "flutter_app"
|
|
packages = parse_lockfile(app_root / "pubspec.lock")
|
|
roots = package_roots(app_root / ".dart_tool" / "package_config.json")
|
|
entries: list[dict[str, object]] = []
|
|
missing: list[dict[str, str]] = []
|
|
|
|
for package in packages:
|
|
name = package["name"]
|
|
package_root = roots.get(name)
|
|
license_file = flutter_license_file(package_root) if package_root else None
|
|
if license_file is None:
|
|
missing.append({"name": name, "version": package["version"]})
|
|
continue
|
|
|
|
text = license_file.read_text(encoding="utf-8", errors="replace")
|
|
candidate, basis = candidate_license(text)
|
|
signatures = license_signatures(text)
|
|
entries.append(
|
|
{
|
|
"name": name,
|
|
"version": package["version"],
|
|
"scope": package.get("dependency", "transitive"),
|
|
"license_file": license_file.name,
|
|
"license_file_sha256": digest(license_file),
|
|
"candidate_spdx": candidate,
|
|
"detected_spdx_candidates": sorted(signatures),
|
|
"candidate_basis": basis,
|
|
"status": (
|
|
"single signature candidate; human review required"
|
|
if candidate
|
|
else "composite or unclassified notice; manual review required"
|
|
),
|
|
}
|
|
)
|
|
|
|
counts: dict[str, int] = {}
|
|
signature_counts: dict[str, int] = {}
|
|
multi_signature_files = 0
|
|
for entry in entries:
|
|
candidate = entry["candidate_spdx"] or "unclassified"
|
|
counts[str(candidate)] = counts.get(str(candidate), 0) + 1
|
|
detected = entry["detected_spdx_candidates"]
|
|
if len(detected) > 1:
|
|
multi_signature_files += 1
|
|
for license_id in detected:
|
|
signature_counts[license_id] = signature_counts.get(license_id, 0) + 1
|
|
|
|
return {
|
|
"format": "flutter-license-triage-v2",
|
|
"generated_at": datetime.now(UTC).isoformat(),
|
|
"source": "flutter_app/pubspec.lock and resolved package LICENSE files",
|
|
"notice": (
|
|
"Text-signature suggestions only. Not legal advice, provenance verification, "
|
|
"or approval to redistribute. Review each package, source, notices, and terms."
|
|
),
|
|
"summary": {
|
|
"locked_packages": len(packages),
|
|
"packages_with_license_file": len(entries),
|
|
"packages_without_license_file": len(missing),
|
|
"candidate_counts": dict(sorted(counts.items())),
|
|
"multi_signature_files": multi_signature_files,
|
|
"detected_signature_counts": dict(sorted(signature_counts.items())),
|
|
},
|
|
"packages": sorted(entries, key=lambda item: str(item["name"]).lower()),
|
|
"missing_license_file": sorted(missing, key=lambda item: item["name"].lower()),
|
|
}
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument(
|
|
"--project-root",
|
|
type=Path,
|
|
default=Path(__file__).resolve().parents[1],
|
|
)
|
|
parser.add_argument("--output", type=Path, default=None)
|
|
args = parser.parse_args()
|
|
root = args.project_root.resolve()
|
|
output = args.output or root / "sbom" / "flutter-license-triage.json"
|
|
report = build_report(root)
|
|
output.parent.mkdir(parents=True, exist_ok=True)
|
|
output.write_text(
|
|
json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
|
|
)
|
|
print(json.dumps(report["summary"], ensure_ascii=False, sort_keys=True))
|
|
print(f"Wrote {output}")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|