From fe0cf953d7efb9253af6aaa146ccd850bdaa9fd0 Mon Sep 17 00:00:00 2001 From: Scott Jones Date: Wed, 23 Sep 2026 21:53:30 -0400 Subject: [PATCH 1/3] Add static QEMU packages for aarch64 --- .../qemu-user-static/.omarchy/package.json | 4 + pkgbuilds/qemu-user-static/PKGBUILD | 64 +++ pkgbuilds/qemu-user-static/README.md | 13 + .../qemu-user-static/qemu-binfmt-conf.sh | 442 ++++++++++++++++++ 4 files changed, 523 insertions(+) create mode 100644 pkgbuilds/qemu-user-static/.omarchy/package.json create mode 100644 pkgbuilds/qemu-user-static/PKGBUILD create mode 100644 pkgbuilds/qemu-user-static/README.md create mode 100644 pkgbuilds/qemu-user-static/qemu-binfmt-conf.sh diff --git a/pkgbuilds/qemu-user-static/.omarchy/package.json b/pkgbuilds/qemu-user-static/.omarchy/package.json new file mode 100644 index 0000000..283d159 --- /dev/null +++ b/pkgbuilds/qemu-user-static/.omarchy/package.json @@ -0,0 +1,4 @@ +{ + "source": "local", + "sync": false +} diff --git a/pkgbuilds/qemu-user-static/PKGBUILD b/pkgbuilds/qemu-user-static/PKGBUILD new file mode 100644 index 0000000..8a21f11 --- /dev/null +++ b/pkgbuilds/qemu-user-static/PKGBUILD @@ -0,0 +1,64 @@ +# aarch64-only build: Arch Linux ARM builds qemu without the static user-mode +# binaries, so the Omarchy default set (qemu-user-static-binfmt, used for +# foreign-architecture containers) repackages Debian's statically linked +# qemu-user build. On x86_64 both packages come from the Arch extra repository. + +pkgbase=qemu-user-static +pkgname=('qemu-user-static' 'qemu-user-static-binfmt') +pkgver=10.0.11 +_debver="1:10.0.11+ds-0+deb13u1+b1" +pkgrel=1 +pkgdesc="A generic and open source machine emulator and virtualizer - static user-mode emulation" +arch=('aarch64') +url="https://www.qemu.org" +license=('GPL-2.0-only' 'LGPL-2.1-only') +source=("qemu-binfmt-conf.sh") +# Debian removes superseded pool files. Pin the official archive snapshot so +# the same checksum-verified binary remains rebuildable. +source_aarch64=("qemu-user_${pkgver}_arm64.deb::https://snapshot.debian.org/archive/debian/20260713T202617Z/pool/main/q/qemu/qemu-user_${_debver#*:}_arm64.deb") +sha256sums=('5e12ef484b8364e1ba7bae4acda837d2e6320ffbeb7fab341034124553cdb137') +sha256sums_aarch64=('6c9483063bf60f37fe181ead911251deabe40c893ea5829bc34b0b7b88913b6a') +noextract=("qemu-user_${pkgver}_arm64.deb") + +prepare() { + mkdir -p deb + bsdtar -C deb -xf "qemu-user_${pkgver}_arm64.deb" data.tar.xz + bsdtar -C deb -xf deb/data.tar.xz ./usr/bin ./usr/share/doc/qemu-user/copyright +} + +package_qemu-user-static() { + pkgdesc="A generic and open source machine emulator and virtualizer - static user-mode emulation" + depends=('glibc') + optdepends=('qemu-user-static-binfmt: binary format rules for foreign-architecture binaries') + provides=('qemu-user-static') + conflicts=('qemu-user-static-bin') + + install -dm755 "$pkgdir/usr/bin" + local binary + for binary in deb/usr/bin/qemu-*; do + install -Dm755 "$binary" "$pkgdir/usr/bin/$(basename "$binary")-static" + done + install -Dm644 deb/usr/share/doc/qemu-user/copyright "$pkgdir/usr/share/licenses/$pkgname/copyright" +} + +package_qemu-user-static-binfmt() { + pkgdesc="Binary format rules for the static QEMU user-mode emulators" + depends=('qemu-user-static') + provides=('qemu-user-binfmt-provider') + conflicts=('qemu-user-binfmt-provider' 'binfmt-qemu-static') + + install -dm755 "$pkgdir/usr/lib/binfmt.d" + # HOST_ARCH keeps the native architecture out of the rule set regardless + # of the build host, so the package is identical under native and QEMU builds. + HOST_ARCH=aarch64 sh "$srcdir/qemu-binfmt-conf.sh" \ + --systemd ALL \ + --exportdir "$pkgdir/usr/lib/binfmt.d/" \ + --qemu-path /usr/bin \ + --qemu-suffix -static \ + --persistent yes \ + --preserve-argv0 yes + local conf + for conf in "$pkgdir"/usr/lib/binfmt.d/*.conf; do + mv "$conf" "${conf%.conf}-static.conf" + done +} diff --git a/pkgbuilds/qemu-user-static/README.md b/pkgbuilds/qemu-user-static/README.md new file mode 100644 index 0000000..daeb0ea --- /dev/null +++ b/pkgbuilds/qemu-user-static/README.md @@ -0,0 +1,13 @@ +# ARM static QEMU candidate + +This split recipe provides `qemu-user-static` and `qemu-user-static-binfmt` on aarch64, where Arch Linux ARM does not supply the static emulator packages requested by Omarchy's default package set. It repackages Debian's checksum-pinned ARM64 binary; it does not compile QEMU or replace upstream's build-worker emulation setup. + +Imported from Marcelo's `maralcbr/omarchy-pkgs` revision `83973903b7deb9b56ce75f02b432fba0561d6293`, through the validated candidate recipe in `omarchy-mac/omarchy-pkgs-aarch64` (#61). Debian copyright and upstream licenses are retained. The immutable Debian snapshot keeps the pinned binary available when the rolling pool removes old revisions. + +## Draft readiness gates + +Automatic sync is deliberately disabled in this initial draft. This does **not** detect new releases or security updates. Do not mark this PR ready until a package-local, fixture-tested update integration handles Debian's full version (epoch, upstream version, Debian security revision and architecture rebuild), immutable archive location and SHA256 together. A new Debian revision at the same QEMU version must produce a forward package version; repeated checks must be idempotent. Missing or malformed metadata, unavailable snapshots and checksum inconsistencies must leave the recipe unchanged and fail visibly. + +The existing simple custom-hook contract updates pkgver and SHA256 arrays but cannot update `_debver` and the snapshot timestamp together. Do not add a misleading upstream-version-only watch. Review the smallest compatible integration with upstream before removing the hold. Once enabled, updates should join upstream's existing six-hour update PR workflow; GitHub notification settings determine who sees those PRs. Until then, check Debian's package and security updates manually. + +Before readiness, build both outputs using upstream's ARM builder and test installation in a disposable ARM VM. Verify AArch64 static ELF executables, exact interpreter paths and ownership, the absence of native AArch64 binfmt rules, execution of a known foreign-architecture program both directly and through binfmt, and clean package removal. Never register binfmt rules on the developer host or assume a container isolates that kernel state. Recipe generation only writes rules into the package staging directory. diff --git a/pkgbuilds/qemu-user-static/qemu-binfmt-conf.sh b/pkgbuilds/qemu-user-static/qemu-binfmt-conf.sh new file mode 100644 index 0000000..5fd462b --- /dev/null +++ b/pkgbuilds/qemu-user-static/qemu-binfmt-conf.sh @@ -0,0 +1,442 @@ +#!/bin/sh +# Enable automatic program execution by the kernel. + +qemu_target_list="i386 i486 alpha arm armeb sparc sparc32plus sparc64 \ +ppc ppc64 ppc64le m68k mips mipsel mipsn32 mipsn32el mips64 mips64el \ +sh4 sh4eb s390x aarch64 aarch64_be hppa riscv32 riscv64 xtensa xtensaeb \ +microblaze microblazeel or1k x86_64 hexagon loongarch64" + +i386_magic='\x7fELF\x01\x01\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x02\x00\x03\x00' +i386_mask='\xff\xff\xff\xff\xff\xfe\xfe\x00\xff\xff\xff\xff\xff\xff\xff\xff\xfe\xff\xff\xff' +i386_family=i386 + +i486_magic='\x7fELF\x01\x01\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x02\x00\x06\x00' +i486_mask='\xff\xff\xff\xff\xff\xfe\xfe\x00\xff\xff\xff\xff\xff\xff\xff\xff\xfe\xff\xff\xff' +i486_family=i386 + +x86_64_magic='\x7fELF\x02\x01\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x02\x00\x3e\x00' +x86_64_mask='\xff\xff\xff\xff\xff\xfe\xfe\x00\xff\xff\xff\xff\xff\xff\xff\xff\xfe\xff\xff\xff' +x86_64_family=i386 + +alpha_magic='\x7fELF\x02\x01\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x02\x00\x26\x90' +alpha_mask='\xff\xff\xff\xff\xff\xfe\xfe\x00\xff\xff\xff\xff\xff\xff\xff\xff\xfe\xff\xff\xff' +alpha_family=alpha + +arm_magic='\x7fELF\x01\x01\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x02\x00\x28\x00' +arm_mask='\xff\xff\xff\xff\xff\xff\xff\x00\xff\xff\xff\xff\xff\xff\xff\xff\xfe\xff\xff\xff' +arm_family=arm + +armeb_magic='\x7fELF\x01\x02\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x02\x00\x28' +armeb_mask='\xff\xff\xff\xff\xff\xff\xff\x00\xff\xff\xff\xff\xff\xff\xff\xff\xff\xfe\xff\xff' +armeb_family=armeb + +sparc_magic='\x7fELF\x01\x02\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x02\x00\x02' +sparc_mask='\xff\xff\xff\xff\xff\xff\xff\x00\xff\xff\xff\xff\xff\xff\xff\xff\xff\xfe\xff\xff' +sparc_family=sparc + +sparc32plus_magic='\x7fELF\x01\x02\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x02\x00\x12' +sparc32plus_mask='\xff\xff\xff\xff\xff\xff\xff\x00\xff\xff\xff\xff\xff\xff\xff\xff\xff\xfe\xff\xff' +sparc32plus_family=sparc + +sparc64_magic='\x7fELF\x02\x02\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x02\x00\x2b' +sparc64_mask='\xff\xff\xff\xff\xff\xff\xff\x00\xff\xff\xff\xff\xff\xff\xff\xff\xff\xfe\xff\xff' +sparc64_family=sparc + +ppc_magic='\x7fELF\x01\x02\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x02\x00\x14' +ppc_mask='\xff\xff\xff\xff\xff\xff\xff\x00\xff\xff\xff\xff\xff\xff\xff\xff\xff\xfe\xff\xff' +ppc_family=ppc + +ppc64_magic='\x7fELF\x02\x02\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x02\x00\x15' +ppc64_mask='\xff\xff\xff\xff\xff\xff\xff\x00\xff\xff\xff\xff\xff\xff\xff\xff\xff\xfe\xff\xff' +ppc64_family=ppc + +ppc64le_magic='\x7fELF\x02\x01\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x02\x00\x15\x00' +ppc64le_mask='\xff\xff\xff\xff\xff\xff\xff\x00\xff\xff\xff\xff\xff\xff\xff\xff\xfe\xff\xff\x00' +ppc64le_family=ppcle + +m68k_magic='\x7fELF\x01\x02\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x02\x00\x04' +m68k_mask='\xff\xff\xff\xff\xff\xff\xfe\x00\xff\xff\xff\xff\xff\xff\xff\xff\xff\xfe\xff\xff' +m68k_family=m68k + +# FIXME: We could use the other endianness on a MIPS host. + +mips_magic='\x7fELF\x01\x02\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x02\x00\x08\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00' +mips_mask='\xff\xff\xff\xff\xff\xff\xff\x00\x00\xff\xff\xff\xff\xff\xff\xff\xff\xfe\xff\xff\xff\xff\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x20' +mips_family=mips + +mipsel_magic='\x7fELF\x01\x01\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x02\x00\x08\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00' +mipsel_mask='\xff\xff\xff\xff\xff\xff\xff\x00\x00\xff\xff\xff\xff\xff\xff\xff\xfe\xff\xff\xff\xff\xff\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x20\x00\x00\x00' +mipsel_family=mips + +mipsn32_magic='\x7fELF\x01\x02\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x02\x00\x08\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x20' +mipsn32_mask='\xff\xff\xff\xff\xff\xff\xff\x00\x00\xff\xff\xff\xff\xff\xff\xff\xff\xfe\xff\xff\xff\xff\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x20' +mipsn32_family=mips + +mipsn32el_magic='\x7fELF\x01\x01\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x02\x00\x08\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x20\x00\x00\x00' +mipsn32el_mask='\xff\xff\xff\xff\xff\xff\xff\x00\x00\xff\xff\xff\xff\xff\xff\xff\xfe\xff\xff\xff\xff\xff\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x20\x00\x00\x00' +mipsn32el_family=mips + +mips64_magic='\x7fELF\x02\x02\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x02\x00\x08' +mips64_mask='\xff\xff\xff\xff\xff\xff\xff\x00\x00\xff\xff\xff\xff\xff\xff\xff\xff\xfe\xff\xff' +mips64_family=mips + +mips64el_magic='\x7fELF\x02\x01\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x02\x00\x08\x00' +mips64el_mask='\xff\xff\xff\xff\xff\xff\xff\x00\x00\xff\xff\xff\xff\xff\xff\xff\xfe\xff\xff\xff' +mips64el_family=mips + +sh4_magic='\x7fELF\x01\x01\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x02\x00\x2a\x00' +sh4_mask='\xff\xff\xff\xff\xff\xff\xff\x00\xff\xff\xff\xff\xff\xff\xff\xff\xfe\xff\xff\xff' +sh4_family=sh4 + +sh4eb_magic='\x7fELF\x01\x02\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x02\x00\x2a' +sh4eb_mask='\xff\xff\xff\xff\xff\xff\xff\x00\xff\xff\xff\xff\xff\xff\xff\xff\xff\xfe\xff\xff' +sh4eb_family=sh4 + +s390x_magic='\x7fELF\x02\x02\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x02\x00\x16' +s390x_mask='\xff\xff\xff\xff\xff\xff\xff\x00\xff\xff\xff\xff\xff\xff\xff\xff\xff\xfe\xff\xff' +s390x_family=s390x + +aarch64_magic='\x7fELF\x02\x01\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x02\x00\xb7\x00' +aarch64_mask='\xff\xff\xff\xff\xff\xff\xff\x00\xff\xff\xff\xff\xff\xff\xff\xff\xfe\xff\xff\xff' +aarch64_family=arm + +aarch64_be_magic='\x7fELF\x02\x02\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x02\x00\xb7' +aarch64_be_mask='\xff\xff\xff\xff\xff\xff\xff\x00\xff\xff\xff\xff\xff\xff\xff\xff\xff\xfe\xff\xff' +aarch64_be_family=armeb + +hppa_magic='\x7f\x45\x4c\x46\x01\x02\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x02\x00\x0f' +hppa_mask='\xff\xff\xff\xff\xff\xff\xff\x00\xff\xff\xff\xff\xff\xff\xff\xff\xff\xfe\xff\xff' +hppa_family=hppa + +riscv32_magic='\x7fELF\x01\x01\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x02\x00\xf3\x00' +riscv32_mask='\xff\xff\xff\xff\xff\xff\xff\x00\xff\xff\xff\xff\xff\xff\xff\xff\xfe\xff\xff\xff' +riscv32_family=riscv + +riscv64_magic='\x7fELF\x02\x01\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x02\x00\xf3\x00' +riscv64_mask='\xff\xff\xff\xff\xff\xff\xff\x00\xff\xff\xff\xff\xff\xff\xff\xff\xfe\xff\xff\xff' +riscv64_family=riscv + +xtensa_magic='\x7fELF\x01\x01\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x02\x00\x5e\x00' +xtensa_mask='\xff\xff\xff\xff\xff\xff\xff\x00\xff\xff\xff\xff\xff\xff\xff\xff\xfe\xff\xff\xff' +xtensa_family=xtensa + +xtensaeb_magic='\x7fELF\x01\x02\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x02\x00\x5e' +xtensaeb_mask='\xff\xff\xff\xff\xff\xff\xff\x00\xff\xff\xff\xff\xff\xff\xff\xff\xff\xfe\xff\xff' +xtensaeb_family=xtensaeb + +microblaze_magic='\x7fELF\x01\x02\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x02\x00\xba\xab' +microblaze_mask='\xff\xff\xff\xff\xff\xff\xff\x00\xff\xff\xff\xff\xff\xff\xff\xff\xfe\xff\xff\xff' +microblaze_family=microblaze + +microblazeel_magic='\x7fELF\x01\x01\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x02\x00\xab\xba' +microblazeel_mask='\xff\xff\xff\xff\xff\xff\xff\x00\xff\xff\xff\xff\xff\xff\xff\xff\xfe\xff\xff\xff' +microblazeel_family=microblazeel + +or1k_magic='\x7fELF\x01\x02\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x02\x00\x5c' +or1k_mask='\xff\xff\xff\xff\xff\xff\xff\x00\xff\xff\xff\xff\xff\xff\xff\xff\xff\xfe\xff\xff' +or1k_family=or1k + +hexagon_magic='\x7fELF\x01\x01\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x02\x00\xa4\x00' +hexagon_mask='\xff\xff\xff\xff\xff\xff\xff\x00\xff\xff\xff\xff\xff\xff\xff\xff\xfe\xff\xff\xff' +hexagon_family=hexagon + +loongarch64_magic='\x7fELF\x02\x01\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x02\x00\x02\x01' +loongarch64_mask='\xff\xff\xff\xff\xff\xff\xff\xfc\x00\xff\xff\xff\xff\xff\xff\xff\xfe\xff\xff\xff' +loongarch64_family=loongarch + +# Converts the name of a host CPU architecture to the corresponding QEMU +# target. +# +# FIXME: This can probably be simplified a lot by dropping most entries. +# Remember that the script is only used on Linux, so we only need to +# handle the strings Linux uses to report the host CPU architecture. +qemu_normalize() { + cpu="$1" + case "$cpu" in + i[3-6]86) + echo "i386" + ;; + amd64) + echo "x86_64" + ;; + powerpc) + echo "ppc" + ;; + ppc64el) + echo "ppc64le" + ;; + armel|armhf|armv[4-9]*l) + echo "arm" + ;; + armv[4-9]*b) + echo "armeb" + ;; + arm64) + echo "aarch64" + ;; + *) + echo "$cpu" + ;; + esac +} + +usage() { + cat <&2 + exit 1 + fi +} + +qemu_check_bintfmt_misc() { + # load the binfmt_misc module + if [ ! -d /proc/sys/fs/binfmt_misc ]; then + if ! /sbin/modprobe binfmt_misc ; then + exit 1 + fi + fi + if [ ! -f /proc/sys/fs/binfmt_misc/register ]; then + if ! mount binfmt_misc -t binfmt_misc /proc/sys/fs/binfmt_misc ; then + exit 1 + fi + fi + + qemu_check_access /proc/sys/fs/binfmt_misc/register +} + +installed_dpkg() { + dpkg --status "$1" > /dev/null 2>&1 +} + +qemu_check_debian() { + if [ ! -e /etc/debian_version ] ; then + echo "WARNING: your system is not a Debian based distro" 1>&2 + elif ! installed_dpkg binfmt-support ; then + echo "WARNING: package binfmt-support is needed" 1>&2 + fi + qemu_check_access "$EXPORTDIR" +} + +qemu_check_systemd() { + if ! systemctl -q is-enabled systemd-binfmt.service ; then + echo "WARNING: systemd-binfmt.service is missing or disabled" 1>&2 + fi + qemu_check_access "$EXPORTDIR" +} + +qemu_generate_register() { + flags="" + if [ "$CREDENTIAL" = "yes" ] ; then + flags="OC" + fi + if [ "$PERSISTENT" = "yes" ] ; then + flags="${flags}F" + fi + if [ "$PRESERVE_ARG0" = "yes" ] ; then + flags="${flags}P" + fi + + echo ":qemu-$cpu:M::$magic:$mask:$qemu:$flags" +} + +qemu_register_interpreter() { + echo "Setting $qemu as binfmt interpreter for $cpu" + qemu_generate_register > /proc/sys/fs/binfmt_misc/register +} + +qemu_generate_systemd() { + echo "Setting $qemu as binfmt interpreter for $cpu for systemd-binfmt.service" + qemu_generate_register > "$EXPORTDIR/qemu-$cpu.conf" +} + +qemu_generate_debian() { + cat > "$EXPORTDIR/qemu-$cpu" <&2 + exit 1 + fi + + # register the interpreter for each cpu except for the native one + + for cpu in ${qemu_target_list} ; do + magic=$(eval echo \$${cpu}_magic) + mask=$(eval echo \$${cpu}_mask) + family=$(eval echo \$${cpu}_family) + + target="$cpu" + if [ "$cpu" = "i486" ] ; then + target="i386" + fi + + qemu="$QEMU_PATH/qemu-$target$QEMU_SUFFIX" + + if [ "$magic" = "" ] || [ "$mask" = "" ] || [ "$family" = "" ] ; then + echo "INTERNAL ERROR: unknown cpu $cpu" 1>&2 + continue + fi + + if [ "$host_family" = "$family" ] ; then + # When --ignore-family is used, we have to generate rules even + # for targets that are in the same family as the host CPU. The + # only exception is of course when the CPU types exactly match + if [ "$target" = "$host_cpu" ] || [ "$IGNORE_FAMILY" = "no" ] ; then + continue + fi + fi + + $BINFMT_SET + done +} + +CHECK=qemu_check_bintfmt_misc +BINFMT_SET=qemu_register_interpreter + +SYSTEMDDIR="/etc/binfmt.d" +DEBIANDIR="/usr/share/binfmts" + +QEMU_PATH=/usr/local/bin +CREDENTIAL=no +PERSISTENT=no +PRESERVE_ARG0=no +QEMU_SUFFIX="" +IGNORE_FAMILY=no + +_longopts="debian,systemd:,qemu-path:,qemu-suffix:,exportdir:,help,credential:,\ +persistent:,preserve-argv0:,ignore-family:" +options=$(getopt -o ds:Q:S:e:hc:p:g:F:i: -l ${_longopts} -- "$@") +eval set -- "$options" + +while true ; do + case "$1" in + -d|--debian) + CHECK=qemu_check_debian + BINFMT_SET=qemu_generate_debian + EXPORTDIR=${EXPORTDIR:-$DEBIANDIR} + ;; + -s|--systemd) + CHECK=qemu_check_systemd + BINFMT_SET=qemu_generate_systemd + EXPORTDIR=${EXPORTDIR:-$SYSTEMDDIR} + shift + # check given cpu is in the supported CPU list + if [ "$1" != "ALL" ] ; then + for cpu in ${qemu_target_list} ; do + if [ "$cpu" = "$1" ] ; then + break + fi + done + + if [ "$cpu" = "$1" ] ; then + qemu_target_list="$1" + else + echo "ERROR: unknown CPU \"$1\"" 1>&2 + usage + exit 1 + fi + fi + ;; + -Q|--qemu-path) + shift + QEMU_PATH="$1" + ;; + -F|--qemu-suffix) + shift + QEMU_SUFFIX="$1" + ;; + -e|--exportdir) + shift + EXPORTDIR="$1" + ;; + -h|--help) + usage + exit 1 + ;; + -c|--credential) + shift + CREDENTIAL="$1" + ;; + -p|--persistent) + shift + PERSISTENT="$1" + ;; + -g|--preserve-argv0) + shift + PRESERVE_ARG0="$1" + ;; + -i|--ignore-family) + shift + IGNORE_FAMILY="$1" + ;; + *) + break + ;; + esac + shift +done + +$CHECK +qemu_set_binfmts From aa64ec9a4f63e22ad328336317b5aa28d6586e0b Mon Sep 17 00:00:00 2001 From: Scott Jones Date: Wed, 23 Sep 2026 23:26:34 -0400 Subject: [PATCH 2/3] Track Debian QEMU revisions through verified immutable snapshots --- .github/workflows/test.yml | 1 + bin/sync-upstream | 16 ++- .../qemu-user-static/.omarchy/package.json | 3 +- .../qemu-user-static/.omarchy/upstream.py | 120 ++++++++++++++++++ .../qemu-user-static/.omarchy/upstream.sh | 3 + pkgbuilds/qemu-user-static/PKGBUILD | 15 ++- pkgbuilds/qemu-user-static/README.md | 14 +- tests/qemu-upstream.py | 115 +++++++++++++++++ 8 files changed, 272 insertions(+), 15 deletions(-) create mode 100644 pkgbuilds/qemu-user-static/.omarchy/upstream.py create mode 100644 pkgbuilds/qemu-user-static/.omarchy/upstream.sh create mode 100644 tests/qemu-upstream.py diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 29d18b0..7bcbb22 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -50,6 +50,7 @@ jobs: pacman -Syu --noconfirm git jq python libarchive python tests/oma-service-removal.py python tests/upstream-watch.py + python tests/qemu-upstream.py ./bin/sync-upstream self-test ./bin/sync-rebuilds --self-test ./bin/omarchy-pkgs self-test diff --git a/bin/sync-upstream b/bin/sync-upstream index 2f84506..360a1b2 100755 --- a/bin/sync-upstream +++ b/bin/sync-upstream @@ -31,7 +31,10 @@ pkgbuilds//.omarchy/upstream.sh, a hook that reports JSON on stdout: } Architecture keys become sha256sums_ in the PKGBUILD; the key "any" means -the unsuffixed sha256sums array. An empty object ({}) reports no update. +the unsuffixed sha256sums array. An empty object ({}) reports no update. Optional "variables" maps existing +underscore-prefixed release scalars to values containing only letters, digits, +periods, underscores, plus, colon and hyphen. They are verified and written in +the same atomic replacement as pkgver and checksums. When the reported version is newer than the checked-in one, pkgver and the listed checksum arrays are rewritten and pkgrel is reset to 1. @@ -209,6 +212,10 @@ validate_release() { and (.sha256sums | to_entries | all( .value | type == "array" and length > 0 and all(test("\\A[0-9a-f]{64}\\z")) )) + and (if has("variables") then (.variables | type == "object" and (to_entries | all( + (.key | test("\\A_[a-z][a-z0-9_]*\\z")) and + (.value | type == "string" and test("\\A[A-Za-z0-9._+:-]+\\z")) + ))) else true end) and (if has("published_at") then (.published_at | type == "string" and length > 0) else true end) ' <<<"$release" >/dev/null } @@ -312,6 +319,13 @@ apply_release() { set_pkgbuild_scalar "$scratch" pkgver "$pkgver" || exit 1 set_pkgbuild_scalar "$scratch" pkgrel 1 || exit 1 + local field value + while IFS=$'\t' read -r field value; do + [[ -n "$field" ]] || continue + set_pkgbuild_scalar "$scratch" "$field" "$value" || exit 1 + [[ $(bash -c 'source "$1"; printf "%s" "${!2}"' _ "$scratch" "$field") == "$value" ]] || exit 1 + done < <(jq -r '(.variables // {}) | to_entries[] | [.key, .value] | @tsv' <<<"$release") + verify_pkgbuild "$scratch" "$release" "$pkgver" "${arrays[@]}" || exit 1 ); then rm -f "$scratch" diff --git a/pkgbuilds/qemu-user-static/.omarchy/package.json b/pkgbuilds/qemu-user-static/.omarchy/package.json index 283d159..2a9719d 100644 --- a/pkgbuilds/qemu-user-static/.omarchy/package.json +++ b/pkgbuilds/qemu-user-static/.omarchy/package.json @@ -1,4 +1,3 @@ { - "source": "local", - "sync": false + "source": "local" } diff --git a/pkgbuilds/qemu-user-static/.omarchy/upstream.py b/pkgbuilds/qemu-user-static/.omarchy/upstream.py new file mode 100644 index 0000000..341d9c1 --- /dev/null +++ b/pkgbuilds/qemu-user-static/.omarchy/upstream.py @@ -0,0 +1,120 @@ +#!/usr/bin/env python3 +"""Track Debian 13 ARM64 QEMU revisions, verifying an immutable snapshot first.""" +import functools +import hashlib +import json +import lzma +from pathlib import Path +import re +import subprocess +import sys +import urllib.parse +import urllib.request + +FEEDS = [ + 'https://deb.debian.org/debian/dists/trixie/main/binary-arm64/Packages.xz', + 'https://deb.debian.org/debian/dists/trixie-updates/main/binary-arm64/Packages.xz', + 'https://security.debian.org/debian-security/dists/trixie-security/main/binary-arm64/Packages.xz', +] + +def fetch(url): + with urllib.request.urlopen(url, timeout=120) as response: + if not response.url.startswith('https://'): + raise ValueError('Insecure redirect') + return response.read() + +def parts(version): + match = re.fullmatch(r'(?:(\d+):)?([0-9][A-Za-z0-9.+~]*?)-([A-Za-z0-9.+~]+)', version) + if not match: + raise ValueError('Unsupported Debian version: ' + version) + return int(match[1] or '0'), match[2], match[3] + +def segment_cmp(a, b): + # Debian policy: tilde precedes everything, then end/digits, letters, + # then other characters; digit runs are compared numerically. + def order(c): + if c == '~': return -1 + if not c or c.isdigit(): return 0 + return ord(c) if c.isalpha() else ord(c) + 256 + while a or b: + while (a and not a[0].isdigit()) or (b and not b[0].isdigit()): + x, y = order(a[:1]), order(b[:1]) + if x != y: return (x > y) - (x < y) + a, b = a[1:], b[1:] + x = re.match(r'\d*', a)[0] + y = re.match(r'\d*', b)[0] + nx, ny = int(x or '0'), int(y or '0') + if nx != ny: return (nx > ny) - (nx < ny) + a, b = a[len(x):], b[len(y):] + return 0 + +def compare(a, b): + ea, ua, ra = parts(a) + eb, ub, rb = parts(b) + return (ea > eb) - (ea < eb) or segment_cmp(ua, ub) or segment_cmp(ra, rb) + +def package_version(version): + epoch, upstream, revision = parts(version) + # Stable releases only. Fail visibly on prerelease/repack conventions that + # need a reviewed Arch ordering rather than silently misordering them. + if '~' in version: + raise ValueError('Debian prerelease needs a reviewed Arch version mapping') + return f'{epoch}.{upstream}.{revision}' + +def records(data): + found = [] + for stanza in re.split(r'\n\s*\n', lzma.decompress(data).decode()): + fields = dict(re.findall(r'^([A-Za-z0-9-]+): (.*)$', stanza, re.M)) + if fields.get('Package') != 'qemu-user': continue + if fields.get('Architecture') != 'arm64': raise ValueError('Wrong architecture') + parts(fields['Version']) + if not re.fullmatch(r'[0-9a-f]{64}', fields.get('SHA256', '')): + raise ValueError('Missing/malformed SHA256') + if not re.fullmatch(r'[1-9][0-9]*', fields.get('Size', '')): + raise ValueError('Missing/malformed package size') + found.append(fields) + return found + +def discover(current, current_pkgver, current_hash, get=fetch): + candidates = [row for url in FEEDS for row in records(get(url))] + if not candidates: raise ValueError('No ARM64 qemu-user package in Debian feeds') + selected = max(candidates, key=functools.cmp_to_key(lambda a,b: compare(a['Version'], b['Version']))) + version, checksum = selected['Version'], selected['SHA256'] + if any(row['SHA256'] != checksum for row in candidates if row['Version'] == version): + raise ValueError('Debian feeds disagree on the selected checksum') + ordering = compare(version, current) + if ordering < 0: raise ValueError('Debian feeds are older than the pinned recipe') + if ordering == 0: + if checksum != current_hash: raise ValueError('Checksum changed for the pinned Debian revision') + return {} + pkgver = package_version(version) + if int(subprocess.check_output(['vercmp', pkgver, current_pkgver], text=True)) <= 0: + raise ValueError('New Debian revision does not advance Arch version; review mapping') + api = 'https://snapshot.debian.org/mr/binary/qemu-user/' + urllib.parse.quote(version, safe='') + '/binfiles' + document = json.loads(get(api)) + if document.get('binary') != 'qemu-user' or document.get('binary_version') != version: + raise ValueError('Snapshot revision differs') + hashes = {row['hash'] for row in document['result'] if row['architecture'] == 'arm64'} + if len(hashes) != 1 or not re.fullmatch(r'[0-9a-f]{40}', next(iter(hashes), '')): + raise ValueError('Missing/ambiguous ARM64 snapshot') + snapshot = hashes.pop() + archive = get('https://snapshot.debian.org/file/' + snapshot) + if len(archive) != int(selected['Size']) or hashlib.sha256(archive).hexdigest() != checksum or hashlib.sha1(archive).hexdigest() != snapshot: + raise ValueError('Snapshot bytes differ from Debian package metadata') + return {'pkgver': pkgver, 'variables': {'_debver': version, '_snapshot': snapshot}, + 'sha256sums': {'aarch64': [checksum]}} + +def main(): + recipe = Path('PKGBUILD').read_text() + def scalar(name): + match = re.search(r'^' + name + r'=[\"\']?([^\"\'\n]+)', recipe, re.M) + if not match: raise ValueError('Missing recipe scalar: ' + name) + return match[1] + checksum = re.search(r"^sha256sums_aarch64=\('([a-f0-9]{64})'\)", recipe, re.M) + if not checksum: raise ValueError('Missing current ARM checksum') + print(json.dumps(discover(scalar('_debver'), scalar('pkgver'), checksum[1]))) + +if __name__ == '__main__': + try: main() + except Exception as error: + sys.exit('QEMU Debian update failed: ' + str(error)) diff --git a/pkgbuilds/qemu-user-static/.omarchy/upstream.sh b/pkgbuilds/qemu-user-static/.omarchy/upstream.sh new file mode 100644 index 0000000..829dbc9 --- /dev/null +++ b/pkgbuilds/qemu-user-static/.omarchy/upstream.sh @@ -0,0 +1,3 @@ +#!/bin/bash +set -euo pipefail +exec python3 .omarchy/upstream.py diff --git a/pkgbuilds/qemu-user-static/PKGBUILD b/pkgbuilds/qemu-user-static/PKGBUILD index 8a21f11..22b64cc 100644 --- a/pkgbuilds/qemu-user-static/PKGBUILD +++ b/pkgbuilds/qemu-user-static/PKGBUILD @@ -5,8 +5,12 @@ pkgbase=qemu-user-static pkgname=('qemu-user-static' 'qemu-user-static-binfmt') -pkgver=10.0.11 -_debver="1:10.0.11+ds-0+deb13u1+b1" +# One-time epoch moves from upstream-only to full Debian revision ordering. +# pkgver records Debian epoch.upstream.revision; updates also verify vercmp. +epoch=1 +pkgver=1.10.0.13+ds.0+deb13u1 +_debver=1:10.0.13+ds-0+deb13u1 +_snapshot=02b553a4532192bc999d15408d1e56d7b254906f pkgrel=1 pkgdesc="A generic and open source machine emulator and virtualizer - static user-mode emulation" arch=('aarch64') @@ -14,10 +18,11 @@ url="https://www.qemu.org" license=('GPL-2.0-only' 'LGPL-2.1-only') source=("qemu-binfmt-conf.sh") # Debian removes superseded pool files. Pin the official archive snapshot so -# the same checksum-verified binary remains rebuildable. -source_aarch64=("qemu-user_${pkgver}_arm64.deb::https://snapshot.debian.org/archive/debian/20260713T202617Z/pool/main/q/qemu/qemu-user_${_debver#*:}_arm64.deb") +# the same checksum-verified binary remains rebuildable. _snapshot is the +# content-addressed snapshot SHA1; SHA256 below remains the integrity check. +source_aarch64=("qemu-user_${pkgver}_arm64.deb::https://snapshot.debian.org/file/$_snapshot") sha256sums=('5e12ef484b8364e1ba7bae4acda837d2e6320ffbeb7fab341034124553cdb137') -sha256sums_aarch64=('6c9483063bf60f37fe181ead911251deabe40c893ea5829bc34b0b7b88913b6a') +sha256sums_aarch64=('c73711af02b97cd5e2667e735d9c06890c57165517110ca4e646c95a7083158d') noextract=("qemu-user_${pkgver}_arm64.deb") prepare() { diff --git a/pkgbuilds/qemu-user-static/README.md b/pkgbuilds/qemu-user-static/README.md index daeb0ea..d30002c 100644 --- a/pkgbuilds/qemu-user-static/README.md +++ b/pkgbuilds/qemu-user-static/README.md @@ -1,13 +1,13 @@ -# ARM static QEMU candidate +# ARM static QEMU -This split recipe provides `qemu-user-static` and `qemu-user-static-binfmt` on aarch64, where Arch Linux ARM does not supply the static emulator packages requested by Omarchy's default package set. It repackages Debian's checksum-pinned ARM64 binary; it does not compile QEMU or replace upstream's build-worker emulation setup. +This aarch64-only split recipe provides qemu-user-static and qemu-user-static-binfmt using Debian’s static ARM64 binaries. It preserves Marcelo’s recipe, imported through omarchy-mac/omarchy-pkgs-aarch64#61, including licensing and binfmt behavior. It does not change x86 packages or the build workers. -Imported from Marcelo's `maralcbr/omarchy-pkgs` revision `83973903b7deb9b56ce75f02b432fba0561d6293`, through the validated candidate recipe in `omarchy-mac/omarchy-pkgs-aarch64` (#61). Debian copyright and upstream licenses are retained. The immutable Debian snapshot keeps the pinned binary available when the rolling pool removes old revisions. +## Updates -## Draft readiness gates +The package-local upstream hook checks Debian 13 (trixie), trixie-updates, and trixie-security ARM64 indexes. It compares full Debian versions, including epochs, security revisions and binary rebuilds, and resolves the selected binary through snapshot.debian.org’s API. The immutable content-addressed archive must match the index’s size and SHA256 as well as the snapshot SHA1 before an update is returned. -Automatic sync is deliberately disabled in this initial draft. This does **not** detect new releases or security updates. Do not mark this PR ready until a package-local, fixture-tested update integration handles Debian's full version (epoch, upstream version, Debian security revision and architecture rebuild), immutable archive location and SHA256 together. A new Debian revision at the same QEMU version must produce a forward package version; repeated checks must be idempotent. Missing or malformed metadata, unavailable snapshots and checksum inconsistencies must leave the recipe unchanged and fail visibly. +A one-time Arch epoch of 1 moves away from the previous upstream-only version. pkgver encodes Debian epoch.upstream.revision. The hook requires both Debian ordering and pacman vercmp to advance; unfamiliar prerelease conventions or ordering discrepancies fail for manual review. Debian distribution upgrades are explicit recipe changes, not automatic jumps to testing/unstable. -The existing simple custom-hook contract updates pkgver and SHA256 arrays but cannot update `_debver` and the snapshot timestamp together. Do not add a misleading upstream-version-only watch. Review the smallest compatible integration with upstream before removing the hold. Once enabled, updates should join upstream's existing six-hour update PR workflow; GitHub notification settings determine who sees those PRs. Until then, check Debian's package and security updates manually. +The existing six-hour upstream update PR workflow discovers the hook. Its pkgver, _debver, _snapshot and ARM checksum are applied atomically through the normal sync interface. Repeated checks are idempotent. Feed failures, malformed metadata, conflicting checksums and unavailable/corrupt snapshots fail visibly before recipe mutation. No update installs packages or registers binfmt rules. -Before readiness, build both outputs using upstream's ARM builder and test installation in a disposable ARM VM. Verify AArch64 static ELF executables, exact interpreter paths and ownership, the absence of native AArch64 binfmt rules, execution of a known foreign-architecture program both directly and through binfmt, and clean package removal. Never register binfmt rules on the developer host or assume a container isolates that kernel state. Recipe generation only writes rules into the package staging directory. +Offline fixtures cover version/epoch/security/binNMU updates, security-feed selection, unchanged versions, metadata and snapshot failures, atomic application, and hostile/missing scalar rejection. Existing GUI-independent VM qualification and packaging evidence are recorded in the PR; new binaries still receive build and runtime checks before publication. diff --git a/tests/qemu-upstream.py b/tests/qemu-upstream.py new file mode 100644 index 0000000..30a54a2 --- /dev/null +++ b/tests/qemu-upstream.py @@ -0,0 +1,115 @@ +#!/usr/bin/env python3 +import hashlib +import importlib.util +import json +import lzma +import re +from pathlib import Path +import subprocess +import tempfile +import unittest +from unittest.mock import patch + +ROOT = Path(__file__).resolve().parents[1] +PACKAGE = ROOT / 'pkgbuilds/qemu-user-static' +spec = importlib.util.spec_from_file_location('qemu', PACKAGE / '.omarchy/upstream.py') +q = importlib.util.module_from_spec(spec) +spec.loader.exec_module(q) +CURRENT = '1:10.0.11+ds-0+deb13u1+b1' + +class Updates(unittest.TestCase): + def fixtures(self, version): + blob = b'fixture package bytes' + sha = hashlib.sha256(blob).hexdigest() + snap = hashlib.sha1(blob).hexdigest() + row = f'Package: qemu-user\nArchitecture: arm64\nVersion: {version}\nSHA256: {sha}\nSize: {len(blob)}\n' + feeds = {url: lzma.compress(row.encode()) for url in q.FEEDS} + def get(url): + if url in feeds: return feeds[url] + if '/mr/' in url: + return json.dumps({'binary':'qemu-user', 'binary_version':version, + 'result':[{'architecture':'arm64','hash':snap}]}).encode() + return blob + return get, feeds, sha + + def test_versions(self): + for old, new in [(CURRENT,'1:10.0.11+ds-0+deb13u1+b2'), + (CURRENT,'1:10.0.11+ds-0+deb13u2'), + (CURRENT,'1:10.0.12+ds-1'), + (CURRENT,'2:9.0.0+ds-1')]: + with self.subTest(new=new): + get,_,_=self.fixtures(new) + result=q.discover(old,q.package_version(old),'0'*64,get) + self.assertEqual(result['variables']['_debver'],new) + self.assertGreater(q.compare(new,old),0) + self.assertLess(q.compare('1:10.0~rc1-1','1:10.0-1'),0) + self.assertLess(q.compare('1:10.0-2','1:10.0-10'),0) + + def test_security_wins(self): + newer='1:10.0.11+ds-0+deb13u2' + get,feeds,_=self.fixtures(newer) + _,oldfeeds,_=self.fixtures(CURRENT) + feeds[q.FEEDS[0]]=oldfeeds[q.FEEDS[0]] + self.assertEqual(q.discover(CURRENT,q.package_version(CURRENT),'0'*64,get)['variables']['_debver'],newer) + + def test_unchanged(self): + get,_,sha=self.fixtures(CURRENT) + self.assertEqual(q.discover(CURRENT,q.package_version(CURRENT),sha,get),{}) + with self.assertRaisesRegex(ValueError,'Checksum changed'): + q.discover(CURRENT,q.package_version(CURRENT),'0'*64,get) + + def test_bad_metadata(self): + get,feeds,_=self.fixtures('1:10.0.12+ds-1') + feeds[q.FEEDS[0]]=lzma.compress(b'Package: qemu-user\nArchitecture: arm64\nVersion: invalid\n') + with self.assertRaises(ValueError): q.discover(CURRENT,q.package_version(CURRENT),'0'*64,get) + + def test_snapshot_failures(self): + for failure in ['missing','bytes','metadata']: + get,_,_=self.fixtures('1:10.0.12+ds-1') + def bad(url): + if '/file/' in url: + if failure=='missing': raise OSError('unavailable snapshot') + if failure=='bytes': return b'corrupt' + if '/mr/' in url and failure=='metadata': return b'{}' + return get(url) + with self.subTest(failure=failure), self.assertRaises((ValueError,OSError)): + q.discover(CURRENT,q.package_version(CURRENT),'0'*64,bad) + + def sync(self, recipe, release): + with tempfile.TemporaryDirectory() as tmp: + p=Path(tmp)/'fixture'; (p/'.omarchy').mkdir(parents=True) + (p/'PKGBUILD').write_text(recipe) + (p/'.omarchy/package.json').write_text('{"source":"local"}') + (p/'.omarchy/upstream.sh').write_text("#!/bin/bash\ncat <<'JSON'\n"+json.dumps(release)+"\nJSON\n") + # Load production functions, excluding only the command dispatch. + prefix=(ROOT/'bin/sync-upstream').read_text().split('if [[ ${#SPECIFIC_PACKAGES[@]} -gt 0 &&')[0] + prefix=prefix.replace('BUILD_ROOT=$(realpath "${BASH_SOURCE[0]%/*}/..")', 'BUILD_ROOT='+str(ROOT)) + command=prefix+'\nPKGBUILDS_DIR='+tmp+'\nSPECIFIC_MODE=true\nsync_package fixture\n((FAILED == 0))\n' + result=subprocess.run(['bash','-c',command],capture_output=True,text=True) + return result,(p/'PKGBUILD').read_text() + + def test_atomic_sync_and_idempotence(self): + get,_,_=self.fixtures('1:10.0.11+ds-0+deb13u2') + release=q.discover(CURRENT,q.package_version(CURRENT),'0'*64,get) + before=(PACKAGE/'PKGBUILD').read_text() + before=re.sub(r'^pkgver=.*$', 'pkgver='+q.package_version(CURRENT), before, flags=re.M) + before=re.sub(r'^_debver=.*$', '_debver='+CURRENT, before, flags=re.M) + result,after=self.sync(before,release) + self.assertEqual(result.returncode,0,result.stdout+result.stderr) + self.assertIn('_debver='+release['variables']['_debver'],after) + self.assertIn('_snapshot='+release['variables']['_snapshot'],after) + self.assertIn(release['sha256sums']['aarch64'][0],after) + result,again=self.sync(after,release) + self.assertEqual(result.returncode,0,result.stdout+result.stderr) + self.assertEqual(again,after) + + def test_invalid_variables_leave_recipe_unchanged(self): + before=(PACKAGE/'PKGBUILD').read_text() + for variables in [{'_debver':'$(touch /tmp/qemu-injection)'}, {'pkgver':'2'}, + {'_absent':'1'}, {'_snapshot':'abc\ncommand'}, {'_snapshot':'https://bad'}]: + with self.subTest(variables=variables): + result,after=self.sync(before,{'pkgver':'99','variables':variables,'sha256sums':{'aarch64':['a'*64]}}) + self.assertNotEqual(result.returncode,0) + self.assertEqual(before,after) + +if __name__=='__main__': unittest.main() From 90fee672ce47de5e242804d7be13e2c43d57e9e2 Mon Sep 17 00:00:00 2001 From: Marcelo Alcantara Date: Thu, 24 Sep 2026 16:05:38 +1000 Subject: [PATCH 3/3] Register 32-bit ARM binfmt rules and keep QEMU changes package-local qemu-binfmt-conf.sh groups arm with aarch64 and skipped it; Apple Silicon has no AArch32 execution, so --ignore-family is needed for ARM32 programs. The bin/sync-upstream extension, its tests and the Tests workflow edit are reverted; QEMU updates become reviewed pins. --- .github/workflows/test.yml | 1 - bin/sync-upstream | 16 +-- .../qemu-user-static/.omarchy/upstream.py | 120 ------------------ .../qemu-user-static/.omarchy/upstream.sh | 3 - pkgbuilds/qemu-user-static/PKGBUILD | 8 +- pkgbuilds/qemu-user-static/README.md | 8 +- tests/qemu-upstream.py | 115 ----------------- 7 files changed, 10 insertions(+), 261 deletions(-) delete mode 100644 pkgbuilds/qemu-user-static/.omarchy/upstream.py delete mode 100644 pkgbuilds/qemu-user-static/.omarchy/upstream.sh delete mode 100644 tests/qemu-upstream.py diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 7bcbb22..29d18b0 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -50,7 +50,6 @@ jobs: pacman -Syu --noconfirm git jq python libarchive python tests/oma-service-removal.py python tests/upstream-watch.py - python tests/qemu-upstream.py ./bin/sync-upstream self-test ./bin/sync-rebuilds --self-test ./bin/omarchy-pkgs self-test diff --git a/bin/sync-upstream b/bin/sync-upstream index 360a1b2..2f84506 100755 --- a/bin/sync-upstream +++ b/bin/sync-upstream @@ -31,10 +31,7 @@ pkgbuilds//.omarchy/upstream.sh, a hook that reports JSON on stdout: } Architecture keys become sha256sums_ in the PKGBUILD; the key "any" means -the unsuffixed sha256sums array. An empty object ({}) reports no update. Optional "variables" maps existing -underscore-prefixed release scalars to values containing only letters, digits, -periods, underscores, plus, colon and hyphen. They are verified and written in -the same atomic replacement as pkgver and checksums. +the unsuffixed sha256sums array. An empty object ({}) reports no update. When the reported version is newer than the checked-in one, pkgver and the listed checksum arrays are rewritten and pkgrel is reset to 1. @@ -212,10 +209,6 @@ validate_release() { and (.sha256sums | to_entries | all( .value | type == "array" and length > 0 and all(test("\\A[0-9a-f]{64}\\z")) )) - and (if has("variables") then (.variables | type == "object" and (to_entries | all( - (.key | test("\\A_[a-z][a-z0-9_]*\\z")) and - (.value | type == "string" and test("\\A[A-Za-z0-9._+:-]+\\z")) - ))) else true end) and (if has("published_at") then (.published_at | type == "string" and length > 0) else true end) ' <<<"$release" >/dev/null } @@ -319,13 +312,6 @@ apply_release() { set_pkgbuild_scalar "$scratch" pkgver "$pkgver" || exit 1 set_pkgbuild_scalar "$scratch" pkgrel 1 || exit 1 - local field value - while IFS=$'\t' read -r field value; do - [[ -n "$field" ]] || continue - set_pkgbuild_scalar "$scratch" "$field" "$value" || exit 1 - [[ $(bash -c 'source "$1"; printf "%s" "${!2}"' _ "$scratch" "$field") == "$value" ]] || exit 1 - done < <(jq -r '(.variables // {}) | to_entries[] | [.key, .value] | @tsv' <<<"$release") - verify_pkgbuild "$scratch" "$release" "$pkgver" "${arrays[@]}" || exit 1 ); then rm -f "$scratch" diff --git a/pkgbuilds/qemu-user-static/.omarchy/upstream.py b/pkgbuilds/qemu-user-static/.omarchy/upstream.py deleted file mode 100644 index 341d9c1..0000000 --- a/pkgbuilds/qemu-user-static/.omarchy/upstream.py +++ /dev/null @@ -1,120 +0,0 @@ -#!/usr/bin/env python3 -"""Track Debian 13 ARM64 QEMU revisions, verifying an immutable snapshot first.""" -import functools -import hashlib -import json -import lzma -from pathlib import Path -import re -import subprocess -import sys -import urllib.parse -import urllib.request - -FEEDS = [ - 'https://deb.debian.org/debian/dists/trixie/main/binary-arm64/Packages.xz', - 'https://deb.debian.org/debian/dists/trixie-updates/main/binary-arm64/Packages.xz', - 'https://security.debian.org/debian-security/dists/trixie-security/main/binary-arm64/Packages.xz', -] - -def fetch(url): - with urllib.request.urlopen(url, timeout=120) as response: - if not response.url.startswith('https://'): - raise ValueError('Insecure redirect') - return response.read() - -def parts(version): - match = re.fullmatch(r'(?:(\d+):)?([0-9][A-Za-z0-9.+~]*?)-([A-Za-z0-9.+~]+)', version) - if not match: - raise ValueError('Unsupported Debian version: ' + version) - return int(match[1] or '0'), match[2], match[3] - -def segment_cmp(a, b): - # Debian policy: tilde precedes everything, then end/digits, letters, - # then other characters; digit runs are compared numerically. - def order(c): - if c == '~': return -1 - if not c or c.isdigit(): return 0 - return ord(c) if c.isalpha() else ord(c) + 256 - while a or b: - while (a and not a[0].isdigit()) or (b and not b[0].isdigit()): - x, y = order(a[:1]), order(b[:1]) - if x != y: return (x > y) - (x < y) - a, b = a[1:], b[1:] - x = re.match(r'\d*', a)[0] - y = re.match(r'\d*', b)[0] - nx, ny = int(x or '0'), int(y or '0') - if nx != ny: return (nx > ny) - (nx < ny) - a, b = a[len(x):], b[len(y):] - return 0 - -def compare(a, b): - ea, ua, ra = parts(a) - eb, ub, rb = parts(b) - return (ea > eb) - (ea < eb) or segment_cmp(ua, ub) or segment_cmp(ra, rb) - -def package_version(version): - epoch, upstream, revision = parts(version) - # Stable releases only. Fail visibly on prerelease/repack conventions that - # need a reviewed Arch ordering rather than silently misordering them. - if '~' in version: - raise ValueError('Debian prerelease needs a reviewed Arch version mapping') - return f'{epoch}.{upstream}.{revision}' - -def records(data): - found = [] - for stanza in re.split(r'\n\s*\n', lzma.decompress(data).decode()): - fields = dict(re.findall(r'^([A-Za-z0-9-]+): (.*)$', stanza, re.M)) - if fields.get('Package') != 'qemu-user': continue - if fields.get('Architecture') != 'arm64': raise ValueError('Wrong architecture') - parts(fields['Version']) - if not re.fullmatch(r'[0-9a-f]{64}', fields.get('SHA256', '')): - raise ValueError('Missing/malformed SHA256') - if not re.fullmatch(r'[1-9][0-9]*', fields.get('Size', '')): - raise ValueError('Missing/malformed package size') - found.append(fields) - return found - -def discover(current, current_pkgver, current_hash, get=fetch): - candidates = [row for url in FEEDS for row in records(get(url))] - if not candidates: raise ValueError('No ARM64 qemu-user package in Debian feeds') - selected = max(candidates, key=functools.cmp_to_key(lambda a,b: compare(a['Version'], b['Version']))) - version, checksum = selected['Version'], selected['SHA256'] - if any(row['SHA256'] != checksum for row in candidates if row['Version'] == version): - raise ValueError('Debian feeds disagree on the selected checksum') - ordering = compare(version, current) - if ordering < 0: raise ValueError('Debian feeds are older than the pinned recipe') - if ordering == 0: - if checksum != current_hash: raise ValueError('Checksum changed for the pinned Debian revision') - return {} - pkgver = package_version(version) - if int(subprocess.check_output(['vercmp', pkgver, current_pkgver], text=True)) <= 0: - raise ValueError('New Debian revision does not advance Arch version; review mapping') - api = 'https://snapshot.debian.org/mr/binary/qemu-user/' + urllib.parse.quote(version, safe='') + '/binfiles' - document = json.loads(get(api)) - if document.get('binary') != 'qemu-user' or document.get('binary_version') != version: - raise ValueError('Snapshot revision differs') - hashes = {row['hash'] for row in document['result'] if row['architecture'] == 'arm64'} - if len(hashes) != 1 or not re.fullmatch(r'[0-9a-f]{40}', next(iter(hashes), '')): - raise ValueError('Missing/ambiguous ARM64 snapshot') - snapshot = hashes.pop() - archive = get('https://snapshot.debian.org/file/' + snapshot) - if len(archive) != int(selected['Size']) or hashlib.sha256(archive).hexdigest() != checksum or hashlib.sha1(archive).hexdigest() != snapshot: - raise ValueError('Snapshot bytes differ from Debian package metadata') - return {'pkgver': pkgver, 'variables': {'_debver': version, '_snapshot': snapshot}, - 'sha256sums': {'aarch64': [checksum]}} - -def main(): - recipe = Path('PKGBUILD').read_text() - def scalar(name): - match = re.search(r'^' + name + r'=[\"\']?([^\"\'\n]+)', recipe, re.M) - if not match: raise ValueError('Missing recipe scalar: ' + name) - return match[1] - checksum = re.search(r"^sha256sums_aarch64=\('([a-f0-9]{64})'\)", recipe, re.M) - if not checksum: raise ValueError('Missing current ARM checksum') - print(json.dumps(discover(scalar('_debver'), scalar('pkgver'), checksum[1]))) - -if __name__ == '__main__': - try: main() - except Exception as error: - sys.exit('QEMU Debian update failed: ' + str(error)) diff --git a/pkgbuilds/qemu-user-static/.omarchy/upstream.sh b/pkgbuilds/qemu-user-static/.omarchy/upstream.sh deleted file mode 100644 index 829dbc9..0000000 --- a/pkgbuilds/qemu-user-static/.omarchy/upstream.sh +++ /dev/null @@ -1,3 +0,0 @@ -#!/bin/bash -set -euo pipefail -exec python3 .omarchy/upstream.py diff --git a/pkgbuilds/qemu-user-static/PKGBUILD b/pkgbuilds/qemu-user-static/PKGBUILD index 22b64cc..8d983a8 100644 --- a/pkgbuilds/qemu-user-static/PKGBUILD +++ b/pkgbuilds/qemu-user-static/PKGBUILD @@ -6,7 +6,8 @@ pkgbase=qemu-user-static pkgname=('qemu-user-static' 'qemu-user-static-binfmt') # One-time epoch moves from upstream-only to full Debian revision ordering. -# pkgver records Debian epoch.upstream.revision; updates also verify vercmp. +# pkgver records Debian epoch.upstream.revision. Updates are reviewed pins: +# bump _debver, pkgver, _snapshot and the checksum together. epoch=1 pkgver=1.10.0.13+ds.0+deb13u1 _debver=1:10.0.13+ds-0+deb13u1 @@ -55,13 +56,16 @@ package_qemu-user-static-binfmt() { install -dm755 "$pkgdir/usr/lib/binfmt.d" # HOST_ARCH keeps the native architecture out of the rule set regardless # of the build host, so the package is identical under native and QEMU builds. + # --ignore-family keeps 32-bit ARM: the script groups it with aarch64, but + # Apple Silicon cannot execute AArch32 natively. HOST_ARCH=aarch64 sh "$srcdir/qemu-binfmt-conf.sh" \ --systemd ALL \ --exportdir "$pkgdir/usr/lib/binfmt.d/" \ --qemu-path /usr/bin \ --qemu-suffix -static \ --persistent yes \ - --preserve-argv0 yes + --preserve-argv0 yes \ + --ignore-family yes local conf for conf in "$pkgdir"/usr/lib/binfmt.d/*.conf; do mv "$conf" "${conf%.conf}-static.conf" diff --git a/pkgbuilds/qemu-user-static/README.md b/pkgbuilds/qemu-user-static/README.md index d30002c..f487ccd 100644 --- a/pkgbuilds/qemu-user-static/README.md +++ b/pkgbuilds/qemu-user-static/README.md @@ -4,10 +4,8 @@ This aarch64-only split recipe provides qemu-user-static and qemu-user-static-bi ## Updates -The package-local upstream hook checks Debian 13 (trixie), trixie-updates, and trixie-security ARM64 indexes. It compares full Debian versions, including epochs, security revisions and binary rebuilds, and resolves the selected binary through snapshot.debian.org’s API. The immutable content-addressed archive must match the index’s size and SHA256 as well as the snapshot SHA1 before an update is returned. +Updates are reviewed pins, not automatic. To move to a new Debian 13 revision, update `_debver`, `pkgver` (Debian epoch.upstream.revision), `_snapshot` (the snapshot.debian.org SHA1 of the ARM64 `qemu-user` archive) and `sha256sums_aarch64` together, and confirm both Debian ordering and `vercmp` advance. A one-time Arch epoch of 1 moves away from the previous upstream-only version. -A one-time Arch epoch of 1 moves away from the previous upstream-only version. pkgver encodes Debian epoch.upstream.revision. The hook requires both Debian ordering and pacman vercmp to advance; unfamiliar prerelease conventions or ordering discrepancies fail for manual review. Debian distribution upgrades are explicit recipe changes, not automatic jumps to testing/unstable. +## binfmt rules -The existing six-hour upstream update PR workflow discovers the hook. Its pkgver, _debver, _snapshot and ARM checksum are applied atomically through the normal sync interface. Repeated checks are idempotent. Feed failures, malformed metadata, conflicting checksums and unavailable/corrupt snapshots fail visibly before recipe mutation. No update installs packages or registers binfmt rules. - -Offline fixtures cover version/epoch/security/binNMU updates, security-feed selection, unchanged versions, metadata and snapshot failures, atomic application, and hostile/missing scalar rejection. Existing GUI-independent VM qualification and packaging evidence are recorded in the PR; new binaries still receive build and runtime checks before publication. +The rules are generated with `--ignore-family yes`, so 32-bit ARM binaries are registered even though the script groups them with aarch64; Apple Silicon has no AArch32 execution. Native aarch64 stays excluded. Rules use the persistent and preserve-argv0 flags and never the credential flag. diff --git a/tests/qemu-upstream.py b/tests/qemu-upstream.py deleted file mode 100644 index 30a54a2..0000000 --- a/tests/qemu-upstream.py +++ /dev/null @@ -1,115 +0,0 @@ -#!/usr/bin/env python3 -import hashlib -import importlib.util -import json -import lzma -import re -from pathlib import Path -import subprocess -import tempfile -import unittest -from unittest.mock import patch - -ROOT = Path(__file__).resolve().parents[1] -PACKAGE = ROOT / 'pkgbuilds/qemu-user-static' -spec = importlib.util.spec_from_file_location('qemu', PACKAGE / '.omarchy/upstream.py') -q = importlib.util.module_from_spec(spec) -spec.loader.exec_module(q) -CURRENT = '1:10.0.11+ds-0+deb13u1+b1' - -class Updates(unittest.TestCase): - def fixtures(self, version): - blob = b'fixture package bytes' - sha = hashlib.sha256(blob).hexdigest() - snap = hashlib.sha1(blob).hexdigest() - row = f'Package: qemu-user\nArchitecture: arm64\nVersion: {version}\nSHA256: {sha}\nSize: {len(blob)}\n' - feeds = {url: lzma.compress(row.encode()) for url in q.FEEDS} - def get(url): - if url in feeds: return feeds[url] - if '/mr/' in url: - return json.dumps({'binary':'qemu-user', 'binary_version':version, - 'result':[{'architecture':'arm64','hash':snap}]}).encode() - return blob - return get, feeds, sha - - def test_versions(self): - for old, new in [(CURRENT,'1:10.0.11+ds-0+deb13u1+b2'), - (CURRENT,'1:10.0.11+ds-0+deb13u2'), - (CURRENT,'1:10.0.12+ds-1'), - (CURRENT,'2:9.0.0+ds-1')]: - with self.subTest(new=new): - get,_,_=self.fixtures(new) - result=q.discover(old,q.package_version(old),'0'*64,get) - self.assertEqual(result['variables']['_debver'],new) - self.assertGreater(q.compare(new,old),0) - self.assertLess(q.compare('1:10.0~rc1-1','1:10.0-1'),0) - self.assertLess(q.compare('1:10.0-2','1:10.0-10'),0) - - def test_security_wins(self): - newer='1:10.0.11+ds-0+deb13u2' - get,feeds,_=self.fixtures(newer) - _,oldfeeds,_=self.fixtures(CURRENT) - feeds[q.FEEDS[0]]=oldfeeds[q.FEEDS[0]] - self.assertEqual(q.discover(CURRENT,q.package_version(CURRENT),'0'*64,get)['variables']['_debver'],newer) - - def test_unchanged(self): - get,_,sha=self.fixtures(CURRENT) - self.assertEqual(q.discover(CURRENT,q.package_version(CURRENT),sha,get),{}) - with self.assertRaisesRegex(ValueError,'Checksum changed'): - q.discover(CURRENT,q.package_version(CURRENT),'0'*64,get) - - def test_bad_metadata(self): - get,feeds,_=self.fixtures('1:10.0.12+ds-1') - feeds[q.FEEDS[0]]=lzma.compress(b'Package: qemu-user\nArchitecture: arm64\nVersion: invalid\n') - with self.assertRaises(ValueError): q.discover(CURRENT,q.package_version(CURRENT),'0'*64,get) - - def test_snapshot_failures(self): - for failure in ['missing','bytes','metadata']: - get,_,_=self.fixtures('1:10.0.12+ds-1') - def bad(url): - if '/file/' in url: - if failure=='missing': raise OSError('unavailable snapshot') - if failure=='bytes': return b'corrupt' - if '/mr/' in url and failure=='metadata': return b'{}' - return get(url) - with self.subTest(failure=failure), self.assertRaises((ValueError,OSError)): - q.discover(CURRENT,q.package_version(CURRENT),'0'*64,bad) - - def sync(self, recipe, release): - with tempfile.TemporaryDirectory() as tmp: - p=Path(tmp)/'fixture'; (p/'.omarchy').mkdir(parents=True) - (p/'PKGBUILD').write_text(recipe) - (p/'.omarchy/package.json').write_text('{"source":"local"}') - (p/'.omarchy/upstream.sh').write_text("#!/bin/bash\ncat <<'JSON'\n"+json.dumps(release)+"\nJSON\n") - # Load production functions, excluding only the command dispatch. - prefix=(ROOT/'bin/sync-upstream').read_text().split('if [[ ${#SPECIFIC_PACKAGES[@]} -gt 0 &&')[0] - prefix=prefix.replace('BUILD_ROOT=$(realpath "${BASH_SOURCE[0]%/*}/..")', 'BUILD_ROOT='+str(ROOT)) - command=prefix+'\nPKGBUILDS_DIR='+tmp+'\nSPECIFIC_MODE=true\nsync_package fixture\n((FAILED == 0))\n' - result=subprocess.run(['bash','-c',command],capture_output=True,text=True) - return result,(p/'PKGBUILD').read_text() - - def test_atomic_sync_and_idempotence(self): - get,_,_=self.fixtures('1:10.0.11+ds-0+deb13u2') - release=q.discover(CURRENT,q.package_version(CURRENT),'0'*64,get) - before=(PACKAGE/'PKGBUILD').read_text() - before=re.sub(r'^pkgver=.*$', 'pkgver='+q.package_version(CURRENT), before, flags=re.M) - before=re.sub(r'^_debver=.*$', '_debver='+CURRENT, before, flags=re.M) - result,after=self.sync(before,release) - self.assertEqual(result.returncode,0,result.stdout+result.stderr) - self.assertIn('_debver='+release['variables']['_debver'],after) - self.assertIn('_snapshot='+release['variables']['_snapshot'],after) - self.assertIn(release['sha256sums']['aarch64'][0],after) - result,again=self.sync(after,release) - self.assertEqual(result.returncode,0,result.stdout+result.stderr) - self.assertEqual(again,after) - - def test_invalid_variables_leave_recipe_unchanged(self): - before=(PACKAGE/'PKGBUILD').read_text() - for variables in [{'_debver':'$(touch /tmp/qemu-injection)'}, {'pkgver':'2'}, - {'_absent':'1'}, {'_snapshot':'abc\ncommand'}, {'_snapshot':'https://bad'}]: - with self.subTest(variables=variables): - result,after=self.sync(before,{'pkgver':'99','variables':variables,'sha256sums':{'aarch64':['a'*64]}}) - self.assertNotEqual(result.returncode,0) - self.assertEqual(before,after) - -if __name__=='__main__': unittest.main()