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

file tests/hardlink-updates.py

#!/usr/bin/env python3
import hashlib
import io
import os
import pathlib
import shlex
import subprocess
import sys
import tarfile
import tempfile

binary = str(pathlib.Path(sys.argv[1]).resolve())
with tempfile.TemporaryDirectory(prefix="holy-hardlink-updates-") as scratch:
    tmp = pathlib.Path(scratch)

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

    def package(label, payload, mode=0o640):
        source = tmp / (label + ".pkg")
        dirs = {str(parent) for path in payload for parent in pathlib.PurePosixPath(path).parents if str(parent) != "."}
        with tarfile.open(source, "w") as archive:
            metadata = ("pkgname = links\npkgver = " + label + "-1\narch = any\n").encode()
            for path in [".PKGINFO", *sorted(dirs), *sorted(payload)]:
                entry = tarfile.TarInfo(path)
                entry.uid, entry.gid = os.getuid(), os.getgid()
                entry.mode = mode
                data = metadata if path == ".PKGINFO" else payload.get(path)
                if path in dirs:
                    entry.type, entry.mode = tarfile.DIRTYPE, 0o755
                elif isinstance(data, str):
                    entry.type, entry.linkname = tarfile.LNKTYPE, data
                    data = None
                elif isinstance(data, tuple):
                    entry.type, entry.linkname, entry.mode = tarfile.SYMTYPE, data[1], 0o777
                    data = None
                else:
                    entry.size = len(data)
                archive.addfile(entry, io.BytesIO(data) if data is not None else None)
        output = tmp / label
        run("import", source, "--source", "fixture", "--format", "pacman", "--output", output)
        artifact, = output.glob("*.holy")
        return artifact, hashlib.sha256(artifact.read_bytes()).hexdigest(), payload, mode

    old = package("1", {"opt/a": "opt/z", "opt/b": "opt/z", "opt/z": b"old\n", "opt/keep": b"keep\n"})
    new = package("2", {"opt/a": "opt/z", "opt/b": "opt/z", "opt/z": b"new\n", "opt/keep": b"keep\n"})
    added = package("3", {**new[2], "opt/new/d": "opt/z"})
    moved = package("4", {"opt/a": "opt/new/q", "opt/z": "opt/new/q", "opt/new/d": "opt/new/q",
                          "opt/new/q": b"new\n", "opt/keep": b"keep\n"})
    split = package("5", {path: b"new\n" if path != "opt/keep" else b"keep\n" for path in moved[2]})
    merged = package("6", {"opt/a": b"new\n", "opt/z": "opt/a", "opt/new/d": "opt/a",
                           "opt/new/q": "opt/a", "opt/keep": b"keep\n"})
    linked = package("7", {"opt/a": ("symlink", "keep"), "opt/keep": b"keep\n"})
    mode_changed = package("8", new[2], mode=0o600)
    versions = [old, new, added, moved, split, merged, linked, mode_changed]

    def prepare(label, installed=old):
        root = tmp / label
        root.mkdir()
        run("db", "init", "--root", root)
        for artifact, _, _, _ in versions:
            run("cache", "stage", "local:" + str(artifact), "--root", root)
        plan = run("db", "plan-set", installed[1], "--root", root).split(" sha256 ")[1].split()[0]
        run("db", "apply-set", plan, installed[1], "--root", root)
        return root

    def check(root, current, previous=None):
        run("db", "check", "--all", "--root", root)
        groups = {}
        for path, value in current[2].items():
            node = root / path
            if isinstance(value, tuple):
                assert node.is_symlink() and os.readlink(node) == value[1]
                continue
            target = value if isinstance(value, str) else path
            assert node.read_bytes() == current[2][target]
            st = node.stat()
            assert st.st_mode & 0o777 == current[3]
            identity = st.st_dev, st.st_ino
            if target in groups:
                assert groups[target] == identity, (path, target)
            else:
                assert identity not in groups.values(), (path, groups)
                groups[target] = identity
        if previous:
            for path in previous[2].keys() - current[2].keys():
                assert not os.path.lexists(root / path)
        assert not list(root.rglob(".holy-update-*"))
        assert not (root / "var/lib/holypkg/transactions/update").exists()

    def plan(root, before, after):
        record = run("db", "plan-update", before[1], after[1], "--root", root)
        if any(isinstance(value, str) for value in after[2].values()):
            assert "group-stage " in record
        return record.split(" sha256 ")[1].split()[0]

    root = prepare("regular")
    outside = tmp / "external"
    os.link(root / "opt/z", outside)
    previous = old
    for following in [new, added, moved, split, merged, linked, mode_changed, old]:
        approved = plan(root, previous, following)
        run("db", "apply-update", approved, previous[1], following[1], "--root", root)
        check(root, following, previous)
        assert outside.read_bytes() == b"old\n"
        previous = following
    run("db", "rm", previous[1], "--root", root)
    invalid = prepare("unexpected-sharing", split)
    (invalid / "opt/z").unlink()
    os.link(invalid / "opt/a", invalid / "opt/z")
    run("db", "check", "--all", "--root", invalid, status=4)
    run("db", "plan-update", split[1], merged[1], "--root", invalid, status=4)
    assert (invalid / "opt/a").stat().st_ino == (invalid / "opt/z").stat().st_ino

    print("hardlink content, membership, anchor moves, split/merge, modes and downgrade passed")

    dynamic = "interpreter /" in run("elf", binary)
    if dynamic or os.environ.get("HOLY_TEST_STATIC_UPDATE_FAULT") == "1":
        environment = os.environ.copy()
        if dynamic:
            library = tmp / "fault.so"
            subprocess.run(["gcc", "-shared", "-fPIC", "-o", str(library),
                str(pathlib.Path(__file__).with_name("update-fault.c")), "-ldl"], check=True)
            environment["LD_PRELOAD"] = str(library)
        for phase in ("intent-after", "hardlink-before", "hardlink-after", "payload-before", "payload-after",
                      "database-before", "database-after", "generation-after", "group-cleanup", "committed", "no-space", "staging-partial"):
            root = prepare("fault-" + phase)
            approved = plan(root, old, new)
            environment.update(HOLY_UPDATE_FAULT=phase, HOLY_UPDATE_NEW=new[1])
            run("db", "apply-update", approved, old[1], new[1], "--root", root,
                env=environment, status=5 if phase == "no-space" else -9)
            run("db", "status", "--root", root, status=5)
            if phase == "staging-partial":
                partial, = root.rglob(".holy-update-*")
                before = partial.read_bytes()
                run("db", "recover", "--update", "--root", root, status=5)
                assert partial.read_bytes() == before and (root / "opt/a").read_bytes() == b"old\n"
                partial.unlink()
            run("db", "recover", "--update", "--root", root)
            check(root, new, old)
        for before, after in ((new, moved), (moved, split), (split, merged), (old, mode_changed)):
            preview_root = prepare("count-" + before[1][:8], before)
            record = run("db", "plan-update", before[1], after[1], "--root", preview_root)
            records = [shlex.split(line) for line in record.splitlines()]
            steps = sum(1 for i, row in enumerate(records) if row and row[0] == "change" and row[2] != "retain"
                        and records[i + 1][1] != "dir" and records[i + 2][1] != "dir")
            assert steps
            for step in range(1, steps + 1):
                root = prepare("step-" + before[1][:8] + "-" + str(step), before)
                approved = plan(root, before, after)
                environment.update(HOLY_UPDATE_FAULT="steps", HOLY_UPDATE_STEP=str(step), HOLY_UPDATE_NEW=after[1])
                run("db", "apply-update", approved, before[1], after[1], "--root", root, env=environment, status=-9)
                run("db", "recover", "--update", "--root", root)
                check(root, after, before)
            environment.pop("HOLY_UPDATE_STEP")
        for damaged in ("old-group", "witness", "alias-stage"):
            root = prepare("damaged-" + damaged)
            approved = plan(root, old, new)
            environment.update(HOLY_UPDATE_FAULT="hardlink-after" if damaged == "old-group" else "payload-after",
                               HOLY_UPDATE_NEW=new[1])
            run("db", "apply-update", approved, old[1], new[1], "--root", root, env=environment, status=-9)
            witness, = root.rglob(".holy-update-*-group")
            if damaged == "old-group":
                path, source = root / "opt/b", root / "opt/z"
            elif damaged == "witness":
                path, source = witness, root / "opt/a"
            else:
                path = next(path for path in root.rglob(".holy-update-*") if not path.name.endswith("-group"))
                source = witness
            content, mode = path.read_bytes(), path.stat().st_mode & 0o777
            path.unlink()
            path.write_bytes(content)
            path.chmod(mode)
            original = [(root / name).read_bytes() for name in ("opt/a", "opt/b", "opt/z")]
            run("db", "recover", "--update", "--root", root, status=5)
            assert original == [(root / name).read_bytes() for name in ("opt/a", "opt/b", "opt/z")]
            assert path.stat().st_ino != source.stat().st_ino
            path.unlink()
            os.link(source, path)
            run("db", "recover", "--update", "--root", root)
            check(root, new, old)
        print("hardlink update per-file interruption, topology drift and recovery passed")
    else:
        print("hardlink update fault injection skipped for uninstrumented static client")