_ _
| |_ ___| |_ _
| | . | | | |
|_|_|___|_|_ |
|___|
git mirror - github.com/owenewans/holy - branch master
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")