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

file tests/appimage.py

#!/usr/bin/env python3
import hashlib
import os
import pathlib
import shutil
import subprocess
import sys
import tempfile

binary = str(pathlib.Path(sys.argv[1]).resolve())
compiler = shutil.which(os.environ.get("FIXTURE_CC", "cc")) or shutil.which("gcc")
# the desktop entry the images ship, and the version one of them states
DESKTOP = ("[Desktop Entry]\nType=Application\nName=Demo\nExec=AppRun %U\n"
           "TryExecup=/usr/lib/demo\nIcon=demo\n")


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 compile_into(compiler, arguments, output):
    result = subprocess.run([compiler, *arguments], capture_output=True, text=True)
    return result.returncode == 0 and output.is_file()


def squashfs(source, filesystem, runtime, name):
    filesystem = pathlib.Path(filesystem)
    subprocess.run(["mksquashfs", str(source), str(filesystem), "-noappend", "-quiet"],
                   check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
    image = filesystem.parent / name
    image.write_bytes(runtime + filesystem.read_bytes())
    return image


with tempfile.TemporaryDirectory() as scratch:
    root = pathlib.Path(scratch)
    runtime = bytearray(pathlib.Path("/bin/true").read_bytes())
    runtime[8:11] = b"AI\x02"
    runtime = bytes(runtime)
    filesystem = root / "filesystem.squashfs"

    # the unclassified image: a dynamic payload, a bundled library that satisfies
    # its own DT_NEEDED, an interpreter script, and a link the payload cannot carry
    source = root / "AppDir"
    (source / "usr/lib").mkdir(parents=True)
    (source / "usr/bin").mkdir(parents=True)
    (source / "AppRun").write_bytes(pathlib.Path("/bin/true").read_bytes())
    (source / "AppRun").chmod(0o755)
    (source / "program").write_bytes(pathlib.Path("/bin/true").read_bytes())
    (source / "hello").write_text("appimage fixture\n")
    (source / "launch").write_text("#!/bin/sh\nexit 0\n")
    (source / "launch").chmod(0o755)
    (source / "system-link").symlink_to("/usr/lib/external")
    private_library = False
    if compiler:
        library = root / "demo-lib.c"
        library.write_text("int demo(void) { return 7; }\n")
        program = root / "demo.c"
        program.write_text("int demo(void);\nint main(void) { return demo(); }\n")
        if compile_into(compiler, ["-shared", "-fPIC", "-Wl,-soname,libdemo.so.1",
                                   str(library), "-o", str(source / "usr/lib/libdemo.so")],
                        source / "usr/lib/libdemo.so"):
            shutil.copyfile(source / "usr/lib/libdemo.so", source / "usr/lib/libdemo.so.1")
            private_library = compile_into(
                compiler, ["-L" + str(source / "usr/lib"), "-ldemo", str(program),
                           "-o", str(source / "usr/bin/demo-program")],
                source / "usr/bin/demo-program")
            if private_library:
                (source / "usr/bin/demo-program").chmod(0o755)
            else:
                (source / "usr/lib/libdemo.so").unlink()
                (source / "usr/lib/libdemo.so.1").unlink()
        else:
            (source / "usr/lib/libdemo.so").unlink(missing_ok=True)
    image = squashfs(source, filesystem, runtime, "fixture.AppImage")

    def run(*args, status=0, env=None):
        result = subprocess.run([binary, "appimage", *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 import_image(path, output, source_name, status=3):
        result = subprocess.run([binary, "import", str(path), "--source", source_name,
                                 "--format", "appimage", "--output", str(output)],
                                capture_output=True, text=True)
        assert result.returncode == status, (result.returncode, result.stdout, result.stderr)
        return result

    def unpack(artifact, into):
        call("fetch", "local:" + str(artifact), "--extract", "--output", str(into))
        return into / "DATA"

    info = run("inspect", image)
    assert "type appimage-2\n" in info
    assert f"squashfs-offset {len(runtime)}\n" in info
    assert f"sha256 {hashlib.sha256(image.read_bytes()).hexdigest()}\n" in info

    if os.geteuid() != 0:
        output = root / "extracted"
        run("extract", image, "--output", output)
        assert (output / "original").read_bytes() == image.read_bytes()
        assert (output / "AppDir" / "hello").read_text() == "appimage fixture\n"
        assert "state extracted-unclassified\n" in (output / "conversion").read_text()
        classification = (output / "classification").read_text()
        assert 'elf "program" x86_64 glibc' in classification
        assert 'entrypoint AppRun\n' in classification
        assert 'interpreter "AppRun" "/lib64/ld-linux-x86-64.so.2"' in classification
        assert 'needed "AppRun" "libc.so.6"' in classification
        assert 'version-required "AppRun" "libc.so.6"' in classification
        assert 'script "launch" "/bin/sh"' in classification
        assert 'path-view-required "system-link" "/usr/lib/external"' in classification
        assert 'runtime-probes plugins dlopen services graphics audio unknown\n' in classification
        assert list(output.glob("*.holy")) == []
        imported = root / "imported"
        staged = import_image(image, imported, "fixture")
        assert "extracted " in staged.stdout
        assert (imported / "original").read_bytes() == image.read_bytes()
        assert 'source-name "fixture"\n' in (imported / "conversion").read_text()
        artifacts = sorted(p.name for p in imported.glob("*.holy"))
        assert artifacts == ["fixture--x86_64--glibc.holy"], artifacts
        artifact = imported / artifacts[0]
        # an image that states no version records zero and says so
        identity = call("info", "local:" + str(artifact))
        assert "version 0\n" in identity and "arch x86_64\n" in identity, identity
        report = (imported / "package").read_text()
        assert "status review-required\n" in report
        assert "version the payload states none, so the package records zero\n" in report
        assert "path-view-required 1 links" in report
        assert "carries neither" in report
        assert "scripts 1 files carry an interpreter" in report
        assert "dropped the image-level sandbox" in report
        assert "changed the launch conditions" in report
        assert "carries no image-level" in staged.stderr
        verify = call("verify", "local:" + str(artifact))
        assert "verified 10 regular files, 1 symlinks, 15 directories, 0 hardlinks" in verify, verify
        manifest = call("manifest", "local:" + str(artifact))
        for path in ("usr/bin/fixture",
                     "usr/lib/holy/private/fixture/appdir/AppRun",
                     "usr/lib/holy/private/fixture/appdir/usr/bin/demo-program",
                     "usr/lib/holy/private/fixture/appdir/usr/lib/libdemo.so.1",
                     "usr/lib/holy/private/fixture/usr/bin/fixture.appimage",
                     "usr/share/holy/fixture/classification",
                     "usr/share/holy/fixture/conversion"):
            assert path in manifest, path
        requirements = call("requirements", "local:" + str(artifact))
        assert 'require "appimage-needed-0" "fixture" "soname" "libc.so.6"' in requirements
        assert ('require "appimage-link-0" "fixture" "file" "/usr/lib/external" "any" "any"'
                in requirements), requirements
        assert "requirements 2\n" in requirements
        # a payload carries no absolute link, so the link is not in the manifest
        assert "usr/lib/holy/private/fixture/appdir/system-link" not in manifest
        if private_library:
            # a library the payload carries is satisfied privately and names no
            # requirement, while the target system still has to provide libc
            assert '"libdemo.so.1"' not in requirements, requirements
            assert '"libdemo.so.1"' in call("provides", "local:" + str(artifact))
        else:
            assert '"libdemo.so.1"' in requirements
        provides = call("provides", "local:" + str(artifact))
        assert 'provide "package" "fixture" "x86_64" "glibc"' in provides
        data = unpack(artifact, root / "unpacked")
        launcher = (data / "usr/bin/fixture").read_text()
        assert launcher.startswith("#!/bin/sh\n")
        assert "holypkg run fixture:fixture" in launcher
        assert "-- /usr/lib/holy/private/fixture/usr/bin/fixture.appimage \"$@\"\n" in launcher
        assert (data / "usr/lib/holy/private/fixture/appdir/hello").read_text() == "appimage fixture\n"
        assert (data / "usr/share/holy/fixture/classification").read_text() == classification
        assert not (data / "usr/lib/holy/private/fixture/appdir/system-link").exists()
        assert os.readlink(data / "usr/lib/holy/private/fixture/usr/bin/fixture.appimage") == \
            "../../appdir/AppRun"
        run("extract", image, "--output", output, status=1)
        no_tool = root / "no-tool"
        no_tool.mkdir()
        run("extract", image, "--output", root / "missing-tool", status=6,
            env={**os.environ, "PATH": str(no_tool)})
        assert not (root / "missing-tool" / "conversion").exists()

        # a converted image can carry runtime requirements the target does not
        # have yet, and the manager refuses the set instead of installing it
        bare = root / "bare root"
        bare.mkdir()
        call("db", "init", "--root", bare)
        call("cache", "stage", "local:" + str(artifact), "--root", bare)
        unsolved = subprocess.run([binary, "db", "plan-set",
                                   hashlib.sha256(artifact.read_bytes()).hexdigest(),
                                   "--root", str(bare)], capture_output=True, text=True)
        assert unsolved.returncode == 4, unsolved
        assert "appimage-needed-0" in unsolved.stderr, unsolved.stderr
        assert not (bare / "usr/bin/fixture").exists()
    else:
        run("extract", image, "--output", root / "root-denied", status=6)

    if os.geteuid() != 0:
        # the complete image: a version in the desktop entry, an app tree and an
        # entry point the run context starts. the payload is static, so the only
        # thing under test is the packaging and the launch context.
        full = root / "FullDir"
        (full / "app").mkdir(parents=True)
        (full / "usr" / "bin").mkdir(parents=True)
        (full / "app" / "resource").write_text("payload resource\n")
        (full / "vendor.desktop").write_text(DESKTOP + "X-AppImage-Version=1.2.3\n")
        entry = root / "entry.c"
        entry.write_text("int main(void) { return 0; }\n")
        self_contained = compiler and compile_into(
            compiler, ["-static", str(entry), "-o", str(full / "AppRun")], full / "AppRun")
        if not self_contained:
            shutil.copyfile("/bin/true", full / "AppRun")
        (full / "AppRun").chmod(0o755)
        shutil.copyfile(full / "AppRun", full / "usr" / "bin" / "helper")
        (full / "usr" / "bin" / "helper").chmod(0o755)
        complete = squashfs(full, root / "complete.squashfs", runtime, "vendor.AppImage")
        output = root / "converted"
        import_image(complete, output, "vendor")
        # a static payload records the runtime it actually has, not the one the
        # host libc would suggest
        expected_libc = "nolibc" if self_contained else "glibc"
        artifacts = sorted(p.name for p in output.glob("*.holy"))
        assert artifacts == ["vendor--x86_64--" + expected_libc + ".holy"], artifacts
        artifact = output / artifacts[0]
        identity = call("info", "local:" + str(artifact))
        assert "name vendor\n" in identity and "version 1.2.3\n" in identity, identity
        manifest = call("manifest", "local:" + str(artifact))
        for path in ("usr/bin/vendor",
                     "usr/share/applications/vendor.desktop",
                     "usr/lib/holy/private/vendor/appdir/app/resource",
                     "usr/lib/holy/private/vendor/appdir/usr/bin/helper",
                     "usr/lib/holy/private/vendor/usr/bin/vendor.appimage"):
            assert path in manifest, path
        call("verify", "local:" + str(artifact))
        data = unpack(artifact, root / "complete-payload")
        desktop = (data / "usr/share/applications/vendor.desktop").read_text()
        assert "Exec=/usr/bin/vendor %F\n" in desktop, desktop
        assert "TryExecup=/usr/bin/vendor\n" in desktop, desktop
        assert "X-AppImage-Version=1.2.3\n" in desktop
        assert "Icon=demo\n" in desktop
        assert "Name=Demo\n" in desktop
        launcher = (data / "usr/bin/vendor").read_text()
        assert "holypkg run vendor:vendor" in launcher
        assert "--view /app=/usr/lib/holy/private/vendor/appdir/app" in launcher
        assert "-- /usr/lib/holy/private/vendor/usr/bin/vendor.appimage \"$@\"" in launcher
        report = (output / "package").read_text()
        assert "version the payload states" not in report, report
        assert "desktop-entry rewritten to /usr/bin/vendor" in report
        assert "runtime probes static inspection cannot close" in report

        # the package installs once the runtime it names is available, and the run
        # context starts the private entry point the launcher names. the launcher
        # is a shell script, so a converted image needs a shell on the target too;
        # a provider claim counts only for a real payload file, so the fixture is
        # a static program at the path the launcher names.
        target = root / "target root"
        (target / "usr/bin").mkdir(parents=True)
        call("db", "init", "--root", target)
        call("cache", "stage", "local:" + str(artifact), "--root", target)
        digest = hashlib.sha256(artifact.read_bytes()).hexdigest()
        runtime_tree = root / "runtime"
        (runtime_tree / "HOLY").mkdir(parents=True)
        (runtime_tree / "DATA/bin").mkdir(parents=True)
        shell_source = root / "sh.c"
        shell_source.write_text("int main(void) { return 0; }\n")
        interpreter = compiler and compile_into(
            compiler, ["-static", str(shell_source), "-o",
                       str(runtime_tree / "DATA/bin/sh")], runtime_tree / "DATA/bin/sh")
        if interpreter:
            (runtime_tree / "DATA/bin/sh").chmod(0o755)
            (runtime_tree / "HOLY/meta").write_text("format holy-package-1\nname fixture-shell\n"
                                               "version 1\nrelease 1\nos linux\n"
                                               "arch x86_64\nlibc nolibc\n")
            for name in ("deps", "provides", "hooks", "origin", "transform"):
                (runtime_tree / "HOLY" / name).write_text("")
            call("manifest", "generate", runtime_tree, "--output", root / "runtime-files")
            (runtime_tree / "HOLY/files").write_text((root / "runtime-files").read_text())
            call("pack", runtime_tree, "--output", root / "runtime.holy")
            runtime_digest = hashlib.sha256((root / "runtime.holy").read_bytes()).hexdigest()
            call("cache", "stage", "local:" + str(root / "runtime.holy"), "--root", target)
            plan = call("db", "plan-set", digest, runtime_digest, "--root", target)
            plan_sha = [line.split()[10] for line in plan.splitlines()
                        if line.startswith("plan-set ")][0]
            applied = call("db", "apply-set", plan_sha, digest, runtime_digest, "--root", target)
            assert "artifacts 2" in applied, applied
            assert (target / "usr/bin/vendor").is_file()
            assert (target / "usr/share/applications/vendor.desktop").is_file()
            assert (target / "usr/lib/holy/private/vendor/appdir/AppRun").is_file()
            assert (target / "usr/share/holy/vendor/classification").is_file()
            assert (target / "usr/lib/holy/private/vendor/usr/bin/vendor.appimage").is_symlink()
            assert (target / "usr/bin/vendor").read_text() == launcher
            started = subprocess.run([binary, "run", "local:vendor", "--root", str(target),
                                      "--",
                                      "/usr/lib/holy/private/vendor/usr/bin/vendor.appimage"],
                                     capture_output=True, text=True)
            assert started.returncode == 0, (started.returncode, started.stdout, started.stderr)
            # a directory view over the private app tree resolves the image's own path
            (target / "app").mkdir()
            viewed = subprocess.run([binary, "run", "local:vendor", "--root", str(target),
                                     "--view", "/app=/usr/lib/holy/private/vendor/appdir/app",
                                     "--", "/usr/lib/holy/private/vendor/usr/bin/vendor.appimage"],
                                    capture_output=True, text=True)
            assert viewed.returncode == 0, (["run", "local:vendor", "--root", str(target),
                                      "--view", "/app=usr/lib/holy/private/vendor/appdir/app",
                                      "--", "/usr/lib/holy/private/vendor/usr/bin/vendor.appimage"],
                                     viewed.returncode, viewed.stdout, viewed.stderr)
            # a private command outside a bin directory is refused, not guessed
            refused = subprocess.run([binary, "run", "local:vendor", "--root", str(target),
                                      "--", "/usr/lib/holy/private/vendor/appdir/AppRun"],
                                     capture_output=True, text=True)
            assert refused.returncode != 0, (refused.returncode, refused.stdout)
            refused = subprocess.run([binary, "run", "local:vendor", "--root", str(target),
                                      "--", "/usr/lib/holy/private/vendor/vendor.appimage"],
                                     capture_output=True, text=True)
            assert refused.returncode != 0, (refused.returncode, refused.stdout)
            # the launcher itself refuses to run as root, because the converted
            # payload is not a sandbox
            if os.geteuid() == 0:
                root_run = subprocess.run(["/bin/sh", str(target / "usr/bin/vendor")],
                                          capture_output=True, text=True)
                assert root_run.returncode == 1, root_run
                assert "not a sandbox" in root_run.stderr, root_run.stderr
        else:
            # without a compiler there is no interpreter fixture, so the set the
            # converted package needs cannot be staged here
            unresolved = subprocess.run([binary, "db", "plan-set", digest, "--root", str(target)],
                                        capture_output=True, text=True)
            assert unresolved.returncode == 4, unresolved
            assert not (target / "usr/bin/vendor").exists()

        # an image whose payload mixes architectures cannot become one package
        mixed = root / "MixedDir"
        mixed.mkdir()
        shutil.copyfile("/bin/true", mixed / "AppRun")
        (mixed / "AppRun").chmod(0o755)
        if compiler:
            built = subprocess.run([compiler, "-m32", "-shared", "-fPIC", str(root / "demo-lib.c"),
                                    "-o", str(mixed / "lib32.so")], capture_output=True, text=True)
            if built.returncode == 0:
                (mixed / "lib32.so").chmod(0o755)
                image32 = squashfs(mixed, root / "mixed.squashfs", runtime, "mixed.AppImage")
                refused = import_image(image32, root / "mixed", "vendor")
                assert "more than one architecture" in refused.stderr, refused.stderr
                assert not list((root / "mixed").glob("*.holy"))

        # a file name the manifest cannot carry, and images without a usable entry
        unnamed = root / "odd name.AppImage"
        unnamed.write_bytes(runtime + filesystem.read_bytes())
        refused = import_image(unnamed, root / "unnamed", "vendor", status=2)
        assert "not a package name" in refused.stderr, refused.stderr
        entryless = root / "EntryDir"
        entryless.mkdir()
        (entryless / "data").write_text("no entry point\n")
        refused = import_image(squashfs(entryless, root / "entryless.squashfs", runtime,
                                        "entryless.AppImage"),
                               root / "entryless", "vendor")
        assert "no AppRun entry point" in refused.stderr, refused.stderr
        assert not list((root / "entryless").glob("*.holy"))
        plain = root / "PlainDir"
        plain.mkdir()
        (plain / "AppRun").write_text("not executable\n")
        refused = import_image(squashfs(plain, root / "plain.squashfs", runtime,
                                        "plain.AppImage"),
                               root / "plain", "vendor", status=2)
        assert "not executable" in refused.stderr, refused.stderr
        link = root / "LinkDir"
        link.mkdir()
        shutil.copyfile("/bin/true", link / "real")
        (link / "real").chmod(0o755)
        os.symlink("real", link / "AppRun")
        refused = import_image(squashfs(link, root / "link.squashfs", runtime,
                                        "link.AppImage"),
                               root / "link", "vendor")
        assert "not an ordinary file" in refused.stderr, refused.stderr

    broken = root / "broken.AppImage"
    broken.write_bytes(runtime + b"not squashfs")
    run("inspect", broken, status=2)
    old = root / "type1.AppImage"
    old.write_bytes(bytes(runtime[:10]) + b"\x01" + bytes(runtime[11:]) +
                    filesystem.read_bytes())
    run("inspect", old, status=2)
    duplicate = root / "duplicate.AppImage"
    duplicate.write_bytes(runtime + filesystem.read_bytes() + filesystem.read_bytes())
    run("inspect", duplicate, status=3)

print("appimage inspect/extract/import fixtures passed")