diff --git a/tools/fedora-helper.py b/tools/fedora-helper.py new file mode 100755 index 0000000..23a3d59 --- /dev/null +++ b/tools/fedora-helper.py @@ -0,0 +1,176 @@ +#!/usr/bin/python3 + +import collections +import os +from pathlib import Path +import sys +import tempfile + +import click +import requests +from requests.compat import urljoin +import solv + +XDG_CACHE_HOME = Path(os.getenv("XDG_CACHE_HOME", os.path.expanduser("~/.cache"))) +CACHEDIR = XDG_CACHE_HOME / "rust2rpm" + +# Arch that is used for resolving dependencies and such +ARCH = "x86_64" +# The repo that contains crates +REPO_URL = f"https://kojipkgs.fedoraproject.org/repos/f33-build/latest/{ARCH}/" +# Just some sane chunk size +CHUNK_SIZE = 8192 + + +def _download(fname, chksum=None, uncompress=False): + url = urljoin(REPO_URL, fname) + with requests.get(urljoin(REPO_URL, fname), stream=True) as r: + tmp = tempfile.TemporaryFile() + total = int(r.headers["Content-Length"]) + with click.progressbar( + length=total, label=f"Downloading {url}", file=sys.stderr + ) as pb: + for chunk in r.iter_content(chunk_size=CHUNK_SIZE): + pb.update(tmp.write(chunk)) + tmp.seek(0) + + if chksum is not None: + fchksum = solv.Chksum(chksum.type) + fchksum.add_fd(tmp.fileno()) + if chksum != fchksum: + raise Exception(f"Checksums do not match: {fchksum} != {chksum}") + + return solv.xfopen_fd(os.path.basename(url) if uncompress else None, tmp.fileno()) + + +@click.group() +def cli(): + pass + + +@cli.command() +@click.pass_context +@click.argument("source", type=click.Path(exists=True)) +@click.option( + "-a", + "--add", + multiple=True, + type=click.Path(exists=True), + help="Add local RPMs to the pool that is used in dependency resolution.", +) +@click.option( + "-v", "--verbose", is_flag=True, help="Enable verbose mode.", +) +def get_binary_license(ctx, source, add, verbose): + """Get the resulting license of a binary. + + Since all crates (aka `rust-*-devel`) are linked statically, the resulting + binary license should be result of combining source license and all used + crates. + + This command resolves dependencies and outputs final license(s). + """ + os.makedirs(CACHEDIR, exist_ok=True) + + pool = solv.Pool() + pool.setarch(ARCH) + + repo = pool.add_repo("upstream") + fd = _download("repodata/repomd.xml") + chksum = solv.Chksum(solv.REPOKEY_TYPE_SHA256) + chksum.add("1.1") + chksum.add_fd(fd.fileno()) + cookie = chksum.raw() + repo.add_repomdxml(fd) + fd.close() + + # Find "primary" + di = repo.Dataiterator_meta( + solv.REPOSITORY_REPOMD_TYPE, "primary", solv.Dataiterator.SEARCH_STRING + ) + di.prepend_keyname(solv.REPOSITORY_REPOMD) + for d in di: + dp = d.parentpos() + filename = dp.lookup_str(solv.REPOSITORY_REPOMD_LOCATION) + checksum = dp.lookup_checksum(solv.REPOSITORY_REPOMD_CHECKSUM) + # FIXME: Can filename be None? + + cache_file = CACHEDIR / "upstream.solv" + try: + with open(cache_file, "rb") as f: + clen = len(cookie) + f.seek(-clen, os.SEEK_END) + fcookie = f.read(clen) + if fcookie != cookie: + raise IOError("repomd.xml has changed") + f.seek(0) + fd = solv.xfopen_fd(None, f.fileno()) + repo.add_solv(fd) + fd.close() + cached = True + except IOError: + # Failed to load from cache + cached = False + + if not cached: + fd = _download(filename, chksum=checksum, uncompress=True) + repo.add_rpmmd(fd, None) + fd.close() + + os.makedirs(CACHEDIR, exist_ok=True) + with tempfile.NamedTemporaryFile(prefix=".newsolv-", dir=CACHEDIR) as tmp: + fd = solv.xfopen_fd(None, tmp.fileno()) + repo.write(fd) + fd.flush() + fd.write(cookie) + fd.close() + if repo.iscontiguous(): + repo.empty() + repo.add_solv(tmp.name) + os.unlink(cache_file) + os.link(tmp.name, cache_file) + + repo_local = pool.add_repo("local") + repo_local.priority = 99 + s = repo.add_rpm(source) + for f in add: + repo.add_rpm(f) + + pool.addfileprovides() + pool.createwhatprovides() + + solver = pool.Solver() + solver.set_flag(solv.Solver.SOLVER_FLAG_IGNORE_RECOMMENDED, True) + problems = solver.solve( + [pool.Job(solv.Job.SOLVER_SOLVABLE | solv.Job.SOLVER_INSTALL, s.id)] + ) + if problems: + click.echo("Failed to resolve dependencies:", err=True) + for problem in problems: + for rule in problem.findallproblemrules(): + for info in rule.allinfos(): + click.echo(f" - ", nl=False) + click.secho(info.problemstr(), fg="red") + ctx.abort() + + licenses = collections.defaultdict(set) + for p in solver.transaction().newsolvables(): + if ( + not (p.name.startswith("rust-") and p.name.endswith("-devel")) + and not p == s + ): + continue + licenses[p.lookup_str(solv.SOLVABLE_LICENSE)].add(p) + # XXX: This is pure hack and we should optimize license license in much better way + if "MIT or ASL 2.0" in licenses and "ASL 2.0 or MIT" in licenses: + licenses["MIT or ASL 2.0"].update(licenses.pop("ASL 2.0 or MIT")) + + for license, packages in sorted(licenses.items()): + click.echo(f"# {license}") + if verbose: + for package in packages: + click.secho(f"# * {package}", fg="white") + + +if __name__ == "__main__": + cli()