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

file tests/split.py

#!/usr/bin/env python3
"""proposes the split outputs of a prepared package tree, and proves that a path no
rule settles is reported as a decision rather than assigned by a file extension."""
import pathlib
import shutil
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 build(sources, name, arch="x86_64"):
    """one prepared tree, as manifest generate and pack expect it"""
    tree = sources / f"tree-{name}"
    (tree / "HOLY").mkdir(parents=True)
    (tree / "DATA").mkdir()
    (tree / "HOLY/meta").write_text(
        f"format holy-package-1\nname {name}\nversion 1\nrelease 1\nos linux\n"
        f"arch {arch}\nlibc nolibc\n")
    for field in ("deps", "provides", "hooks", "origin", "transform", "files"):
        (tree / "HOLY" / field).write_text("")
    return tree


def put(tree, relative, body, mode=0o644):
    target = tree / "DATA" / relative
    target.parent.mkdir(parents=True, exist_ok=True)
    target.write_text(body)
    target.chmod(mode)


def compiler():
    for name in ("cc", "gcc", "clang"):
        if shutil.which(name):
            return name
    return None


def compile_into(cc, arguments, output):
    output.parent.mkdir(parents=True, exist_ok=True)
    result = subprocess.run([cc, *map(str, arguments)], capture_output=True, text=True)
    assert result.returncode == 0, (arguments, result.stdout, result.stderr)
    return output


def read(path):
    records = {}
    for line in path.read_text().splitlines():
        fields = line.split(" ", 2)
        records.setdefault(fields[0], []).append(line)
    return path.read_text()


def main():
    cc = compiler()
    if not cc:
        print("a host compiler required for the split fixture", file=sys.stderr)
        return 6
    with tempfile.TemporaryDirectory() as scratch:
        root = pathlib.Path(scratch)
        sources = root / "sources"
        sources.mkdir()

        # the payload a real project installs: a program, a versioned object, an
        # unversioned plugin, a static archive, headers, pkg-config metadata,
        # documentation, a license and a payload link
        tree = build(sources, "fixture")
        main_source = sources / "main.c"
        main_source.write_text("int main(void) { return 0; }\n")
        library_source = sources / "lib.c"
        library_source.write_text("int answer(void) { return 42; }\n")
        program = compile_into(cc, ["-static", str(main_source), "-o",
                                    str(tree / "DATA/usr/bin/fixture")],
                               tree / "DATA/usr/bin/fixture")
        program.chmod(0o755)
        library = compile_into(cc, ["-shared", "-fPIC", "-Wl,-soname,libfixture.so.1",
                                    str(library_source), "-o",
                                    str(tree / "DATA/usr/lib/libfixture.so.1")],
                               tree / "DATA/usr/lib/libfixture.so.1")
        library.chmod(0o755)
        plugin = compile_into(cc, ["-shared", "-fPIC", str(library_source), "-o",
                                   str(tree / "DATA/usr/lib/pluginfixture.so")],
                              tree / "DATA/usr/lib/pluginfixture.so")
        plugin.chmod(0o755)
        (tree / "DATA/usr/lib/libfixture.so").symlink_to("libfixture.so.1")
        object_file = compile_into(cc, ["-c", str(library_source), "-o",
                                        str(sources / "lib.o")], sources / "lib.o")
        archive = tree / "DATA/usr/lib/libfixture.a"
        archive.parent.mkdir(parents=True, exist_ok=True)
        result = subprocess.run(["ar", "rcs", str(archive), str(object_file)],
                                capture_output=True, text=True)
        assert result.returncode == 0, result.stderr
        put(tree, "usr/include/fixture.h", "int answer(void);\n")
        put(tree, "usr/lib/pkgconfig/fixture.pc", "Name: fixture\nVersion: 1\n")
        put(tree, "usr/share/man/man1/fixture.1", ".TH FIXTURE 1\n")
        put(tree, "usr/share/doc/fixture/README", "the manual page fixture\n")
        put(tree, "usr/share/licenses/fixture", "MIT\n")

        # a license and a runtime data file are not documentation, however a general
        # pattern would match them
        proposal = root / "proposal"
        report = call("split", tree, "--output", proposal, status=3)
        assert "paths 10 runtime 4 devel 2 docs 2 debug 0 decisions 2" in report, report
        body = proposal.read_text()
        assert body.startswith('format holy-split-1\nname "fixture"\n'), body
        # the runtime output keeps the program, the versioned object, the payload link
        # and the license
        for path, output, reason in (
                ("usr/bin/fixture", "fixture", "executable"),
                ("usr/lib/libfixture.so.1", "fixture", "versioned shared object"),
                ("usr/lib/libfixture.so", "fixture", "payload link"),
                ("usr/share/licenses/fixture", "fixture", "runtime payload")):
            assert f'path "{path}" "{output}" "{reason}"' in body, (path, body)
        # headers and pkg-config metadata are proposed for the development output
        assert 'path "usr/include/fixture.h" "fixture-devel" "header"' in body, body
        assert 'path "usr/lib/pkgconfig/fixture.pc" "fixture-devel" "pkg-config metadata"' \
            in body, body
        # man pages and reference documents are proposed for the documentation output
        assert 'path "usr/share/man/man1/fixture.1" "fixture-doc" "documentation"' in body, body
        assert 'path "usr/share/doc/fixture/README" "fixture-doc" "documentation"' in body, body
        # an unversioned object and a static archive are decisions, not assignments:
        # a file extension is not a sufficient criterion and a tree states no
        # metadata or dlopen probe that would settle either
        assert 'decision "usr/lib/pluginfixture.so" "unversioned-object" suggested "fixture"' \
            in body, body
        assert 'decision "usr/lib/libfixture.a" "static-archive" suggested "fixture-devel"' \
            in body, body
        assert "unversioned-object\n" in report and "static-archive\n" in report, report
        # the outputs the proposal declares are the ones it assigns
        assert 'output "fixture" runtime\n' in body, body
        assert 'output "fixture-devel" devel\n' in body, body
        assert 'output "fixture-doc" docs\n' in body, body

        # an explicit rule outranks the heuristic, so the same tree proposes no
        # decision once the operator states where the object and the archive belong
        ruled = root / "ruled"
        report = call("split", tree, "--output", ruled, "--split", "fixture",
                      "usr/lib/pluginfixture.so", "--split", "fixture-devel",
                      "usr/lib/libfixture.a")
        assert "decisions 0" in report, report
        body = ruled.read_text()
        assert 'path "usr/lib/pluginfixture.so" "fixture" "explicit rule"' in body, body
        assert 'path "usr/lib/libfixture.a" "fixture-devel" "explicit rule"' in body, body
        assert "decision " not in body, body

        # two rules naming different outputs for one path is a contradiction the
        # operator has to settle, so the path is a decision rather than a guess
        repeated = root / "repeated"
        report = call("split", tree, "--output", repeated, "--split", "fixture",
                      "usr/lib/*", "--split", "fixture-devel", "usr/lib/*", status=3)
        assert "decisions 5" in report, report
        body = repeated.read_text()
        assert 'decision "usr/lib/pluginfixture.so" "repeated"' in body, body
        assert 'decision "usr/lib/libfixture.a" "repeated"' in body, body

        # a link that leaves the tree it installs into is a decision, and so is an
        # absolute one, because a payload carries neither
        for target, name in (("../../../outside", "escape"), ("/usr/bin/escape", "absolute")):
            link = tree / f"DATA/usr/bin/{name}"
            link.symlink_to(target)
            escaped = root / f"escaped-{name}"
            report = call("split", tree, "--output", escaped, status=3)
            assert f'decision "usr/bin/{name}" "escaping-link"' in escaped.read_text(), target
            assert f"decision usr/bin/{name} escaping-link\n" in report, report
            link.unlink()

        # a rule may only name an output this proposal declares
        unknown = root / "unknown"
        result = subprocess.run([binary, "split", tree, "--output", str(unknown), "--split",
                                 "elsewhere", "usr/lib/*"], capture_output=True, text=True)
        assert result.returncode == 2, result
        assert "undeclared output elsewhere" in result.stderr, result.stderr

        # a tree without HOLY/meta, and a tree that is not there
        bare = build(sources, "bare")
        for field in ("deps", "provides", "hooks", "origin", "transform", "files"):
            (bare / "HOLY" / field).write_text("")
        (bare / "HOLY/meta").unlink()
        result = subprocess.run([binary, "split", bare, "--output", str(root / "bare.txt")],
                                capture_output=True, text=True)
        assert result.returncode == 2, result
        assert "HOLY/meta" in result.stderr, result.stderr
        result = subprocess.run([binary, "split", root / "absent", "--output",
                                 str(root / "absent.txt")], capture_output=True, text=True)
        assert result.returncode == 6, result
        assert "prepared tree unavailable" in result.stderr, result.stderr

        # a data-only tree proposes one runtime output and no development or
        # documentation output, because a declared output that receives nothing is
        # what a build rejects
        data = build(sources, "dataonly")
        put(data, "usr/share/fixture/data", "runtime data\n")
        plain = root / "plain"
        report = call("split", data, "--output", plain)
        assert "paths 1 runtime 1 devel 0 docs 0 debug 0 decisions 0" in report, report
        body = plain.read_text()
        assert 'output "dataonly" runtime\n' in body, body
        assert 'output "dataonly-devel"' not in body, body
        assert 'output "dataonly-doc"' not in body, body
        assert 'output "dataonly-debug"' not in body, body

        # a debug output is cut from the runtime files by build-id, so a payload
        # built with a note names the debug file and keeps the build-id the stripped
        # artifact is matched against, and one without a note is a decision
        program_source = sources / "program.c"
        program_source.write_text("int main(void) { return 0; }\n")
        debugged = build(sources, "debugged")
        noted = compile_into(cc, ["-g", "-O0", "-Wl,--build-id", str(program_source), "-o",
                                   str(debugged / "DATA/usr/bin/debugged")],
                             debugged / "DATA/usr/bin/debugged")
        noted.chmod(0o755)
        put(debugged, "usr/include/debugged.h", "int answer(void);\n")
        proposal = root / "debugged"
        report = call("split", debugged, "--output", proposal, "--debug")
        assert "debug 1 decisions 0" in report, report
        body = proposal.read_text()
        assert 'output "debugged-debug" debug\n' in body, body
        assert 'debug "usr/bin/debugged" "' in body, body
        identity = call("elf", noted).splitlines()
        build_id = [line.split()[1] for line in identity if line.startswith("build-id ")][0]
        assert f'debug "usr/bin/debugged" "{build_id}"\n' in body, body
        # the note is what a stripped artifact keeps, so the tool that cuts the pair
        # is named rather than invented per file
        assert "objcopy --only-keep-debug" in body, body
        assert "--add-gnu-debuglink" in body, body
        # the header is still development material, and the program is a runtime file
        # the debug output is cut from
        assert 'path "usr/include/debugged.h" "debugged-devel" "header"' in body, body
        assert 'path "usr/bin/debugged" "debugged" "runtime artifact for a debug file"' \
            in body, body
        assert "no-build-id" not in body, body

        # a payload without a build-id note cannot be tied to a debug file, so the
        # path is a decision rather than an untraceable debug entry
        unnoted = build(sources, "unnoted")
        plain_program = compile_into(cc, ["-g", "-O0", str(program_source), "-o",
                                           str(unnoted / "DATA/usr/bin/unnoted")],
                                     unnoted / "DATA/usr/bin/unnoted")
        plain_program.chmod(0o755)
        proposal = root / "unnoted"
        report = call("split", unnoted, "--output", proposal, "--debug", status=3)
        assert "debug 0" in report, report
        body = proposal.read_text()
        assert 'decision "usr/bin/unnoted" "no-build-id" suggested "unnoted"' in body, body
        assert "no build-id note ties a debug file" in body, body
        assert "\ndebug " not in body, body

    print("split proposal fixtures passed")
    return 0


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