git mirror - github.com/owenewans/holy - branch master
clone: https://src.holypkg.eu/holy/

file tests/conflict.py

#!/usr/bin/env python3
"""reports the conflicts one installed set can leave behind: two providers of one
SONAME, two providers of one package name, two declared owners of one file path and
two private programs of one name."""
import hashlib
import json
import pathlib
import subprocess
import sys
import tempfile


binary = str(pathlib.Path(sys.argv[1]).resolve())


def call(*args, status=0):
    result = subprocess.run([binary, *map(str, args)], capture_output=True, text=True)
    assert result.returncode == status, (args, result.returncode, result.stdout, result.stderr)
    return result.stdout


def digest(path):
    return hashlib.sha256(path.read_bytes()).hexdigest()


def program(relative):
    """a path a run PATH lookup would resolve, which a package claims as a program"""
    parts = relative.split("/")
    return parts[-2:-1] and parts[-2] in ("bin", "sbin")


def build(root, tree, name, *, files, provides="", deps="", arch="noarch", libc="nolibc",
          label=None):
    """one package tree, packed and staged in the target cache"""
    import shutil
    shutil.rmtree(tree, ignore_errors=True)
    (tree / "HOLY").mkdir(parents=True)
    (tree / "DATA").mkdir(parents=True)
    (tree / "HOLY/meta").write_text(
        "format holy-package-1\nname %s\nversion 1\nrelease 1\nos linux\narch %s\nlibc %s\n"
        % (name, arch, libc))
    for field, body in (("deps", deps), ("provides", provides),
                        ("hooks", ""), ("origin", ""), ("transform", "")):
        (tree / "HOLY" / field).write_text(body)
    for relative, body in files.items():
        path = tree / "DATA" / relative
        path.parent.mkdir(parents=True, exist_ok=True)
        if isinstance(body, bytes):
            path.write_bytes(body)
        else:
            path.write_text(body)
        path.chmod(0o755 if program(relative) else 0o644)
    label = label or name
    call("manifest", "generate", tree, "--output", root / (label + "-files"))
    (tree / "HOLY/files").write_text((root / (label + "-files")).read_text())
    artifact = root / (label + ".holy")
    call("pack", tree, "--output", artifact)
    call("cache", "stage", "local:" + str(artifact), "--root", root)
    return digest(artifact)


def install(root, plan_digests):
    """plan and apply one set; every candidate is selected, so two roots conflict"""
    out = call("db", "plan-set", *plan_digests, "--root", root)
    plan = [line.split()[10] for line in out.splitlines() if line.startswith("plan-set ")][0]
    call("db", "apply-set", plan, *plan_digests, "--root", root)
    return out


def findings(root):
    """the report over a root that has a conflict, which is status 1"""
    text = call("conflict", "--root", root, "--json", status=1)
    return [record for record in (json.loads(line) for line in text.splitlines())
            if record["type"] == "finding"]


def main():
    with tempfile.TemporaryDirectory() as scratch:
        base = pathlib.Path(scratch)
        root = base / "root"
        (root / "usr/share").mkdir(parents=True)
        tree = base / "tree"
        call("db", "init", "--root", root)

        # a clean root has no conflicts
        report = call("conflict", "--root", root, "--json")
        summary = [json.loads(line) for line in report.splitlines()][-1]
        assert summary["type"] == "summary" and summary["findings"] == 0, summary
        assert summary["installed"] == 0, summary

        # one library and one application that needs it
        lib = build(root, tree, "conflictor", files={"usr/share/conflictor": "the library\n"},
                    provides="provide package conflictor noarch nolibc - metadata\n"
                             "provide soname libconflictor.so.1 noarch nolibc - metadata\n")
        app = build(root, tree, "consumer", files={"usr/share/consumer": "the application\n"},
                    deps="require dep-1 consumer package conflictor noarch nolibc any - "
                         "conflictor metadata\n")
        out = install(root, [app, lib])
        assert "requirement dep-1 consumer %s provider %s package conflictor" % (
            app, lib) in out, out
        # one owner and one dependent, so nothing collides
        call("conflict", "--root", root, "--json")
        text = call("conflict", "--root", root)
        assert "conflicts 0 read-only" in text, text
        assert "installed 2" in text, text

        # a second library with the same SONAME, installed as its own root
        rival = build(root, tree, "conflictor-rival",
                      files={"usr/share/conflictor-rival": "the rival library\n"},
                      provides="provide package conflictor-rival noarch nolibc - metadata\n"
                               "provide soname libconflictor.so.1 noarch nolibc - metadata\n")
        call("db", "plan-set", rival, "--root", root)
        out = call("db", "apply-set",
                   [line.split()[10] for line in
                    call("db", "plan-set", rival, "--root", root).splitlines()
                    if line.startswith("plan-set ")][0], rival, "--root", root)
        assert "committed-set" in out, out
        found = findings(root)
        names = sorted((record["kind"], record["name"], record["reason"]) for record in found)
        assert names == [("soname", "libconflictor.so.1", "duplicate-provider")], found
        soname = found[0]
        assert [provider["artifact"] for provider in soname["providers"]] == sorted(
            [lib, rival]), soname
        for provider in soname["providers"]:
            assert provider["arch"] == "noarch" and provider["libc"] == "nolibc", provider
        text = call("conflict", "--root", root, status=1)
        assert "conflict soname \"libconflictor.so.1\" providers 2 reason duplicate-provider" \
               in text, text
        assert "conflicts 1 read-only" in text, text
        # the application still resolves, and its provider is the one the set chose
        assert "provider %s" % lib in text, text

        # a different SONAME from another runtime is a different capability, since
        # noarch carries no runtime
        musl_rival = build(root, base / "tree2", "conflictor-musl",
                           files={"usr/share/conflictor-musl": "the musl library\n"},
                           provides="provide package conflictor-musl noarch nolibc - metadata\n"
                                    "provide soname libconflictor-musl.so.1 noarch nolibc - metadata\n")
        install(root, [musl_rival])
        found = findings(root)
        assert [record["kind"] for record in found] == ["soname"], found
        assert found[0]["name"] == "libconflictor.so.1", found[0]

        # two artifacts of different names both claiming one package name, which is
        # what a mixed provider looks like once both are installed
        other_root = base / "other-root"
        (other_root / "usr/share").mkdir(parents=True)
        call("db", "init", "--root", other_root)
        mixed_a = build(other_root, base / "tree3", "alpha", files={"usr/share/alpha": "a\n"},
                        provides="provide package shared noarch nolibc - metadata\n")
        mixed_b = build(other_root, base / "tree4", "beta", files={"usr/share/beta": "b\n"},
                        provides="provide package shared noarch nolibc - metadata\n")
        install(other_root, [mixed_a])
        install(other_root, [mixed_b])
        found = findings(other_root)
        assert [(record["kind"], record["name"], record["reason"]) for record in found] == [
            ("package", "shared", "mixed-providers")], found
        assert sorted(provider["artifact"] for provider in found[0]["providers"]) == sorted(
            [mixed_a, mixed_b]), found[0]
        # the slot of each name is its own, so the set is not otherwise a conflict
        text = call("conflict", "--root", other_root, status=1)
        assert "installed 2" in text, text

        # two providers of one SONAME with different ABIs is a mismatch, not a duplicate
        abi_root = base / "abi-root"
        (abi_root / "usr/share").mkdir(parents=True)
        call("db", "init", "--root", abi_root)
        abi_a = build(abi_root, base / "tree5", "abi-a", files={"usr/share/abi-a": "a\n"},
                      provides="provide soname libabi.so.1 x86_64 glibc - metadata\n",
                      arch="x86_64", libc="glibc")
        abi_b = build(abi_root, base / "tree6", "abi-b", files={"usr/share/abi-b": "b\n"},
                      provides="provide soname libabi.so.1 x86_64 musl - metadata\n",
                      arch="x86_64", libc="musl")
        # one set carries one root, so the second ABI arrives in the set after the first
        install(abi_root, [abi_a])
        install(abi_root, [abi_b])
        found = findings(abi_root)
        assert [(record["kind"], record["reason"]) for record in found] == [
            ("soname", "abi-mismatch")], found
        assert "reason abi-mismatch" in call("conflict", "--root", abi_root, status=1)

        # two declared owners of one public file path
        file_root = base / "file-root"
        (file_root / "usr/share").mkdir(parents=True)
        call("db", "init", "--root", file_root)
        path_a = build(file_root, base / "tree7", "path-a",
                       files={"usr/share/shared-a.conf": "a\n"},
                       provides="provide file /usr/share/shared.conf noarch nolibc - metadata\n")
        path_b = build(file_root, base / "tree8", "path-b",
                       files={"usr/share/shared-b.conf": "b\n"},
                       provides="provide file /usr/share/shared.conf noarch nolibc - metadata\n")
        install(file_root, [path_a])
        install(file_root, [path_b])
        found = findings(file_root)
        assert [(record["kind"], record["name"]) for record in found] == [
            ("file", "/usr/share/shared.conf")], found
        assert found[0]["reason"] == "duplicate-provider", found[0]

        # two private programs of one name, which a run PATH resolves by sort order
        private_root = base / "private-root"
        (private_root / "usr/share").mkdir(parents=True)
        call("db", "init", "--root", private_root)
        shell = None
        source = base / "sh.c"
        source.write_text("int main(void) { return 0; }\n")
        for compiler in ("cc", "gcc", "clang"):
            if subprocess.run(["which", compiler], capture_output=True).returncode:
                continue
            built = subprocess.run([compiler, "-static", str(source), "-o", str(
                base / "sh")], capture_output=True)
            if built.returncode == 0:
                shell = base / "sh"
                break
        assert shell, "a static shell is required for the private program fixtures"
        source.write_text('#include <stdio.h>\nint main(void) { puts("tool"); return 0; }\n')
        tool = None
        for compiler in ("cc", "gcc", "clang"):
            if subprocess.run(["which", compiler], capture_output=True).returncode:
                continue
            built = subprocess.run([compiler, "-static", str(source), "-o", str(base / "tool")],
                                   capture_output=True)
            if built.returncode == 0:
                tool = base / "tool"
                break
        assert tool, "a static program is required for the private program fixtures"
        first = build(private_root, base / "tree9", "private-a", arch="x86_64", files={
            "usr/lib/holy/private/private-a/usr/bin/tool": tool.read_bytes()})
        second = build(private_root, base / "tree10", "private-b", arch="x86_64", files={
            "usr/lib/holy/private/private-b/usr/bin/tool": tool.read_bytes()})
        install(private_root, [first])
        install(private_root, [second])
        found = findings(private_root)
        kinds = sorted((record["kind"], record["name"], record["reason"]) for record in found)
        assert kinds == [("private-command", "tool", "shadowed-path")], found
        # a private program each package owns alone is not a conflict
        single = build(private_root, base / "tree11", "private-c", arch="x86_64", files={
            "usr/lib/holy/private/private-c/usr/bin/other": tool.read_bytes()})
        install(private_root, [single])
        assert "conflicts 1 read-only" in call("conflict", "--root", private_root, status=1)

        # a report over a root with no database, and over a pending transaction
        empty = base / "empty-root"
        empty.mkdir()
        result = subprocess.run([binary, "conflict", "--root", empty], capture_output=True, text=True)
        assert result.returncode == 1, result
        assert "invalid-state" in result.stderr, result.stderr
        generation = int((private_root / "var/lib/holypkg/generation").read_text())
        journal = private_root / "var/lib/holypkg/transactions/journal"
        journal.write_text("format holy-journal-1\nstage applying\ngeneration %d\n"
                           "artifact %s\nplan %064d\n" % (generation, first, 2))
        result = subprocess.run([binary, "conflict", "--root", private_root],
                                capture_output=True, text=True)
        assert result.returncode == 5, result
        assert "incomplete-transaction" in result.stderr, result.stderr
        result = subprocess.run([binary, "conflict", "--root", private_root, "--json"],
                                capture_output=True, text=True)
        assert '"code":"incomplete-transaction"' in result.stdout, result.stdout
        journal.unlink()
        call("conflict", "--root", private_root, status=1)

        # an invalid argument is refused before the root is read
        result = subprocess.run([binary, "conflict", "--root"], capture_output=True, text=True)
        assert result.returncode == 2, result
        assert "usage: holypkg conflict" in result.stderr, result.stderr

    print("conflict fixtures passed")
    return 0


if __name__ == "__main__":
    sys.exit(main())