scripts/m2image_compliance.py
Rama main · réplica de lectura del repositorio original.
#!/usr/bin/env python3
"""Build deterministic per-M2Image license evidence and source-provenance metadata.
This does not try to decide whether a distribution's source-availability terms
satisfy a particular license. It records the exact package/source identity that
the image build observed and packages license/copyright material present in the
built guest, plus FireCrab's canonical GPL-2.0 text. Release publication can use
source-map.json as the mechanical input for corresponding-source handling.
"""
from __future__ import annotations
import argparse
import hashlib
import json
import shutil
import sys
from pathlib import Path
from urllib.parse import parse_qs, quote, urlsplit
LEGAL_PREFIXES = (
"license",
"licence",
"copying",
"copyright",
"notice",
"authors",
)
RPM_KEY_METADATA_DISPOSITION = "rpm-key-metadata"
def _read_json(path: Path) -> dict:
with path.open(encoding="utf-8") as stream:
return json.load(stream)
def _comment_fields(comment: str | None) -> dict[str, str]:
fields: dict[str, str] = {}
for part in (comment or "").split(";"):
if "=" not in part:
continue
key, value = part.split("=", 1)
key = key.strip()
value = value.strip()
if key and value:
fields[key] = value
return fields
def _package_architecture(package: dict, image_architecture: str) -> str:
"""Return the binary package architecture already encoded in its SPDX purl.
Older synthetic fixtures and pre-architecture SBOMs may not carry a purl;
keep the image architecture as a compatibility fallback for those inputs.
Real M2Image SBOMs emit ``?arch=...`` from the package database, preserving
values such as Rocky ``noarch`` instead of rewriting them to the image arch.
"""
for reference in package.get("externalRefs") or []:
if reference.get("referenceType") != "purl":
continue
locator = reference.get("referenceLocator")
if not isinstance(locator, str):
continue
try:
values = parse_qs(urlsplit(locator).query).get("arch")
except ValueError:
continue
if values and values[0]:
return values[0]
return image_architecture
def _source_resolver(distribution: str, source: str, source_version: str, commit: str) -> dict:
distro = distribution.lower()
if distro == "alpine":
resolver = {
"type": "alpine-aports",
"sourcePackage": source,
"sourceVersion": source_version,
}
if commit:
resolver["repositoryCommit"] = commit
resolver["metadataUrl"] = (
"https://gitlab.alpinelinux.org/alpine/aports/-/commit/"
+ quote(commit, safe="")
)
return resolver
if distro == "ubuntu":
return {
"type": "ubuntu-source-package",
"sourcePackage": source,
"sourceVersion": source_version,
"metadataUrl": (
"https://launchpad.net/ubuntu/+source/"
+ quote(source, safe="._+-")
+ "/"
+ quote(source_version, safe="._+:-~")
),
}
if distro == "rocky":
return {
"type": "rocky-source-rpm",
"sourceArtifact": source,
"sourceVersion": source_version,
}
return {
"type": "distribution-source-package",
"sourcePackage": source,
"sourceVersion": source_version,
}
def _non_source_disposition(distribution: str, package_name: str, disposition: str) -> str:
if (
distribution.lower() == "rocky"
and package_name == "gpg-pubkey"
and disposition == RPM_KEY_METADATA_DISPOSITION
):
return disposition
raise ValueError(
f"{package_name}: unsupported source-disposition {disposition!r}"
)
def source_map(spdx: dict) -> dict:
packages = spdx.get("packages") or []
if len(packages) < 2:
raise ValueError("SPDX document contains no installed guest packages")
image = packages[0]
image_fields = _comment_fields(image.get("comment"))
distribution = image_fields.get("distribution", "unknown")
architecture = image_fields.get("architecture", "unknown")
records = []
for package in packages[1:]:
fields = _comment_fields(package.get("comment"))
package_name = str(package.get("name") or "<unknown>")
binary_version = str(package.get("versionInfo") or "unknown")
package_architecture = _package_architecture(package, architecture)
disposition = fields.get("source-disposition", "")
if disposition:
records.append(
{
"binaryPackage": package.get("name"),
"binaryVersion": binary_version,
"architecture": package_architecture,
"declaredLicense": fields.get("package-manager-license") or None,
"sourceDisposition": _non_source_disposition(
distribution, package_name, disposition
),
}
)
continue
source = fields.get("source-package")
if not source:
raise ValueError(
f"{package_name}@{binary_version}: missing source-package identity"
)
source_version = fields.get("source-version") or binary_version
source_commit = fields.get("source-commit", "")
records.append(
{
"binaryPackage": package.get("name"),
"binaryVersion": binary_version,
"architecture": package_architecture,
"declaredLicense": fields.get("package-manager-license") or None,
"source": _source_resolver(
distribution, source, source_version, source_commit
),
}
)
records.sort(key=lambda item: (str(item["binaryPackage"]), item["binaryVersion"]))
return {
"schemaVersion": 1,
"image": {
"alias": image.get("name"),
"version": image.get("versionInfo"),
"distribution": distribution,
"architecture": architecture,
},
"packages": records,
}
def _legal_file(path: Path, legal_root: Path) -> bool:
rel = path.relative_to(legal_root)
parts = rel.parts
if len(parts) >= 3 and parts[:3] == ("usr", "share", "licenses"):
return True
if len(parts) >= 3 and parts[:3] == ("usr", "share", "common-licenses"):
return True
if len(parts) >= 3 and parts[:3] == ("usr", "share", "spdx"):
return True
if len(parts) >= 3 and parts[:3] == ("usr", "share", "doc"):
name = path.name.lower()
return name.startswith(LEGAL_PREFIXES)
return False
def _copy_guest_legal_files(legal_root: Path, destination: Path) -> list[dict]:
if not legal_root.is_dir():
return []
records = []
for path in sorted(legal_root.rglob("*")):
if not path.is_file() or path.is_symlink() or not _legal_file(path, legal_root):
continue
rel = path.relative_to(legal_root)
output = destination / "guest" / rel
output.parent.mkdir(parents=True, exist_ok=True)
shutil.copyfile(path, output)
data = output.read_bytes()
records.append(
{
"guestPath": "/" + rel.as_posix(),
"bundlePath": output.relative_to(destination.parent).as_posix(),
"bytes": len(data),
"sha256": hashlib.sha256(data).hexdigest(),
}
)
return records
def build_bundle(*, spdx_path: Path, legal_root: Path, gpl2_text: Path, output_dir: Path) -> dict:
spdx = _read_json(spdx_path)
if spdx.get("spdxVersion") != "SPDX-2.3":
raise ValueError("M2Image SBOM is not SPDX 2.3")
if not gpl2_text.is_file():
raise ValueError(f"canonical GPL-2.0 text not found: {gpl2_text}")
if output_dir.exists():
shutil.rmtree(output_dir)
licenses_dir = output_dir / "licenses"
licenses_dir.mkdir(parents=True)
source_document = source_map(spdx)
(output_dir / "source-map.json").write_text(
json.dumps(source_document, indent=2, sort_keys=True) + "\n",
encoding="utf-8",
)
records = _copy_guest_legal_files(legal_root, licenses_dir)
gpl_target = licenses_dir / "GPL-2.0-only.txt"
shutil.copyfile(gpl2_text, gpl_target)
gpl_data = gpl_target.read_bytes()
records.append(
{
"guestPath": None,
"bundlePath": "licenses/GPL-2.0-only.txt",
"bytes": len(gpl_data),
"sha256": hashlib.sha256(gpl_data).hexdigest(),
"provenance": "FireCrab canonical license text",
}
)
records.sort(key=lambda item: item["bundlePath"])
(licenses_dir / "index.json").write_text(
json.dumps({"schemaVersion": 1, "files": records}, indent=2, sort_keys=True)
+ "\n",
encoding="utf-8",
)
package_count = len(source_document["packages"])
summary = {
"schemaVersion": 1,
"image": source_document["image"],
"packageCount": package_count,
"guestLegalFileCount": sum(1 for item in records if item.get("guestPath")),
"licenseFileCount": len(records),
"sourceMap": "source-map.json",
"licenseIndex": "licenses/index.json",
}
(output_dir / "bundle.json").write_text(
json.dumps(summary, indent=2, sort_keys=True) + "\n", encoding="utf-8"
)
(output_dir / "README.txt").write_text(
"FireCrab M2Image compliance bundle\n"
"=================================\n\n"
"sbom.spdx.json identifies the installed binary packages. source-map.json\n"
"records the package-manager source identity or an explicit non-source\n"
"disposition observed for every binary. licenses/ contains copyright/license\n"
"material recovered from the built guest plus FireCrab's canonical GPL-2.0\n"
"text. Source-map metadata is an input to release corresponding-source\n"
"publication; it is not itself a statement that corresponding-source\n"
"obligations have been satisfied.\n",
encoding="utf-8",
)
shutil.copyfile(spdx_path, output_dir / "sbom.spdx.json")
return summary
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--sbom", type=Path, required=True)
parser.add_argument("--legal-root", type=Path, required=True)
parser.add_argument("--gpl2-text", type=Path, required=True)
parser.add_argument("--output-dir", type=Path, required=True)
args = parser.parse_args(argv)
try:
summary = build_bundle(
spdx_path=args.sbom,
legal_root=args.legal_root,
gpl2_text=args.gpl2_text,
output_dir=args.output_dir,
)
except (OSError, ValueError, json.JSONDecodeError) as exc:
print(f"m2image compliance: {exc}", file=sys.stderr)
return 2
print(
"m2image compliance: "
f"{summary['packageCount']} packages, "
f"{summary['guestLegalFileCount']} guest legal files -> {args.output_dir}"
)
return 0
if __name__ == "__main__":
raise SystemExit(main())