From eadb1f05bcf5adc44a8e26ed1c28d2655a70a429 Mon Sep 17 00:00:00 2001 From: Senemos Maintainers <75160848+MCC45TR@users.noreply.github.com> Date: Thu, 8 Oct 2026 19:27:22 +0300 Subject: [PATCH 1/7] Build coordinated stock ABL and Fedora Core images for Uke --- .github/workflows/validate.yml | 5 +- README.md | 16 ++ configs/boot/uke-boot-pair-host-rpms.json | 6 + configs/boot/uke-boot-pair-rpms.json | 22 ++ configs/boot/uke-boot-pair.json | 56 ++++ src/boot-pair/android-sparse.cpp | 118 +++++++++ src/boot-pair/container-stage.sh | 194 ++++++++++++++ src/boot-pair/dracut/module-setup.sh | 22 ++ src/boot-pair/dracut/root-guard.sh | 41 +++ .../dracut/uke-initrd-screen.service | 21 ++ src/boot-pair/dracut/uke-root-guard.service | 14 + src/boot-pair/entry.sh | 248 ++++++++++++++++++ src/boot-pair/prepare-root.sh | 77 ++++++ src/boot-pair/root-status.sh | 14 + src/boot-pair/screen-shell.sh | 8 + src/boot-pair/uke-cdc-shell.service | 22 ++ src/boot-pair/uke-root-status.service | 14 + src/boot-pair/uke-screen-shell.service | 23 ++ src/image/core.sh | 11 +- tests/android-sparse-contract.sh | 31 +++ tests/boot-pair-contract.sh | 44 ++++ tests/boot-pair-qemu-prepare.sh | 25 ++ tests/boot-pair-qemu.sh | 101 +++++++ 23 files changed, 1131 insertions(+), 2 deletions(-) create mode 100644 configs/boot/uke-boot-pair-host-rpms.json create mode 100644 configs/boot/uke-boot-pair-rpms.json create mode 100644 configs/boot/uke-boot-pair.json create mode 100644 src/boot-pair/android-sparse.cpp create mode 100644 src/boot-pair/container-stage.sh create mode 100644 src/boot-pair/dracut/module-setup.sh create mode 100644 src/boot-pair/dracut/root-guard.sh create mode 100644 src/boot-pair/dracut/uke-initrd-screen.service create mode 100644 src/boot-pair/dracut/uke-root-guard.service create mode 100644 src/boot-pair/entry.sh create mode 100644 src/boot-pair/prepare-root.sh create mode 100644 src/boot-pair/root-status.sh create mode 100644 src/boot-pair/screen-shell.sh create mode 100644 src/boot-pair/uke-cdc-shell.service create mode 100644 src/boot-pair/uke-root-status.service create mode 100644 src/boot-pair/uke-screen-shell.service create mode 100644 tests/android-sparse-contract.sh create mode 100644 tests/boot-pair-contract.sh create mode 100644 tests/boot-pair-qemu-prepare.sh create mode 100644 tests/boot-pair-qemu.sh diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 2f64733..225bd5e 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -16,10 +16,13 @@ jobs: sudo apt-get install -y --no-install-recommends shellcheck jq ripgrep device-tree-compiler g++ libext2fs-dev libselinux1-dev - name: Validate scripts and admission contracts run: | - shellcheck src/image/*.sh src/boot/*.sh src/audit/*.sh tests/*.sh + shellcheck src/image/*.sh src/boot/*.sh src/boot-pair/*.sh src/boot-pair/dracut/*.sh src/audit/*.sh tests/*.sh bash tests/image-contract.sh bash tests/boot-contract.sh + bash tests/boot-pair-contract.sh bash tests/kernel-dt-admission.sh g++ -std=c++20 -O2 -Wall -Wextra -Werror src/image/ext4-labels.cpp -o /tmp/ext4-labels -lext2fs -lcom_err -lselinux g++ -std=c++20 -O2 -Wall -Wextra -Werror src/boot/boot-image.cpp -o /tmp/boot-image bash tests/boot-image-contract.sh /tmp/boot-image + g++ -std=c++20 -O2 -Wall -Wextra -Werror src/boot-pair/android-sparse.cpp -o /tmp/android-sparse + bash tests/android-sparse-contract.sh /tmp/android-sparse /tmp/android-sparse-fixtures diff --git a/README.md b/README.md index afa7ae8..274d0ef 100644 --- a/README.md +++ b/README.md @@ -95,6 +95,22 @@ keep writable output separate from read-only source and payload mounts. ## Stock ABL boot_b development route +`./ukelinux.sh --build boot-pair --distro=fedora --help` describes the new +two-image development recipe for `boot_b` and a separate GPT partition named +`linux`. It prepares a root-capable built-in initramfs, source-pinned adaptation +of the bootloader-selected DT and a local tablet-screen TTY candidate. The +host builder never changes GPT, Android userdata, slots, init_boot, vendor_boot +or dtbo. Actual Image/modules/DTB compilation, all 1,146 module byte comparisons +against signed COPR release 1.5 and the linked initramfs check have passed. +EXT4 labeling/readback and full sparse decode checks have passed. A generic +ARM64 diagnostic VM reached the enforcing Fedora root and executed a local +TTY keyboard command; the final frozen recipe and negative-root cases are +under revalidation. Physical ABL, UFS, screen and input acceptance are separate +owner-test gates. +See the [paired-image operator guide](https://github.com/MCC45TR/uke-linux-docs/blob/main/docs/testing/FEDORA-BOOT-PAIR.md) +for coordinated UUIDs, the separate GPT `linux` prerequisite and rollback. +Do not substitute the older initramfs-only image for this root-capable pair. + `./ukelinux.sh --build boot --distro=fedora --help` describes the additional host-only `fedora_boot.img` route. It builds a raw ARM64 kernel with a built-in Fedora debug initramfs and wraps it in Android boot v4. Aloha and ESP are not diff --git a/configs/boot/uke-boot-pair-host-rpms.json b/configs/boot/uke-boot-pair-host-rpms.json new file mode 100644 index 0000000..28a9c6c --- /dev/null +++ b/configs/boot/uke-boot-pair-host-rpms.json @@ -0,0 +1,6 @@ +[ + {"file":"dracut-111-4.fc46.aarch64.rpm","sha256":"238700de19f7a415d0be309248258940e0b79727f0bde4a0cd371646c5188830"}, + {"file":"kbd-2.10.0-3.fc46.aarch64.rpm","sha256":"61d67855568dd833b60bafa05b780c772fd1996511d061b2da058a40a6fd9832"}, + {"file":"xkeyboard-config-2.48-4.fc45.noarch.rpm","sha256":"1e2933eca6a7c8bf7e39e071299cb4a4cd712f43e8228474c91515c6dbc72ae7"}, + {"file":"libxkbcommon-1.13.1-4.fc46.aarch64.rpm","sha256":"9ffa7a09728604ff701d48a72541682889d2e6c4f7dbc0a0833af0011081c1df"} +] diff --git a/configs/boot/uke-boot-pair-rpms.json b/configs/boot/uke-boot-pair-rpms.json new file mode 100644 index 0000000..cc0f015 --- /dev/null +++ b/configs/boot/uke-boot-pair-rpms.json @@ -0,0 +1,22 @@ +[ + { + "file": "senemos-uke-linux-kernel-mainline-7.2.9-1.5.fc46.aarch64.rpm", + "sha256": "d71f9cfb93bc635b6fe9857e0c5ac7d941899f46413bd35094c8dc6320a057f3", + "nevra": "senemos-uke-linux-kernel-mainline-0:7.2.9-1.5.fc46.aarch64" + }, + { + "file": "senemos-uke-linux-kernel-mainline-core-7.2.9-1.5.fc46.aarch64.rpm", + "sha256": "f0a87256666f501fea8d324e6cd4b4205e3e42cc7a7b5581440534b29a2908cd", + "nevra": "senemos-uke-linux-kernel-mainline-core-0:7.2.9-1.5.fc46.aarch64" + }, + { + "file": "senemos-uke-linux-kernel-mainline-dtbs-7.2.9-1.5.fc46.aarch64.rpm", + "sha256": "9b4ca266bee7772f8d5e700a7a5053cc63114955aa6caf1adf52d8f6e462a565", + "nevra": "senemos-uke-linux-kernel-mainline-dtbs-0:7.2.9-1.5.fc46.aarch64" + }, + { + "file": "senemos-uke-linux-kernel-mainline-modules-7.2.9-1.5.fc46.aarch64.rpm", + "sha256": "38bb5e7732dd3a66215720de46cccb2c2ac685fdb44b1ab9e97704798f65fb63", + "nevra": "senemos-uke-linux-kernel-mainline-modules-0:7.2.9-1.5.fc46.aarch64" + } +] diff --git a/configs/boot/uke-boot-pair.json b/configs/boot/uke-boot-pair.json new file mode 100644 index 0000000..d6d1d3a --- /dev/null +++ b/configs/boot/uke-boot-pair.json @@ -0,0 +1,56 @@ +{ + "schema_version": 1, + "device": "uke", + "distribution": "fedora", + "release": "rawhide", + "mode": "stock-abl-ext4-root", + "default_boot_template": "android17-evolutionx-12.1", + "boot_templates": [ + { + "id": "global-os3.0.303.0", + "source_class": "OEM-global", + "sha256": "efdee1d4e1acd7f6e77615330dbcb045caeeccad8568d89fa6abd627f606e77f", + "bytes": 100663296 + }, + { + "id": "global-os3.0.304.0", + "source_class": "OEM-global", + "sha256": "1eaf4858a7c70bee9b663b485f3192b164738bbdae3edbb986a5419621c8ef30", + "bytes": 100663296 + }, + { + "id": "android17-evolutionx-12.1", + "source_class": "community-ROM", + "sha256": "b8da3f7d9c54bba2a0cf6fc72af0df14e7f2993b251ffd2390bfa693d46d2b45", + "bytes": 100663296 + } + ], + "boot_partition_bytes": 100663296, + "header_version": 4, + "kernel_release": "7.2.9-senemos-uke", + "patch_count": 13, + "kernel_manifest_sha256": "a545fe2f6029346bbf1cb34b115b07a29f0933064151116328e1f8364f9fae54", + "kernel_config_sha256": "e2995d52f963778ba13e48329dad9fb8fbcd784b6c7a43e97ec1a2940ab020ac", + "base_kernel_image_sha256": "5eacec7505f85df39df868c5a9e81274cf55bbaf6ee8d91e71fd827eba80c56c", + "kernel_rpm_spec_sha256": "c1db3c429f3f9856493bd05b08e886a9c46346052de22d43144ef9e0c9154ad8", + "base_module_symvers_sha256": "fa28dccf8b45ea718d318cb772f002004a0125abb18e8cea390977fd654f754e", + "host_tool_image": "40c9868d08bbad2cb58312fd4c8295ec2a3f132f95bd0eb150a45e05160c2866", + "kernel_tool_image": "a6fb262f41c060702100f704f5c63ac48957682e84befb0812f08e248f2bec7e", + "qemu_tool_image": "9a044f1e709a13ab768bfad155ce969e5ae637ea2378b7277fbc2fad358daaf6", + "external_initramfs": "ignored-by-CONFIG_INITRAMFS_FORCE", + "dt_source": "bootloader-selected-stock-FDT-with-built-in-reviewed-provider-adaptation", + "dt_handoff_verified": false, + "avb_state": "unsigned-no-footer", + "boot_tested": false, + "hardware_tested": false, + "root_size_mib": 3072, + "core_prepared_root_sha256": "33a2343d17d1f4e588737aab704b93277e4bef4f4e6b3efe01341cc4a844ecd2", + "root_tool_image": "9e787753723cc715b6d46fabb9f7b17ceb5195d269224a3f8262fd47a05833ad", + "root_selector": "UUID-and-PARTNAME-linux", + "system_format": "android-sparse-raw-and-fill-only", + "partition_changes": [ + "boot_b", + "linux" + ], + "display": "simplefb-O82-3200x2136-from-runtime-splash-reservation" +} diff --git a/src/boot-pair/android-sparse.cpp b/src/boot-pair/android-sparse.cpp new file mode 100644 index 0000000..b98fdbd --- /dev/null +++ b/src/boot-pair/android-sparse.cpp @@ -0,0 +1,118 @@ +// SPDX-License-Identifier: MIT +// Host-only Android sparse codec. Every logical block is RAW or explicit FILL; +// never DONT_CARE. Accept only bounded regular files and new regular outputs. +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +static constexpr uint32_t magic=0xed26ff3a, block=4096; +static constexpr uint16_t raw=0xcac1, fill=0xcac2; +static constexpr uint64_t limit=4ULL*1024*1024*1024; +static void require(bool value, const char *message) { if (!value) throw std::runtime_error(message); } +static void io(int fd, void *buffer, size_t bytes, bool write) { + auto *at=static_cast(buffer); + while (bytes) { + const auto count=write ? ::write(fd,at,bytes) : ::read(fd,at,bytes); + if (count<0 && errno==EINTR) continue; + require(count>0,"Short or failed I/O"); at+=count; bytes-=count; + } +} +static uint32_t get(const unsigned char *p, unsigned bytes) { + uint32_t value=0; for (unsigned i=0;i>(i*8); +} +static uint64_t size(int fd) { + struct stat st {}; require(!fstat(fd,&st) && S_ISREG(st.st_mode) && st.st_size>0 && + uint64_t(st.st_size)<=limit,"Input must be a bounded regular file"); return st.st_size; +} +static void chunk(int out,uint16_t type,uint32_t blocks,uint32_t payload) { + std::array header {}; + put(header.data(),type,2); put(header.data()+4,blocks,4); put(header.data()+8,12+payload,4); + io(out,header.data(),header.size(),true); +} +static uint32_t uniform(const std::array &data,bool &same) { + const uint32_t value=get(data.data(),4); same=true; + for (size_t i=4;i header {}; + put(header.data(),magic,4); put(header.data()+4,1,2); put(header.data()+8,28,2); + put(header.data()+10,12,2); put(header.data()+12,block,4); put(header.data()+16,bytes/block,4); + io(out,header.data(),header.size(),true); + uint32_t count=0, run=0, pattern=0; bool fill_run=false; + std::vector payload; payload.reserve(1024*1024); + auto flush=[&] { + if (!run) return; + chunk(out,fill_run?fill:raw,run,fill_run?4:payload.size()); + if (fill_run) { std::array word {}; put(word.data(),pattern,4); io(out,word.data(),4,true); } + else io(out,payload.data(),payload.size(),true); + ++count; run=0; payload.clear(); + }; + for (uint64_t i=0;i data {}; io(in,data.data(),data.size(),false); + bool same; const auto value=uniform(data,same); + if (run && (same!=fill_run || (same && value!=pattern) || (!same && payload.size()>=1024*1024))) flush(); + if (!run) { fill_run=same; pattern=value; } + ++run; if (!same) payload.insert(payload.end(),data.begin(),data.end()); + } + flush(); put(header.data()+20,count,4); + require(lseek(out,0,SEEK_SET)==0,"Header seek failed"); io(out,header.data(),header.size(),true); + std::cout << "Logical blocks=" << bytes/block << " chunks=" << count << " DONT_CARE=0\n"; +} +static void decode(int in,int out,uint64_t input_bytes) { + std::array header {}; io(in,header.data(),header.size(),false); + require(get(header.data(),4)==magic && get(header.data()+4,2)==1 && get(header.data()+6,2)==0 && + get(header.data()+8,2)==28 && get(header.data()+10,2)==12 && + get(header.data()+12,4)==block && get(header.data()+24,4)==0,"Unsupported sparse header"); + const uint64_t total=get(header.data()+16,4); const uint32_t chunks=get(header.data()+20,4); + require(total && total*block<=limit && chunks && chunks<=total,"Invalid sparse bounds"); + uint64_t produced=0,consumed=28; + for (uint32_t i=0;i ch {}; io(in,ch.data(),ch.size(),false); consumed+=12; + const auto type=get(ch.data(),2), blocks=get(ch.data()+4,4), stored=get(ch.data()+8,4); + require(!get(ch.data()+2,2) && blocks && blocks<=total-produced,"Invalid sparse chunk bounds"); + const uint64_t bytes=uint64_t(blocks)*block; + require((type==raw && bytes<=UINT32_MAX-12 && stored==bytes+12) || + (type==fill && stored==16),"Unsupported chunk, including DONT_CARE"); + require(stored<=input_bytes-consumed+12,"Truncated sparse chunk"); + std::array data {}; + if (type==fill) { io(in,data.data(),4,false); consumed+=4; for (size_t at=4;at=0,"Cannot open regular input"); + const auto bytes=size(in); + out=open(argv[3],O_WRONLY|O_CREAT|O_EXCL|O_NOFOLLOW|O_CLOEXEC,0644); + require(out>=0,"Output must be new"); created=true; + if (std::string(argv[1])=="encode") encode(in,out,bytes); else decode(in,out,bytes); + require(!fsync(out),"Output flush failed"); close(out); close(in); return 0; + } catch (const std::exception &error) { + if (out>=0) close(out); + if (in>=0) close(in); + if (created) unlink(argv[3]); + std::cerr << error.what() << '\n'; return 1; + } +} diff --git a/src/boot-pair/container-stage.sh b/src/boot-pair/container-stage.sh new file mode 100644 index 0000000..3283f95 --- /dev/null +++ b/src/boot-pair/container-stage.sh @@ -0,0 +1,194 @@ +#!/usr/bin/env bash +# Frozen, offline host stages. Every write stays below the disposable job. +set -Eeuo pipefail +[[ $# == 2 && -d /job/inputs ]] +stage=$1 owner=$2 +trap 'if [[ $owner != none ]]; then chown -R "$owner" /job; fi' EXIT +profile=/job/inputs/profile.json +release=7.2.9-senemos-uke +verify() { [[ $(sha256sum "$1" | cut -d ' ' -f1) == "$2" ]]; } +(cd /job/inputs; sha256sum -c SHA256SUMS) +case $stage in +source) + top=/job/kernel/build/fedora-rawhide/7.2.9/rpmbuild + mkdir -p "$top/SOURCES" "$top/SPECS" /job/dt + cp /job/inputs/SOURCES/* "$top/SOURCES/" + cp /job/inputs/kernel.spec "$top/SPECS/kernel.spec" + verify "$top/SPECS/kernel.spec" "$(jq -r .kernel_rpm_spec_sha256 "$profile")" + rpmbuild --nodeps -bp --target=aarch64 --define "_topdir $top" "$top/SPECS/kernel.spec" + source=$top/BUILD/senemos-uke-linux-kernel-mainline-7.2.9-build/linux-7.2.9 + cmp "$source/senemos-applied-patches.txt" /job/inputs/applied-patches.txt + jq -S . "$source/senemos-adaptation/source-lock.json" > /job/prepared-source.json + jq -S .source /job/inputs/kernel-manifest.json > /job/qualified-source.json + cmp /job/prepared-source.json /job/qualified-source.json + bash /kernel-recipe/src/boot/prepare-abl-source.sh "$source" /job/dt + # The host test uses the exact same transform and vendored libfdt C code + # as the kernel. It does not execute scripts from the source donor. + gcc -std=gnu11 -O2 -Wall -Wextra -Werror -I "$source/scripts/dtc/libfdt" \ + -c /kernel-recipe/src/boot/uke-abl.c -o /job/dt/uke-abl-host.o + objects=() + for name in fdt fdt_ro fdt_wip fdt_rw fdt_sw fdt_strerror fdt_empty_tree fdt_addresses fdt_overlay fdt_check; do + gcc -std=gnu11 -O2 -I "$source/scripts/dtc/libfdt" -c "$source/scripts/dtc/libfdt/$name.c" -o "/job/dt/$name.o" + objects+=("/job/dt/$name.o") + done + g++ -std=c++20 -O2 -Wall -Wextra -Werror -I "$source/scripts/dtc/libfdt" \ + /kernel-recipe/tests/abl-dt-check.cpp /job/dt/uke-abl-host.o "${objects[@]}" -o /job/dt/abl-dt-check + printf '%s\n' 'Qualified source preparation and stock-ABL patch applied; host transform compiled' + ;; +dt-test) + [[ -s /private/live-fdt.dtb ]] + (cd /private; sha256sum -c live-fdt.sha256) + if [[ -e /private/abl-fixture ]]; then + mv /private/abl-fixture "/private/abl-fixture-failed-$(date -u +%Y%m%dT%H%M%SZ)" + fi + bash /kernel-recipe/tests/abl-dt-contract.sh /job/dt/abl-dt-check /private/live-fdt.dtb \ + /job/dt/ufs.dtbo /job/dt/usb.dtbo /private/abl-fixture + ;; +audit-initramfs) + verify /job/inputs/prepared-root.tar "$(jq -r .core_prepared_root_sha256 "$profile")" + rm -rf /job/initramfs-root + mkdir /job/initramfs-root + (cd /job/initramfs-root; gzip -cd /job/initramfs.cpio.gz | cpio -idm --quiet --no-absolute-filenames) + [[ -s /job/initramfs-root/usr/lib/systemd/system/initrd-switch-root.service ]] + [[ -L /job/initramfs-root/usr/lib/systemd/system/sysroot.mount.requires/uke-root-guard.service ]] + [[ -s /job/initramfs-root/etc/uke-boot-pair.uuid ]] + [[ -x /job/initramfs-root/usr/libexec/uke-root-guard ]] + for name in ufs_qcom phy_qcom_qmp_ufs dwc3_qcom phy_msm_snps_eusb2_uke repeater_qti_pmic_eusb2_uke g_serial; do + modinfo -b /job/initramfs-root -k "$release" "$name" >/dev/null + done + bash /builder/src/audit/check-target-payload.sh /job/initramfs-root + bash /builder/src/audit/check-target-privacy.sh /job/initramfs-root + bash /builder/src/audit/check-target-payload.sh /job/root + bash /builder/src/audit/check-target-privacy.sh --namespace-policy /builder/configs/images/selinux-namespace-policy.json /job/root + while IFS= read -r -d '' file; do + if [[ $(od -An -tx1 -N4 "$file" | tr -d ' \n') == 7f454c46 ]]; then + readelf -h "$file" | grep -E 'Machine:.*AArch64' >/dev/null + fi + done < <(find /job/initramfs-root -type f -print0) + ;; +kernel) + top=/job/kernel/build/fedora-rawhide/7.2.9/rpmbuild + out=$top/kernel-out + source=$top/BUILD/senemos-uke-linux-kernel-mainline-7.2.9-build/linux-7.2.9 + cp /job/inputs/base.config "$out/.config" + export ARCH=arm64 LLVM=1 KBUILD_BUILD_USER=senemos KBUILD_BUILD_HOST=uke-builder + export KBUILD_BUILD_TIMESTAMP='2026-10-03 10:44:10 UTC' KBUILD_BUILD_VERSION=1 SOURCE_DATE_EPOCH=1791024250 + clang --version > /job/toolchain.txt + cd "$source" + scripts/config --file "$out/.config" --set-str INITRAMFS_SOURCE /job/initramfs.cpio.gz \ + --enable INITRAMFS_FORCE --enable CMDLINE_FORCE --disable CMDLINE_FROM_BOOTLOADER \ + --enable QCOM_UKE_ABL_DT --set-str CMDLINE "$(jq -r .kernel_command_line "$profile")" + make O="$out" LOCALVERSION=-senemos-uke olddefconfig + grep -Fx CONFIG_QCOM_UKE_ABL_DT=y "$out/.config" + grep -Fx CONFIG_FB_SIMPLE=y "$out/.config" + grep -Fx CONFIG_FRAMEBUFFER_CONSOLE=y "$out/.config" + grep -Fx '# CONFIG_FRAMEBUFFER_CONSOLE_DEFERRED_TAKEOVER is not set' "$out/.config" + grep '^CONFIG_' /job/inputs/base.config | grep -Ev '^CONFIG_(QCOM_UKE_ABL_DT|INITRAMFS_(SOURCE|FORCE|ROOT_UID|ROOT_GID|COMPRESSION_[A-Z0-9_]+)|CMDLINE|CMDLINE_FORCE|CMDLINE_FROM_BOOTLOADER)=' | sort > /job/base-module.config + grep '^CONFIG_' "$out/.config" | grep -Ev '^CONFIG_(QCOM_UKE_ABL_DT|INITRAMFS_(SOURCE|FORCE|ROOT_UID|ROOT_GID|COMPRESSION_[A-Z0-9_]+)|CMDLINE|CMDLINE_FORCE|CMDLINE_FROM_BOOTLOADER)=' | sort > /job/boot-module.config + cmp /job/base-module.config /job/boot-module.config + diff -u /job/inputs/base.config "$out/.config" > /job/config-delta.txt || [[ $? == 1 ]] + make O="$out" LOCALVERSION=-senemos-uke -j"$(cat /job/inputs/jobs)" Image modules dtbs + [[ $(make -s O="$out" LOCALVERSION=-senemos-uke kernelrelease) == "$release" ]] + sort "$out/vmlinux.symvers" > /job/boot-vmlinux.symvers + cmp /job/inputs/base-vmlinux.symvers /job/boot-vmlinux.symvers + # Module-byte equality is stronger than comparing zero CRCs when + # MODVERSIONS is off. Every freshly built module must match its installed + # qualified RPM byte for byte; otherwise composition stops for repackaging. + count=0 + while IFS= read -r module; do + [[ -n $module ]] + # Linux 7.2 Kbuild records the module object in modules.order; + # the installed payload and final linker product use .ko. + case $module in *.o) module=${module%.o}.ko;; *.ko) :;; *) exit 1;; esac + installed=/job/root/usr/lib/modules/$release/kernel/$module.zst + [[ -s $installed && -s $out/$module ]] + cp "$out/$module" /job/module-compare.ko + llvm-strip -g /job/module-compare.ko + zstd -dc "$installed" | cmp - /job/module-compare.ko + count=$((count + 1)) + done < "$out/modules.order" + [[ $count == 1146 ]] + rm /job/module-compare.ko + printf '%s\n' "$count" > /job/module-byte-match-count + cp "$out/.config" /job/boot.config + cp "$out/arch/arm64/boot/Image" /job/Image + cp "$out/Module.symvers" /job/Module.symvers + # Extract the linked initramfs by actual ELF symbol/section addresses. + llvm-objcopy --dump-section .init.data=/job/init-data.bin "$out/vmlinux" /job/ramfs-inspection.elf + base=$(llvm-readelf -SW "$out/vmlinux" | awk '$2==".init.data" {print $4}') + start=$(llvm-nm "$out/vmlinux" | awk '$3=="__initramfs_start" {print $1}') + length_address=$(llvm-nm "$out/vmlinux" | awk '$3=="__initramfs_size" {print $1}') + [[ $base =~ ^[a-f0-9]{16}$ && $start =~ ^[a-f0-9]{16}$ && $length_address =~ ^[a-f0-9]{16}$ ]] + offset=$((16#$start - 16#$base)); size_offset=$((16#$length_address - 16#$base)) + length=$(od -An -tu8 -j "$size_offset" -N8 /job/init-data.bin | tr -d ' \n') + ((offset >= 0 && length > 0 && offset + length <= $(stat -c %s /job/init-data.bin))) + dd if=/job/init-data.bin of=/job/embedded-initramfs.cpio.gz bs=1M skip="$offset" count="$length" iflag=skip_bytes,count_bytes status=none + cmp /job/initramfs.cpio.gz /job/embedded-initramfs.cpio.gz + rm /job/ramfs-inspection.elf /job/init-data.bin + ;; +compose) + [[ $(cat /job/module-byte-match-count) == 1146 ]] + (cd /job; sha256sum -c pair-root.sha256) + # Restore numeric owners/capabilities from the ARM64 archive, including + # when a rootful Docker host needed chown for intermediate job access. + rm -rf /job/root + mkdir /job/root + tar --numeric-owner --same-owner --xattrs --xattrs-include='security.capability' -xf /job/pair-root.tar -C /job/root + # Resume a failed composition without trusting a partial image. Completed + # candidates are returned by the host entry before reaching this stage. + for file in fedora_boot.img roundtrip-Image system.raw.img system.img system.roundtrip.img; do + [[ ! -e /job/$file ]] || { [[ -f /job/$file && ! -L /job/$file ]]; rm "/job/$file"; } + done + rm -rf /job/ext4-readback /job/sparse-fixtures + root=/job/root + # Keep RPM-owned vmlinuz/config intact. The stock-ABL entry is a separate + # built-in-initramfs image, with verified identical module bytes. + install -Dm644 /job/boot.config "$root/usr/lib/uke-boot-pair/boot.config" + install -m644 /job/Image "$root/usr/lib/uke-boot-pair/Image" + install -m644 /job/inputs/profile.json "$root/usr/lib/uke-boot-pair/profile.json" + g++ -std=c++20 -O2 -Wall -Wextra -Werror /builder/src/boot/boot-image.cpp -o /job/boot-image + bash /builder/tests/boot-image-contract.sh /job/boot-image + /job/boot-image pack /job/inputs/stock-boot.img /job/Image /job/fedora_boot.img "$(jq -r .boot_partition_bytes "$profile")" + /job/boot-image inspect /job/fedora_boot.img > /job/boot-header.json + /job/boot-image extract /job/fedora_boot.img /job/roundtrip-Image + cmp /job/Image /job/roundtrip-Image + jq -e '.header_version==4 and .ramdisk_bytes==0 and .signature_bytes==0 and .avb_footer_present==false' /job/boot-header.json >/dev/null + root_bytes=$(($(jq -r .root_size_mib "$profile") * 1024 * 1024)) + truncate -s "$root_bytes" /job/system.raw.img + root_uid=$(stat -c %u "$root") root_gid=$(stat -c %g "$root") root_mode=$(stat -c %a "$root") + [[ $root_uid == 0 && $root_gid =~ ^[0-9]+$ && $root_mode =~ ^[0-7]{3,4}$ ]] + mkfs.ext4 -q -F -b 4096 -L UKE_LINUX -U "$(jq -r .root_uuid "$profile")" -m 0 \ + -E "lazy_itable_init=0,lazy_journal_init=0,root_owner=$root_uid:$root_gid,root_perms=$root_mode" \ + -d "$root" /job/system.raw.img + g++ -std=c++20 -O2 -Wall -Wextra -Werror /builder/src/image/ext4-labels.cpp -o /job/ext4-labels -lext2fs -lcom_err -lselinux + contexts=$root/etc/selinux/targeted/contexts/files/file_contexts + /job/ext4-labels apply /job/system.raw.img "$root" "$contexts" + /job/ext4-labels verify /job/system.raw.img "$root" "$contexts" + e2fsck -fn /job/system.raw.img + g++ -std=c++20 -O2 -Wall -Wextra -Werror /builder/src/boot-pair/android-sparse.cpp -o /job/android-sparse + bash /builder/tests/android-sparse-contract.sh /job/android-sparse /job/sparse-fixtures + /job/android-sparse encode /job/system.raw.img /job/system.img + /job/android-sparse decode /job/system.img /job/system.roundtrip.img + cmp /job/system.raw.img /job/system.roundtrip.img + rm /job/system.roundtrip.img + mkdir /job/ext4-readback + debugfs -R 'rdump / /job/ext4-readback' /job/system.raw.img + bash /builder/src/audit/check-target-payload.sh /job/ext4-readback + bash /builder/src/audit/check-target-privacy.sh --namespace-policy /builder/configs/images/selinux-namespace-policy.json /job/ext4-readback + cmp "$root/etc/fstab" /job/ext4-readback/etc/fstab + cmp /job/Image /job/ext4-readback/usr/lib/uke-boot-pair/Image + cmp /job/initramfs.cpio.gz "/job/ext4-readback/boot/initramfs-$release.img" + (cd /job; sha256sum fedora_boot.img system.img system.raw.img Image boot.config initramfs.cpio.gz Module.symvers boot-header.json inputs/profile.json installed-rpms.tsv toolchain.txt > SHA256SUMS) + jq -n --slurpfile profile "$profile" --arg recipe "$(cat /job/inputs/recipe-sha256)" \ + --arg boot "$(sha256sum /job/fedora_boot.img | cut -d ' ' -f1)" \ + --arg system "$(sha256sum /job/system.img | cut -d ' ' -f1)" \ + --arg raw "$(sha256sum /job/system.raw.img | cut -d ' ' -f1)" \ + '{schema_version:1,status:"host-validated-stock-ABL-pair-candidate",recipe_sha256:$recipe,profile:$profile[0],sha256:{boot:$boot,system:$system,decoded_ext4:$raw},checks:{source_preparation:true,base_patches:13,abl_adaptation_patch:1,embedded_initramfs_byte_match:true,module_byte_match:1146,android_v4_roundtrip:true,full_sparse_logical_coverage:true,sparse_roundtrip:true,all_inode_selinux_labels:true,ext4_readback:true,no_python:true,privacy:true},qemu_tested:false,boot_tested:false,hardware_tested:false,flash_executed:false}' > /job/manifest.json + (cd /job; sha256sum manifest.json >> SHA256SUMS; sha256sum -c SHA256SUMS) + ;; +qemu-prepare) + if [[ -e /job/qemu ]]; then mv /job/qemu "/job/qemu-failed-$(date -u +%Y%m%dT%H%M%SZ)"; fi + bash /builder/tests/boot-pair-qemu-prepare.sh /job /job/qemu + ;; +*) exit 2;; +esac diff --git a/src/boot-pair/dracut/module-setup.sh b/src/boot-pair/dracut/module-setup.sh new file mode 100644 index 0000000..c149a04 --- /dev/null +++ b/src/boot-pair/dracut/module-setup.sh @@ -0,0 +1,22 @@ +#!/bin/bash +# Upstream host-only dracut interface; excluded from the tablet root. +# shellcheck disable=SC2154 +check() { return 0; } +depends() { echo systemd; } +installkernel() { + instmods ufs_qcom phy_qcom_qmp_ufs dwc3 dwc3_qcom \ + phy_msm_snps_eusb2_uke repeater_qti_pmic_eusb2_uke \ + i2c_qcom_geni gpi g_serial usbhid hid_generic xhci_hcd xhci_plat_hcd +} +install() { + inst_multiple /bin/sh /usr/bin/blkid /usr/bin/readlink /usr/bin/grep /usr/bin/sleep /usr/bin/cat /usr/bin/uke-boot-status + inst_simple "$moddir/root-guard.sh" /usr/libexec/uke-root-guard + chmod 755 "$initdir/usr/libexec/uke-root-guard" + inst_simple "$moddir/uke-root-guard.service" "$systemdsystemunitdir/uke-root-guard.service" + inst_simple "$moddir/uke-initrd-screen.service" "$systemdsystemunitdir/uke-initrd-screen.service" + inst_simple /etc/uke-boot-pair.uuid /etc/uke-boot-pair.uuid + mkdir -p "$initdir$systemdsystemunitdir/sysroot.mount.requires" + ln -s ../uke-root-guard.service "$initdir$systemdsystemunitdir/sysroot.mount.requires/uke-root-guard.service" + mkdir -p "$initdir$systemdsystemunitdir/initrd.target.wants" + ln -s ../uke-initrd-screen.service "$initdir$systemdsystemunitdir/initrd.target.wants/uke-initrd-screen.service" +} diff --git a/src/boot-pair/dracut/root-guard.sh b/src/boot-pair/dracut/root-guard.sh new file mode 100644 index 0000000..effdf04 --- /dev/null +++ b/src/boot-pair/dracut/root-guard.sh @@ -0,0 +1,41 @@ +#!/bin/sh +# Native/POSIX initramfs boundary: never fall back to Android or another EXT4 root. +set -eu +uuid=$(cat /etc/uke-boot-pair.uuid) +case "$uuid" in + ????????-????-????-????-????????????) ;; + *) echo 'UKE_ROOT_REJECTED: invalid image UUID' >&2; exit 1;; +esac +case "$uuid" in *[!a-f0-9-]*) exit 1;; esac +selected=/dev/disk/by-uuid/$uuid +attempt=0 +while [ "$attempt" -lt 45 ]; do + count=0 + candidate='' + for metadata in /sys/class/block/*/uevent; do + [ -r "$metadata" ] || continue + grep -Fx PARTNAME=linux "$metadata" >/dev/null || continue + count=$((count + 1)) + while IFS='=' read -r key value; do + [ "$key" != DEVNAME ] || candidate=/dev/$value + done < "$metadata" + done + if [ "$count" -gt 1 ]; then + echo 'UKE_ROOT_REJECTED: multiple linux partitions' >&2; exit 1 + fi + if [ "$count" -eq 1 ] && [ -b "$candidate" ] && [ -e "$selected" ]; then + [ "$(readlink -f "$selected")" = "$(readlink -f "$candidate")" ] || { + echo 'UKE_ROOT_REJECTED: UUID belongs to a different partition' >&2; exit 1; + } + [ "$(blkid -p -s UUID -o value "$candidate")" = "$uuid" ] && \ + [ "$(blkid -p -s TYPE -o value "$candidate")" = ext4 ] || { + echo 'UKE_ROOT_REJECTED: unexpected filesystem identity' >&2; exit 1; + } + echo 'UKE_ROOT_ADMITTED: linux partition and image UUID match' + exit 0 + fi + attempt=$((attempt + 1)) + sleep 1 +done +echo 'UKE_ROOT_REJECTED: matching linux partition unavailable' >&2 +exit 1 diff --git a/src/boot-pair/dracut/uke-initrd-screen.service b/src/boot-pair/dracut/uke-initrd-screen.service new file mode 100644 index 0000000..5f96ae3 --- /dev/null +++ b/src/boot-pair/dracut/uke-initrd-screen.service @@ -0,0 +1,21 @@ +[Unit] +Description=Uke early local development terminal while admitting the Fedora root +DefaultDependencies=no +After=systemd-udev-trigger.service +Before=initrd-switch-root.target +Conflicts=initrd-switch-root.target +ConditionKernelCommandLine=senemos.debug=cdc-acm + +[Service] +ExecCondition=/usr/bin/uke-boot-status --identity +ExecStart=-/bin/sh -i +StandardInput=tty-force +StandardOutput=tty +StandardError=tty +TTYPath=/dev/tty1 +TTYReset=yes +TTYVHangup=yes +Environment=TERM=linux +KillSignal=SIGHUP +Restart=always +RestartSec=2 diff --git a/src/boot-pair/dracut/uke-root-guard.service b/src/boot-pair/dracut/uke-root-guard.service new file mode 100644 index 0000000..42ac3f4 --- /dev/null +++ b/src/boot-pair/dracut/uke-root-guard.service @@ -0,0 +1,14 @@ +[Unit] +Description=Admit only the Fedora image in the linux partition +DefaultDependencies=no +After=systemd-udev-trigger.service +Before=sysroot.mount +OnFailure=emergency.target + +[Service] +Type=oneshot +ExecStart=/usr/libexec/uke-root-guard +TimeoutStartSec=55 +RemainAfterExit=yes +StandardOutput=journal+console +StandardError=journal+console diff --git a/src/boot-pair/entry.sh b/src/boot-pair/entry.sh new file mode 100644 index 0000000..3e0a984 --- /dev/null +++ b/src/boot-pair/entry.sh @@ -0,0 +1,248 @@ +#!/usr/bin/env bash +# Sourced by the one public ukelinux.sh entry; no device commands are executed. +# shellcheck disable=SC2034 # OFFLINE is consumed by the shared host bootstrap. +pair_help() { + cat <<'HELP' +Uke stock-ABL Fedora Rawhide pair (host-only build) +Usage: ./ukelinux.sh --build boot-pair --distro=fedora --device-dt FILE [options] + --device-dt FILE Private, read-only live bootloader FDT used for host admission tests + --kernel-cache DIR Verified earlier local boot cache (workspace default) + --core-candidate DIR Qualified prepared Fedora root (workspace default) + --stock-boot FILE Reviewed Android v4 boot template (EvolutionX default) + --jobs N Maximum two CPUs, default one; queued behind other builds + --release rawhide Only reviewed Fedora release + --offline Require cached tools, sources and RPMs + --dry-run Explain the contract without building + --test All source, DT, module, filesystem and codec gates always run + --help, -h Show this help + +Outputs fedora_boot.img and system.img (Android sparse EXT4, explicit RAW/FILL +coverage). Bootloader RAM/reservations are retained; embedded DT provider +adaptation adds UFS, USB peripheral and a simplefb screen candidate. Root is +admitted only when its image UUID and GPT partition name linux both match. +No GPT changes, userdata writes, slot changes, flashing or init_boot/vendor_boot/ +dtbo replacement. Physical ABL/UFS/display/input acceptance remains owner-tested. +HELP +} +pair_recipe_hash() { + local dir=$1 kernel=$2 + cat "$dir"/src/boot-pair/*.sh "$dir"/src/boot-pair/*.cpp "$dir"/src/boot-pair/*.service \ + "$dir"/src/boot-pair/dracut/* "$dir"/configs/boot/uke-boot-pair*.json \ + "$dir"/configs/keys/{uke-copr,fedora-46}.asc \ + "$dir"/src/boot/boot-image.cpp "$dir"/tests/boot-image-contract.sh "$dir"/tests/android-sparse-contract.sh \ + "$dir"/tests/boot-pair*.sh \ + "$dir"/src/image/host-lib.sh "$dir"/src/image/ext4-labels.cpp "$dir"/src/audit/*.sh \ + "$dir"/configs/images/selinux-namespace-policy.json \ + "$kernel"/src/boot/{uke-abl.c,prepare-abl-source.sh,sm7675-uke-ufs.dtso,sm7675-uke-usb2.dtso} \ + "$kernel"/src/boot/vendor/* \ + "$kernel"/patches/boot/*.patch "$kernel"/tests/abl-dt-{check.cpp,contract.sh} | sha256sum | cut -d ' ' -f1 +} +pair_freeze_recipe() { + local kernel=$1 recipe=$2 + if [[ -f $WORK/inputs/recipe-sha256 ]]; then + [[ $(cat "$WORK/inputs/recipe-sha256") == "$recipe" && + $(pair_recipe_hash "$WORK/recipe" "$WORK/kernel-recipe") == "$recipe" ]] || die 'Frozen recipe differs on resume' + else + cp -a "$BUILDER/src" "$BUILDER/configs" "$BUILDER/tests" "$WORK/recipe/" + # The separate signed source archive supplies the kernel itself. + # Do not copy unrelated development trees or their read-only .git + # objects into the recipe, especially during an interrupted resume. + mkdir -p "$WORK/kernel-recipe/src" "$WORK/kernel-recipe/patches" "$WORK/kernel-recipe/tests" + cp -a "$kernel/src/boot" "$WORK/kernel-recipe/src/" + cp -a "$kernel/patches/boot" "$WORK/kernel-recipe/patches/" + cp -a "$kernel/tests/abl-dt-check.cpp" "$kernel/tests/abl-dt-contract.sh" "$WORK/kernel-recipe/tests/" + fi + [[ $(pair_recipe_hash "$WORK/recipe" "$WORK/kernel-recipe") == "$recipe" ]] || die 'Source changed during recipe freeze' + printf '%s\n' "$recipe" > "$WORK/inputs/recipe-sha256" +} +pair_seed_cache() { + local cache=$1 previous candidate match='' suffix=kernel/build/fedora-rawhide/7.2.9/rpmbuild/kernel-out + # Reuse only Kbuild intermediates, never a phase receipt or delivered + # Image. Make rechecks commands/dependencies; final module and linked-byte + # checks still run. A failed build is not an accepted output. + for previous in "$BUILDER"/build/boot-pair/pair-*; do + [[ $previous != "$WORK" && -f $previous/source.passed && -d $previous/$suffix ]] || continue + cmp -s "$previous/inputs/base.config" "$WORK/inputs/base.config" || continue + cmp -s "$previous/inputs/kernel-manifest.json" "$WORK/inputs/kernel-manifest.json" || continue + cmp -s "$previous/inputs/kernel.spec" "$WORK/inputs/kernel.spec" || continue + cmp -s "$previous/inputs/applied-patches.txt" "$WORK/inputs/applied-patches.txt" || continue + [[ $(jq -r .kernel_tool_image "$previous/inputs/profile.json") == $(jq -r .kernel_tool_image "$WORK/inputs/profile.json") ]] || continue + jq -S .source "$WORK/inputs/kernel-manifest.json" | cmp -s - "$previous/prepared-source.json" || continue + candidate=$previous/$suffix + [[ -z $match || $candidate/.config -nt $match/.config ]] && match=$candidate + done + if [[ -n $match ]]; then + say 'Reusing source-compatible Kbuild intermediates; all final gates will run' + cache=$match + fi + cp -a --reflink=auto "$cache" "$WORK/kernel/build/fedora-rawhide/7.2.9/rpmbuild/" + sha256sum "$cache/.config" | cut -d ' ' -f1 > "$WORK/kbuild-seed-config.sha256" +} +pair_job() { + local image=$1 stage=$2 owner=none status platform=linux/amd64 + wait_idle + [[ $stage != root ]] || platform=linux/arm64 + if [[ $ENGINE == docker ]] && ! "$ENGINE" info --format '{{json .SecurityOptions}}' | jq -e 'any(.[]; contains("rootless"))' >/dev/null; then owner=$(id -u):$(id -g); fi + local command=(bash /builder/src/boot-pair/container-stage.sh "$stage" "$owner") + [[ $stage != root ]] || command=(bash /builder/src/boot-pair/prepare-root.sh "$owner") + [[ $stage != qemu ]] || command=(bash /builder/tests/boot-pair-qemu.sh /job/qemu) + CID=$("$ENGINE" create --network=none --security-opt label=disable --platform "$platform" --cpus="$JOBS" --memory=3g \ + -v "$WORK:/job" -v "$WORK/recipe:/builder:ro" -v "$WORK/kernel-recipe:/kernel-recipe:ro" \ + -v "$PRIVATE:/private" "$image" "${command[@]}") + "$ENGINE" start "$CID" >/dev/null + while [[ $("$ENGINE" inspect --format '{{or .State.Running .State.Paused}}' "$CID") == true ]]; do + if heavy_busy; then + if ((PAUSED == 0)); then "$ENGINE" pause "$CID" >/dev/null; PAUSED=1; say 'Our pair job paused behind another build'; fi + elif ((PAUSED)); then "$ENGINE" unpause "$CID" >/dev/null; PAUSED=0; say 'Pair job resumed'; fi + sleep 2 + done + "$ENGINE" logs "$CID" > "$WORK/$stage.log" 2>&1 + status=$("$ENGINE" inspect --format '{{.State.ExitCode}}' "$CID") + "$ENGINE" rm "$CID" >/dev/null; CID='' + ((status == 0)) || die "Pair stage $stage failed ($status); inspect $WORK/$stage.log" +} +pair_main() { + local target=boot-pair distro=fedora release=rawhide key='' value='' device_dt='' kernel_cache='' core='' stock='' + local kernel=$BUILDER/../senemos-uke-kernel profile=$BUILDER/configs/boot/uke-boot-pair.json + JOBS=1 OFFLINE=0 DRY_RUN=0 ENGINE='' CID='' PAUSED=0 + while (($#)); do + case $1 in + --help|-h) pair_help; return;; + --offline) OFFLINE=1;; --dry-run) DRY_RUN=1;; --test) :;; + --build=*|--distro=*|--release=*|--jobs=*|--device-dt=*|--kernel-cache=*|--core-candidate=*|--stock-boot=*) key=${1%%=*}; value=${1#*=};; + --build|--distro|--release|--jobs|--device-dt|--kernel-cache|--core-candidate|--stock-boot) key=$1; shift; (($#)) || die "Missing value for $key"; value=$1;; + *) die "Unknown pair option: $1";; + esac + case $key in + --build) target=$value;; --distro) distro=$value;; --release) release=$value;; --jobs) JOBS=$value;; + --device-dt) device_dt=$value;; --kernel-cache) kernel_cache=$value;; --core-candidate) core=$value;; --stock-boot) stock=$value;; + esac + key=''; shift + done + [[ $target == boot-pair && $distro == fedora && $release == rawhide ]] || die 'Only boot-pair/Fedora Rawhide is admitted' + [[ $JOBS =~ ^[1-9][0-9]{0,2}$ ]] || die 'Jobs must be a positive integer' + ((JOBS <= 2)) || JOBS=2 + say 'Two-image stock-ABL candidate: boot_b and linux; local screen TTY required' + say 'Existing init_boot, vendor_boot, dtbo, GPT and Android userdata remain untouched' + ((DRY_RUN == 0)) || return 0 + bootstrap + ((EUID != 0)) || die 'Run as a normal user' + [[ $(uname -m) == x86_64 ]] || die 'This profile currently pins x86_64 host images' + [[ -f $device_dt && ! -L $device_dt ]] || die '--device-dt must name a private bounded live FDT capture' + [[ $(stat -c %s "$device_dt") -ge 40 && $(stat -c %s "$device_dt") -le 2097152 ]] || die 'Live FDT is outside ARM64 bounds' + kernel_cache=${kernel_cache:-$BUILDER/build/boot/boot-b-b09bf6e9cbffa4d5} + core=${core:-$BUILDER/build/images/core-rawhide-6bd23aae9255f1de} + stock=${stock:-$BUILDER/referances/firmware/android17-evolutionx-12.1/boot.img} + kernel_cache=$(realpath -e "$kernel_cache"); core=$(realpath -e "$core"); kernel=$(realpath -e "$kernel") + local artifact=$kernel/artifacts/fedora-rawhide/7.2.9 recipe effective uuid hash name file tool + verify_cache "$artifact/build-manifest.json" "$(jq -r .kernel_manifest_sha256 "$profile")" + verify_cache "$core/prepared-root.tar" "$(jq -r .core_prepared_root_sha256 "$profile")" + (cd "$kernel_cache"; sha256sum -c SHA256SUMS) || die 'Kernel cache checksum failure' + verify_cache "$kernel_cache/base.config" "$(jq -r .kernel_config_sha256 "$profile")" + verify_cache "$kernel_cache/inputs/kernel-manifest.json" "$(jq -r .kernel_manifest_sha256 "$profile")" + cmp "$kernel_cache/boot.config" "$kernel_cache/kernel/build/fedora-rawhide/7.2.9/rpmbuild/kernel-out/.config" || die 'Cached Kbuild configuration differs from the qualified boot image' + cmp "$kernel_cache/Image" "$kernel_cache/kernel/build/fedora-rawhide/7.2.9/rpmbuild/kernel-out/arch/arm64/boot/Image" || die 'Cached Kbuild Image differs from the qualified boot image' + cmp "$kernel_cache/Module.symvers" "$kernel_cache/kernel/build/fedora-rawhide/7.2.9/rpmbuild/kernel-out/Module.symvers" || die 'Cached Kbuild symbol receipt differs' + hash=$(sha256sum "$stock" | cut -d ' ' -f1) + jq -e --arg hash "$hash" 'any(.boot_templates[];.sha256==$hash)' "$profile" >/dev/null || die 'Unreviewed boot template' + for tool in host_tool_image kernel_tool_image root_tool_image qemu_tool_image; do + "$ENGINE" image exists "$(jq -r ".$tool" "$profile")" || die "Missing pinned $tool" + done + mkdir -p "$BUILDER/build/boot-pair" + exec 9> "$BUILDER/build/ukelinux-build.lock" + if ! flock -n 9; then say 'Queued behind another image invocation'; flock 9; fi + [[ $(df -Pk "$BUILDER/build" | awk 'NR==2 {print $4}') -ge $((14*1024*1024)) ]] || die 'At least 14 GiB free disk space is required' + recipe=$(pair_recipe_hash "$BUILDER" "$kernel") + hash=$(printf '%s\n%s\n' "$recipe" "$(sha256sum "$stock" "$device_dt" | cut -d ' ' -f1)" | sha256sum | cut -d ' ' -f1) + uuid=${hash:0:8}-${hash:8:4}-${hash:12:4}-${hash:16:4}-${hash:20:12} + effective=$(jq -S --arg uuid "$uuid" \ + --arg stock "$(sha256sum "$stock" | cut -d ' ' -f1)" \ + '.root_uuid=$uuid | .stock_boot_sha256=$stock | + .kernel_command_line=("rdinit=/usr/lib/systemd/systemd root=UUID="+$uuid+" rw rootfstype=ext4 rootwait rd.retry=60 rd.timeout=65 rd.fstab=no rd.gpt-auto=no systemd.gpt_auto=no systemd.unit=multi-user.target senemos.debug=cdc-acm console=tty0 console=ttyAMA0 fbcon=map:0 fbcon=font:TER16x32 consoleblank=0 loglevel=7 panic=0 regulator_ignore_unused clk_ignore_unused pd_ignore_unused")' "$profile") + WORK=$BUILDER/build/boot-pair/pair-${hash:0:16} + if [[ -f $WORK/manifest.json ]]; then + (cd "$WORK" || exit; sha256sum -c SHA256SUMS) + if [[ -f $WORK/qemu/result.json ]]; then + jq -e --arg image "$(sha256sum "$WORK/Image" | cut -d ' ' -f1)" \ + --arg script "$(sha256sum "$WORK/recipe/tests/boot-pair-qemu.sh" | cut -d ' ' -f1)" \ + '.kernel_sha256==$image and .test_script_sha256==$script and + .real_ext4_root_transition and .root_pid1_systemd and .selinux_enforcing and + .local_tty_keyboard_command and .wrong_partition_name_rejected and + .wrong_filesystem_uuid_rejected and .boot_tested==false and .hardware_tested==false' \ + "$WORK/qemu/result.json" >/dev/null || die 'Cached VM result is incomplete or belongs to different inputs' + # The observer may have completed just before the host process + # lost its receipt/final-manifest write. Its checked result is + # enough to resume sealing without rerunning into existing files. + touch "$WORK/qemu.passed" + if jq -e --slurpfile vm "$WORK/qemu/result.json" \ + --arg hash "$(sha256sum "$WORK/qemu/result.json" | cut -d ' ' -f1)" \ + '.qemu_tested==true and .qemu_evidence.sha256==$hash and .qemu_evidence.result==$vm[0]' \ + "$WORK/manifest.json" >/dev/null && + grep -F ' qemu/result.json' "$WORK/SHA256SUMS" >/dev/null; then + say "Verified pair cache and generic VM tests: $WORK"; return + fi + fi + fi + mkdir -p "$WORK/recipe" "$WORK/kernel-recipe" "$WORK/inputs/kernel" + pair_freeze_recipe "$kernel" "$recipe" + printf '%s\n' "$effective" > "$WORK/inputs/profile.json" + jq -er .root_uuid "$WORK/inputs/profile.json" > "$WORK/inputs/root-uuid" + jq -er .kernel_command_line "$WORK/inputs/profile.json" > "$WORK/inputs/kernel-cmdline" + cp "$stock" "$WORK/inputs/stock-boot.img" + cp "$artifact/build-manifest.json" "$WORK/inputs/kernel-manifest.json" + cp "$artifact/output/applied-patches.txt" "$WORK/inputs/applied-patches.txt" + jq -r '.source.patches[].path|split("/")|last' "$artifact/build-manifest.json" | cmp - "$WORK/inputs/applied-patches.txt" || die 'Applied-patch receipt differs from the accepted manifest' + cp "$kernel_cache/base.config" "$WORK/inputs/base.config" + cp "$kernel_cache/base-vmlinux.symvers" "$WORK/inputs/base-vmlinux.symvers" + cp "$kernel_cache/inputs/kernel.spec" "$WORK/inputs/kernel.spec" + cp -a "$kernel_cache/inputs/SOURCES" "$WORK/inputs/" + # Reflinks preserve existing accepted artifacts without another full copy. + cp --reflink=auto "$core/prepared-root.tar" "$WORK/inputs/prepared-root.tar" + while IFS=$'\t' read -r name hash; do + [[ $name == *.aarch64.rpm ]] || continue + verify_cache "$kernel/referances/copr/11090323/$name" "$hash" + cp "$kernel/referances/copr/11090323/$name" "$WORK/inputs/kernel/$name" + done < <(jq -r '.[]|[.file,.sha256]|@tsv' "$BUILDER/configs/boot/uke-boot-pair-rpms.json") + mkdir -p "$WORK/inputs/host-rpms" + while IFS=$'\t' read -r name hash; do + verify_cache "$core/inputs/runtime/packages/$name" "$hash" + cp "$core/inputs/runtime/packages/$name" "$WORK/inputs/host-rpms/$name" + done < <(jq -r '.[]|[.file,.sha256]|@tsv' "$BUILDER/configs/boot/uke-boot-pair-host-rpms.json") + printf '%s\n' "$JOBS" > "$WORK/inputs/jobs" + (cd "$WORK/inputs" || exit; find . -type f ! -name SHA256SUMS -print0 | sort -z | xargs -0 sha256sum) > "$WORK/input-checksums.part" + mv "$WORK/input-checksums.part" "$WORK/inputs/SHA256SUMS" + # Keep live DT and transformed unit data in a private sibling of its + # capture, never in the public build output or source tree. + PRIVATE=$(dirname "$(realpath -e "$device_dt")")/pair-${uuid} + [[ -d $PRIVATE ]] || mkdir -m700 "$PRIVATE" + install -m600 "$device_dt" "$PRIVATE/live-fdt.dtb" + (cd "$PRIVATE" || exit; sha256sum live-fdt.dtb > live-fdt.sha256) + trap cleanup EXIT + wait_idle + local architecture=x86_64 + TEST_IMAGE=$(jq -r .root_tool_image "$profile") + prepare_aarch64 + [[ $(awk '$1=="MemAvailable:" {print $2}' /proc/meminfo) -ge $((4*1024*1024)) ]] || die 'At least 4 GiB available RAM is required' + mkdir -p "$WORK/kernel/build/fedora-rawhide/7.2.9/rpmbuild" + if [[ ! -d $WORK/kernel/build/fedora-rawhide/7.2.9/rpmbuild/kernel-out ]]; then + pair_seed_cache "$kernel_cache/kernel/build/fedora-rawhide/7.2.9/rpmbuild/kernel-out" + fi + for file in source dt-test root audit-initramfs kernel compose qemu-prepare qemu; do + [[ ! -e $WORK/$file.passed ]] || continue + tool=$(jq -r .host_tool_image "$profile") + case $file in source|kernel) tool=$(jq -r .kernel_tool_image "$profile");; root) tool=$(jq -r .root_tool_image "$profile");; qemu) tool=$(jq -r .qemu_tool_image "$profile");; esac + say "Pair stage: $file" + pair_job "$tool" "$file" + touch "$WORK/$file.passed" + done + jq --slurpfile vm "$WORK/qemu/result.json" \ + --arg hash "$(sha256sum "$WORK/qemu/result.json" | cut -d ' ' -f1)" \ + '.qemu_tested=true | .qemu_evidence={path:"qemu/result.json",sha256:$hash,result:$vm[0]}' \ + "$WORK/manifest.json" > "$WORK/manifest.json.part" + mv "$WORK/manifest.json.part" "$WORK/manifest.json" + # Replace the pre-VM manifest entry; cover the final VM record as well. + sed -i '/ manifest.json$/d; / qemu\/result.json$/d' "$WORK/SHA256SUMS" + (cd "$WORK" || exit; sha256sum manifest.json qemu/result.json >> SHA256SUMS; sha256sum -c SHA256SUMS) + say "Pair composed: $WORK/fedora_boot.img and $WORK/system.img" + say 'QEMU, physical tablet screen and UFS acceptance are separate evidence gates' +} diff --git a/src/boot-pair/prepare-root.sh b/src/boot-pair/prepare-root.sh new file mode 100644 index 0000000..92aafa8 --- /dev/null +++ b/src/boot-pair/prepare-root.sh @@ -0,0 +1,77 @@ +#!/usr/bin/env bash +# Disposable pinned ARM64 host userspace; never invoked on the tablet. +set -Eeuo pipefail +[[ $# == 1 ]] +owner=$1 +[[ $(uname -m) == aarch64 ]] +root=/job/root +trap 'if [[ $owner != none ]]; then chown -R "$owner" /job; fi' EXIT +release=7.2.9-senemos-uke +(cd /job/inputs; sha256sum -c SHA256SUMS) +rm -rf "$root" +mkdir -p "$root" /job/tmp /job/empty-config +tar --numeric-owner --same-owner --xattrs --xattrs-include='security.capability' -xf /job/inputs/prepared-root.tar -C "$root" +packages=(/job/inputs/kernel/*.aarch64.rpm) +[[ ${#packages[@]} == 4 ]] +rpmkeys --root "$root" --import /builder/configs/keys/uke-copr.asc +for package in "${packages[@]}"; do + rpmkeys --root "$root" --checksig "$package" | grep -F 'signatures OK' +done +rpm --root "$root" -Uvh --replacepkgs "${packages[@]}" +rpm --root "$root" --restore senemos-uke-linux-kernel-mainline{,-core,-modules,-dtbs} +rpm --root "$root" -V senemos-uke-linux-kernel-mainline{,-core,-modules,-dtbs} +rpm --root "$root" -qa --qf '%{NAME}\t%{VERSION}-%{RELEASE}\t%{ARCH}\n' | sort > /job/installed-rpms.tsv +if cut -f1 /job/installed-rpms.tsv | grep -Ei 'python|pypy|libpython|^plasma-workspace$|^dolphin$'; then exit 1; fi +uuid=$(cat /job/inputs/root-uuid) +printf '%s\n' "$uuid" > "$root/etc/uke-boot-pair.uuid" +printf 'UUID=%s / ext4 defaults 0 1\n' "$uuid" > "$root/etc/fstab" +rm -f "$root/etc/systemd/system/sysinit.target.wants/debug-shell.service" \ + "$root/etc/systemd/system/multi-user.target.wants/uke-esp32-cdc-log.service" \ + "$root/etc/systemd/system/debug-shell.service.d/90-uke-core.conf" +ln -sfn /dev/null "$root/etc/systemd/system/getty@tty1.service" +for unit in uke-screen-shell uke-cdc-shell uke-root-status; do + install -Dm644 "/builder/src/boot-pair/$unit.service" "$root/usr/lib/systemd/system/$unit.service" + mkdir -p "$root/etc/systemd/system/multi-user.target.wants" + ln -sfn "/usr/lib/systemd/system/$unit.service" "$root/etc/systemd/system/multi-user.target.wants/$unit.service" +done +install -Dm755 /builder/src/boot-pair/screen-shell.sh "$root/usr/libexec/uke-screen-shell" +install -Dm755 /builder/src/boot-pair/root-status.sh "$root/usr/libexec/uke-root-status" +# Automatic native-COPR updates must not silently replace the module tuple of +# this built-in-initramfs stock-ABL candidate. Update through a rebuilt pair. +printf '\nexcludepkgs=senemos-uke-linux-kernel-mainline*\n' >> "$root/etc/dnf/dnf.conf" +mkdir -p "$root/etc/systemd/journald.conf.d" +printf '[Journal]\nStorage=persistent\nSystemMaxUse=64M\nSystemMaxFileSize=8M\n' > "$root/etc/systemd/journald.conf.d/90-uke-bringup.conf" +rm -f "$root/etc/modules-load.d/uke-esp32.conf" +printf 'g_serial\n' > "$root/etc/modules-load.d/uke-cdc.conf" +chroot "$root" /usr/bin/depmod "$release" +# Temporarily install the exact signed host assembly dependency closure in +# this disposable native root. Remove it through RPM before target export. +# No project Python and no cross-sysroot guesses about upstream dracut hooks. +rpmkeys --root "$root" --import /builder/configs/keys/fedora-46.asc +for package in /job/inputs/host-rpms/*.rpm; do + rpmkeys --root "$root" --checksig "$package" | grep -F 'signatures OK' +done +rpm --root "$root" -Uvh /job/inputs/host-rpms/*.rpm +mkdir -p "$root/usr/lib/dracut/modules.d/92uke-abl" +install -m644 /builder/src/boot-pair/dracut/* "$root/usr/lib/dracut/modules.d/92uke-abl/" +chmod 755 "$root/usr/lib/dracut/modules.d/92uke-abl/module-setup.sh" +# Upstream --sysroot resolves both hooks and target dependencies from this +# signed temporary installation, while host /proc and /dev remain available. +dracut --sysroot "$root" \ + --conf /dev/null --confdir /job/empty-config --tmpdir /job/tmp \ + --force --no-hostonly --no-hostonly-cmdline --no-early-microcode --gzip \ + --add uke-abl --kernel-cmdline "$(cat /job/inputs/kernel-cmdline)" \ + --filesystems ext4 --omit 'network plymouth crypt systemd-cryptsetup syslog lvm mdraid btrfs i18n' \ + /job/initramfs.cpio.gz "$release" +[[ -s /job/initramfs.cpio.gz ]] +rpm --root "$root" -e dracut kbd xkeyboard-config libxkbcommon +rm -rf "$root/usr/lib/dracut/modules.d/92uke-abl" +rpm --root "$root" -qa --qf '%{NAME}\t%{VERSION}-%{RELEASE}\t%{ARCH}\n' | sort > /job/final-rpms.tsv +[[ $(sha256sum /job/final-rpms.tsv | cut -d ' ' -f1) == $(sha256sum /job/installed-rpms.tsv | cut -d ' ' -f1) ]] +cp /job/initramfs.cpio.gz "$root/boot/initramfs-$release.img" +rm -rf "$root/var/log/"* "$root/var/cache/dnf"* "$root/tmp/"* "$root/var/tmp/"* +rm -f "$root/etc/ssh/ssh_host_"* "$root/var/lib/systemd/random-seed" "$root/root/.bash_history" +: > "$root/etc/machine-id" +tar --numeric-owner --xattrs --xattrs-include='security.capability' -cpf /job/pair-root.tar -C "$root" . +(cd /job; sha256sum pair-root.tar > pair-root.sha256) +printf '%s\n' 'Pinned ARM64 root RPM replacement and root-capable dracut assembly complete' diff --git a/src/boot-pair/root-status.sh b/src/boot-pair/root-status.sh new file mode 100644 index 0000000..8056d2d --- /dev/null +++ b/src/boot-pair/root-status.sh @@ -0,0 +1,14 @@ +#!/bin/sh +set -eu +mkdir -p /var/log/uke-bringup +{ + printf 'UKE_FEDORA_EXT4_ROOT_READY\n' + uname -r + findmnt -n -o SOURCE,FSTYPE / + printf 'pid1='; readlink /proc/1/exe + printf 'selinux='; cat /sys/fs/selinux/enforce +} > /var/log/uke-bringup/root-status.txt +cat /var/log/uke-bringup/root-status.txt +# Export the same status on a real serial port when one exists. On Uke this +# remains the local console/journal; on QEMU it is an observation channel. +if [ -c /dev/ttyAMA0 ]; then cat /var/log/uke-bringup/root-status.txt > /dev/ttyAMA0; fi diff --git a/src/boot-pair/screen-shell.sh b/src/boot-pair/screen-shell.sh new file mode 100644 index 0000000..1a121c6 --- /dev/null +++ b/src/boot-pair/screen-shell.sh @@ -0,0 +1,8 @@ +#!/bin/sh +# Local development access deliberately enabled in this owner-test image. +printf '\nFedora Rawhide on Uke — local development terminal\n' +printf 'Kernel: '; uname -r +printf 'Root: '; findmnt -n -o SOURCE,FSTYPE / +printf 'This shell is on the linux partition.\n\n' +export PS1='Fedora Uke:\w# ' +exec /usr/bin/bash --noprofile --norc -i diff --git a/src/boot-pair/uke-cdc-shell.service b/src/boot-pair/uke-cdc-shell.service new file mode 100644 index 0000000..d2fa59a --- /dev/null +++ b/src/boot-pair/uke-cdc-shell.service @@ -0,0 +1,22 @@ +[Unit] +Description=Uke Fedora development shell over USB CDC ACM +Requires=uke-cdc-acm.service +After=uke-cdc-acm.service +ConditionKernelCommandLine=senemos.debug=cdc-acm + +[Service] +ExecStartPre=/usr/bin/uke-boot-status --cdc --require-tty --wait 15 +ExecStart=-/usr/bin/bash --noprofile --norc -i +StandardInput=tty-force +StandardOutput=tty +StandardError=tty +TTYPath=/dev/ttyGS0 +TTYReset=yes +TTYVHangup=yes +Environment=TERM=vt100 +Restart=always +RestartSec=3 +KillSignal=SIGHUP + +[Install] +WantedBy=multi-user.target diff --git a/src/boot-pair/uke-root-status.service b/src/boot-pair/uke-root-status.service new file mode 100644 index 0000000..9be8603 --- /dev/null +++ b/src/boot-pair/uke-root-status.service @@ -0,0 +1,14 @@ +[Unit] +Description=Record the real Fedora root transition +After=local-fs.target +Before=uke-screen-shell.service + +[Service] +Type=oneshot +ExecCondition=/usr/bin/uke-boot-status --identity +ExecStart=/usr/libexec/uke-root-status +StandardOutput=journal+console +StandardError=journal+console + +[Install] +WantedBy=multi-user.target diff --git a/src/boot-pair/uke-screen-shell.service b/src/boot-pair/uke-screen-shell.service new file mode 100644 index 0000000..5962b12 --- /dev/null +++ b/src/boot-pair/uke-screen-shell.service @@ -0,0 +1,23 @@ +[Unit] +Description=Uke Fedora local development terminal on the tablet screen +After=systemd-user-sessions.service +ConditionKernelCommandLine=senemos.debug=cdc-acm +Conflicts=getty@tty1.service + +[Service] +ExecCondition=/usr/bin/uke-boot-status --identity +ExecStart=/usr/libexec/uke-screen-shell +StandardInput=tty-force +StandardOutput=tty +StandardError=tty +TTYPath=/dev/tty1 +TTYReset=yes +TTYVHangup=yes +TTYVTDisallocate=no +Environment=TERM=linux +Restart=always +RestartSec=2 +KillSignal=SIGHUP + +[Install] +WantedBy=multi-user.target diff --git a/src/image/core.sh b/src/image/core.sh index 2154af6..9ca6c9a 100644 --- a/src/image/core.sh +++ b/src/image/core.sh @@ -12,6 +12,7 @@ help() { Senemos Uke Fedora Core filesystem builder (host only) Usage: ./ukelinux.sh --build core --distro=fedora [options] ./ukelinux.sh --build boot --distro=fedora --help + ./ukelinux.sh --build boot-pair --distro=fedora --help --release rawhide Reviewed first target (default) --jobs N At most two CPUs; defaults to two --esp-size N ESP filesystem size in MiB (default 256; minimum 128) @@ -73,11 +74,19 @@ recipe_hash() { "$dir"/configs/keys/*.asc | sha256sum | cut -d ' ' -f1 } image_main() { - local boot_target=0 prior='' argument + local boot_target=0 pair_target=0 prior='' argument for argument in "$@"; do + if [[ $argument == --build=boot-pair || ($prior == --build && $argument == boot-pair) ]]; then pair_target=1; fi if [[ $argument == --build=boot || ($prior == --build && $argument == boot) ]]; then boot_target=1; fi prior=$argument done + if ((pair_target)); then + # shellcheck source=../boot-pair/entry.sh + # shellcheck disable=SC1091 + source "$BUILDER/src/boot-pair/entry.sh" + pair_main "$@" + return + fi if ((boot_target)); then # shellcheck source=../boot/entry.sh # shellcheck disable=SC1091 diff --git a/tests/android-sparse-contract.sh b/tests/android-sparse-contract.sh new file mode 100644 index 0000000..f720781 --- /dev/null +++ b/tests/android-sparse-contract.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +# Codec fixtures include real nonzero RAW data and explicit zero/nonzero FILL. +set -Eeuo pipefail +[[ $# == 2 && -x $1 && ! -e $2 ]] || exit 2 +binary=$1 out=$2 +mkdir -p "$out" +dd if=/dev/zero of="$out/raw.img" bs=4096 count=1 status=none +{ + dd if=/dev/urandom bs=4096 count=2 status=none + dd if=/dev/zero bs=4096 count=1 status=none + for ((i=0;i<1024;++i)); do printf 'UKE!'; done +} >> "$out/raw.img" +"$binary" encode "$out/raw.img" "$out/sparse.img" +"$binary" decode "$out/sparse.img" "$out/roundtrip.img" +cmp "$out/raw.img" "$out/roundtrip.img" +if "$binary" encode "$out/raw.img" "$out/sparse.img"; then exit 1; fi +ln -s raw.img "$out/link.img" +if "$binary" encode "$out/link.img" "$out/link-output.img"; then exit 1; fi +if "$binary" encode /dev/zero "$out/block-output.img"; then exit 1; fi +for scenario in truncated dont-care trailing bad-size; do + cp "$out/sparse.img" "$out/$scenario.img" + case $scenario in + truncated) truncate -s 30 "$out/$scenario.img";; + dont-care) printf '\303\312' | dd of="$out/$scenario.img" bs=1 seek=28 conv=notrunc status=none;; + trailing) printf X >> "$out/$scenario.img";; + bad-size) printf '\377\377\377\377' | dd of="$out/$scenario.img" bs=1 seek=32 conv=notrunc status=none;; + esac + if "$binary" decode "$out/$scenario.img" "$out/rejected-$scenario.img"; then exit 1; fi + [[ ! -e $out/rejected-$scenario.img ]] +done +echo 'Sparse roundtrip, no-overwrite, symlink/device and four malformed-input groups passed' diff --git a/tests/boot-pair-contract.sh b/tests/boot-pair-contract.sh new file mode 100644 index 0000000..ff8c973 --- /dev/null +++ b/tests/boot-pair-contract.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +set -Eeuo pipefail +root=$(cd "$(dirname "$0")/.." && pwd) +# shellcheck source=../src/image/core.sh +# shellcheck disable=SC1091 +source "$root/src/image/core.sh" +reject() { if (image_main --build boot-pair "$@") >/dev/null 2>&1; then echo 'Invalid pair request accepted' >&2; exit 1; fi; } +reject --distro debian --dry-run +reject --release 45 --dry-run +reject --jobs 0 --dry-run +reject --jobs +reject --device-dt +reject --kernel-cache +reject --unknown +(image_main --build boot-pair --offline --dry-run) | grep -F 'Two-image stock-ABL' >/dev/null +(image_main --build=boot-pair --help) | grep -F system.img >/dev/null +jq -e '.mode=="stock-abl-ext4-root" and .partition_changes==["boot_b","linux"] and .root_size_mib==3072 and .boot_tested==false and .hardware_tested==false' "$root/configs/boot/uke-boot-pair.json" >/dev/null +if rg -n '^\s*(fastboot|adb|dd .*of=/dev/|.*set-active)' "$root/src/boot-pair"; then echo 'Device mutation found' >&2; exit 1; fi +# Resume must preserve the frozen recipe and reject tampering, including when +# the development kernel has unrelated read-only Git objects below src/. +# shellcheck source=../src/boot-pair/entry.sh +# shellcheck disable=SC1091 +source "$root/src/boot-pair/entry.sh" +fixture=$(mktemp -d) +trap 'rm -rf "$fixture"' EXIT +kernel=$fixture/kernel +mkdir -p "$kernel/src/boot/vendor" "$kernel/src/upstream/.git/objects" "$kernel/patches/boot" "$kernel/tests" +touch "$kernel/src/boot/uke-abl.c" "$kernel/src/boot/prepare-abl-source.sh" \ + "$kernel/src/boot/sm7675-uke-ufs.dtso" "$kernel/src/boot/sm7675-uke-usb2.dtso" \ + "$kernel/src/boot/vendor/fdt_check.c" "$kernel/patches/boot/test.patch" \ + "$kernel/tests/abl-dt-check.cpp" "$kernel/tests/abl-dt-contract.sh" +printf 'unrelated read-only source\n' > "$kernel/src/upstream/.git/objects/sentinel" +chmod 444 "$kernel/src/upstream/.git/objects/sentinel" +BUILDER=$root WORK=$fixture/job +mkdir -p "$WORK/recipe" "$WORK/kernel-recipe" "$WORK/inputs" +recipe=$(pair_recipe_hash "$BUILDER" "$kernel") +pair_freeze_recipe "$kernel" "$recipe" +[[ ! -e $WORK/kernel-recipe/src/upstream ]] +chmod 444 "$WORK/kernel-recipe/src/boot/uke-abl.c" +pair_freeze_recipe "$kernel" "$recipe" +[[ $(stat -c %a "$WORK/kernel-recipe/src/boot/uke-abl.c") == 444 ]] +printf 'tamper\n' >> "$WORK/recipe/src/boot-pair/root-status.sh" +if (pair_freeze_recipe "$kernel" "$recipe") >/dev/null 2>&1; then echo 'Tampered frozen recipe accepted' >&2; exit 1; fi +echo 'Pair CLI, scope, non-mutation and frozen-recipe resume contracts passed' diff --git a/tests/boot-pair-qemu-prepare.sh b/tests/boot-pair-qemu-prepare.sh new file mode 100644 index 0000000..b5061a1 --- /dev/null +++ b/tests/boot-pair-qemu-prepare.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +# Host-only disposable GPT fixtures. Never accepts a device or existing output. +set -Eeuo pipefail +[[ $# == 2 && -s $1/system.raw.img && -s $1/manifest.json && ! -e $2 ]] || exit 2 +candidate=$(realpath "$1") out=$2 +mkdir -p "$out" +bytes=$(stat -c %s "$candidate/system.raw.img") +[[ $bytes == 3221225472 ]] +for name in correct wrong-partname wrong-uuid; do + truncate -s "$((bytes + 4*1024*1024))" "$out/$name.disk" + partition_name=linux + [[ $name != wrong-partname ]] || partition_name=userdata + printf 'label: gpt\nunit: sectors\n\nstart=2048,size=%s,type=L,name="%s"\n' "$((bytes / 512))" "$partition_name" | sfdisk "$out/$name.disk" + if [[ $name == wrong-uuid ]]; then + cp --reflink=auto "$candidate/system.raw.img" "$out/wrong-uuid.ext4" + tune2fs -U random "$out/wrong-uuid.ext4" + dd if="$out/wrong-uuid.ext4" of="$out/$name.disk" bs=1M seek=1 conv=notrunc,sparse status=none + rm "$out/wrong-uuid.ext4" + else + dd if="$candidate/system.raw.img" of="$out/$name.disk" bs=1M seek=1 conv=notrunc,sparse status=none + fi +done +cp "$candidate/Image" "$out/Image" +cp "$candidate/inputs/profile.json" "$out/profile.json" +printf '%s\n' 'Three generic VM GPT fixtures prepared; no Uke geometry or hardware emulated' diff --git a/tests/boot-pair-qemu.sh b/tests/boot-pair-qemu.sh new file mode 100644 index 0000000..7798f76 --- /dev/null +++ b/tests/boot-pair-qemu.sh @@ -0,0 +1,101 @@ +#!/usr/bin/env bash +# Generic ARM64 machine: proves real EXT4 root transition and guard behavior, +# never Qualcomm ABL/UFS/display acceptance. Root writes use disposable snapshots. +set -Eeuo pipefail +[[ $# == 1 && -s $1/correct.disk && -s $1/Image && ! -e $1/result.json ]] || exit 2 +out=$(realpath "$1") +qemu-system-aarch64 --version > "$out/qemu-version.txt" +qemu-system-aarch64 -machine "virt,gic-version=3,dumpdtb=$out/virt-dump.dtb" -cpu max -m 1536 -smp 1 -display none -kernel "$out/Image" +dtc -q -I dtb -O dtb -o "$out/virt.dtb" "$out/virt-dump.dtb" +fdtput -t s "$out/virt.dtb" / compatible linux,dummy-virt xiaomi,uke qcom,sm7675 +vm_pid='' +trap '[[ -z $vm_pid ]] || kill "$vm_pid" 2>/dev/null || true' EXIT +normalize() { + local escape=$'\033' + tr -d '\r' < "$1" | sed -E "s/${escape}\\[[0-?]*[ -/]*[@-~]//g;s/${escape}M//g" +} +qmp() { + printf '%s\n' "$1" >&3 + local response + IFS= read -r -t 5 response <&3 + while [[ $response == *'"event"'* ]]; do IFS= read -r -t 5 response <&3; done + [[ $response == *'"return"'* ]] +} +for name in correct wrong-partname wrong-uuid; do + log=$out/$name.serial.log + timeout --signal=TERM --kill-after=5s 240 qemu-system-aarch64 \ + -machine virt,gic-version=3 -cpu max -accel tcg,thread=single -m 1536 -smp 1 \ + -display none -nic none -monitor none -serial "file:$log" \ + -qmp tcp:127.0.0.1:4444,server=on,wait=off \ + -device qemu-xhci,id=xhci -device usb-kbd,bus=xhci.0 \ + -drive "if=none,file=$out/$name.disk,format=raw,id=root,snapshot=on" \ + -device virtio-blk-pci,drive=root -kernel "$out/Image" -dtb "$out/virt.dtb" \ + -append 'EXTERNAL_CMDLINE_WAS_USED=1' > "$out/$name.qemu.log" 2>&1 & + vm_pid=$! + observed=0 + for ((i=0;i<210;++i)); do + if [[ -s $log ]]; then + if [[ $name == correct ]] && grep -F UKE_FEDORA_EXT4_ROOT_READY "$log" >/dev/null && + grep -F 'pid1=/usr/lib/systemd/systemd' "$log" >/dev/null && + grep -F selinux=1 "$log" >/dev/null && + grep -F 'unit=uke-screen-shell ' "$log" | grep -F 'res=success' >/dev/null; then observed=1; break; fi + if [[ $name != correct ]] && grep -F UKE_ROOT_REJECTED "$log" >/dev/null; then observed=1; break; fi + fi + kill -0 "$vm_pid" 2>/dev/null || break + sleep 1 + done + normalize "$log" > "$out/$name.status.txt" + ((observed)) || { echo "Expected root result not observed: $name" >&2; exit 1; } + if [[ $name == correct ]]; then + grep -F UKE_ROOT_ADMITTED "$out/$name.status.txt" >/dev/null + grep -F '/dev/vda1 ext4' "$out/$name.status.txt" >/dev/null + grep -F 'pid1=/usr/lib/systemd/systemd' "$out/$name.status.txt" >/dev/null + grep -F selinux=1 "$out/$name.status.txt" >/dev/null + exec 3<>/dev/tcp/127.0.0.1/4444 + IFS= read -r -t 5 greeting <&3 + [[ $greeting == *'"QMP"'* ]] + qmp '{"execute":"qmp_capabilities"}' + qmp '{"execute":"human-monitor-command","arguments":{"command-line":"sendkey ctrl-alt-f1"}}' + sleep 5 + qmp '{"execute":"human-monitor-command","arguments":{"command-line":"sendkey ctrl-u"}}' + # echo realrootok > /dev/ttyAMA0 through the VM's actual USB keyboard. + for key in e c h o spc r e a l r o o t o k spc shift-dot spc slash d e v slash t t y shift-a shift-m shift-a 0 ret; do + qmp "{\"execute\":\"human-monitor-command\",\"arguments\":{\"command-line\":\"sendkey $key\"}}" + sleep 0.15 + done + entered=0 + for ((i=0;i<15;++i)); do + if normalize "$log" | grep -Fx realrootok >/dev/null; then entered=1; break; fi + sleep 1 + done + ((entered)) || { echo 'Real-root local TTY keyboard command not observed' >&2; exit 1; } + printf '%s\n' '{"execute":"quit"}' >&3 + exec 3>&- + wait "$vm_pid"; vm_pid='' + else + sleep 5 + normalize "$log" > "$out/$name.status.txt" + if grep -F UKE_FEDORA_EXT4_ROOT_READY "$out/$name.status.txt"; then echo 'Wrong root was admitted' >&2; exit 1; fi + kill "$vm_pid" 2>/dev/null || true + wait "$vm_pid" || true + vm_pid='' + fi + normalize "$log" > "$out/$name.status.txt" +done +cat > "$out/result.json" < Date: Thu, 8 Oct 2026 19:31:09 +0300 Subject: [PATCH 2/7] Bound root admission with monotonic time and builtin parsing --- src/boot-pair/dracut/root-guard.sh | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/boot-pair/dracut/root-guard.sh b/src/boot-pair/dracut/root-guard.sh index effdf04..2307a61 100644 --- a/src/boot-pair/dracut/root-guard.sh +++ b/src/boot-pair/dracut/root-guard.sh @@ -9,16 +9,20 @@ esac case "$uuid" in *[!a-f0-9-]*) exit 1;; esac selected=/dev/disk/by-uuid/$uuid attempt=0 +read -r uptime _ < /proc/uptime +started=${uptime%%.*} while [ "$attempt" -lt 45 ]; do count=0 candidate='' for metadata in /sys/class/block/*/uevent; do [ -r "$metadata" ] || continue - grep -Fx PARTNAME=linux "$metadata" >/dev/null || continue - count=$((count + 1)) + name='' device='' while IFS='=' read -r key value; do - [ "$key" != DEVNAME ] || candidate=/dev/$value + case "$key" in PARTNAME) name=$value;; DEVNAME) device=$value;; esac done < "$metadata" + [ "$name" = linux ] || continue + count=$((count + 1)) + candidate=/dev/$device done if [ "$count" -gt 1 ]; then echo 'UKE_ROOT_REJECTED: multiple linux partitions' >&2; exit 1 @@ -35,6 +39,8 @@ while [ "$attempt" -lt 45 ]; do exit 0 fi attempt=$((attempt + 1)) + read -r uptime _ < /proc/uptime + [ "$(( ${uptime%%.*} - started ))" -lt 45 ] || break sleep 1 done echo 'UKE_ROOT_REJECTED: matching linux partition unavailable' >&2 From 8276a871a712baad97fa8cb103d543417fe0339c Mon Sep 17 00:00:00 2001 From: Senemos Maintainers <75160848+MCC45TR@users.noreply.github.com> Date: Thu, 8 Oct 2026 19:32:36 +0300 Subject: [PATCH 3/7] Account for the older CI lint diagnostic on conjunctive rejection --- .github/workflows/validate.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 225bd5e..ac59e11 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -16,7 +16,10 @@ jobs: sudo apt-get install -y --no-install-recommends shellcheck jq ripgrep device-tree-compiler g++ libext2fs-dev libselinux1-dev - name: Validate scripts and admission contracts run: | - shellcheck src/image/*.sh src/boot/*.sh src/boot-pair/*.sh src/boot-pair/dracut/*.sh src/audit/*.sh tests/*.sh + # SC2015 in older ShellCheck flags the intentional UUID-and-type + # condition followed by explicit rejection if either check fails. + # The pinned host validation also checks all default diagnostics. + shellcheck --exclude=SC2015 src/image/*.sh src/boot/*.sh src/boot-pair/*.sh src/boot-pair/dracut/*.sh src/audit/*.sh tests/*.sh bash tests/image-contract.sh bash tests/boot-contract.sh bash tests/boot-pair-contract.sh From ae6fd0ed6dc694cdea7e3e858683d0110d2146f5 Mon Sep 17 00:00:00 2001 From: Senemos Maintainers <75160848+MCC45TR@users.noreply.github.com> Date: Thu, 8 Oct 2026 19:44:46 +0300 Subject: [PATCH 4/7] Decode terminal control strings in the VM serial observer --- .github/workflows/validate.yml | 1 + src/boot-pair/serial-normalize.sh | 7 +++++++ tests/boot-pair-qemu.sh | 7 +++---- tests/boot-pair-serial-contract.sh | 15 +++++++++++++++ 4 files changed, 26 insertions(+), 4 deletions(-) create mode 100644 src/boot-pair/serial-normalize.sh create mode 100644 tests/boot-pair-serial-contract.sh diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index ac59e11..b48101d 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -23,6 +23,7 @@ jobs: bash tests/image-contract.sh bash tests/boot-contract.sh bash tests/boot-pair-contract.sh + bash tests/boot-pair-serial-contract.sh bash tests/kernel-dt-admission.sh g++ -std=c++20 -O2 -Wall -Wextra -Werror src/image/ext4-labels.cpp -o /tmp/ext4-labels -lext2fs -lcom_err -lselinux g++ -std=c++20 -O2 -Wall -Wextra -Werror src/boot/boot-image.cpp -o /tmp/boot-image diff --git a/src/boot-pair/serial-normalize.sh b/src/boot-pair/serial-normalize.sh new file mode 100644 index 0000000..357b690 --- /dev/null +++ b/src/boot-pair/serial-normalize.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +# Host-only decoding of the generic VM's terminal observation stream. +normalize() { + local escape=$'\033' bell=$'\007' + tr -d '\r' < "$1" | sed -E \ + "s/${escape}\\][^${escape}${bell}]*(${bell}|${escape}\\\\)//g;s/${escape}P[^${escape}]*${escape}\\\\//g;s/${escape}\\[[0-?]*[ -/]*[@-~]//g;s/${escape}[78M]//g" +} diff --git a/tests/boot-pair-qemu.sh b/tests/boot-pair-qemu.sh index 7798f76..f1618fe 100644 --- a/tests/boot-pair-qemu.sh +++ b/tests/boot-pair-qemu.sh @@ -10,10 +10,9 @@ dtc -q -I dtb -O dtb -o "$out/virt.dtb" "$out/virt-dump.dtb" fdtput -t s "$out/virt.dtb" / compatible linux,dummy-virt xiaomi,uke qcom,sm7675 vm_pid='' trap '[[ -z $vm_pid ]] || kill "$vm_pid" 2>/dev/null || true' EXIT -normalize() { - local escape=$'\033' - tr -d '\r' < "$1" | sed -E "s/${escape}\\[[0-?]*[ -/]*[@-~]//g;s/${escape}M//g" -} +# shellcheck source=../src/boot-pair/serial-normalize.sh +# shellcheck disable=SC1091 +source "$(dirname "$0")/../src/boot-pair/serial-normalize.sh" qmp() { printf '%s\n' "$1" >&3 local response diff --git a/tests/boot-pair-serial-contract.sh b/tests/boot-pair-serial-contract.sh new file mode 100644 index 0000000..f93ae64 --- /dev/null +++ b/tests/boot-pair-serial-contract.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +set -Eeuo pipefail +root=$(cd "$(dirname "$0")/.." && pwd) +# shellcheck source=../src/boot-pair/serial-normalize.sh +# shellcheck disable=SC1091 +source "$root/src/boot-pair/serial-normalize.sh" +fixture=$(mktemp -d) +trap 'rm -rf "$fixture"' EXIT +# systemd/agetty CSI, OSC with both terminators, DCS query and cursor saves +# may share a serial line with the command's independent ttyAMA0 write. +printf '\033[32mUKE_ROOT_ADMITTED\033[0m\r\n\033]3008;fixture\033\\\033P+q6E616D65\033\\realrootok\r\n\033]104\007\0337\0338\033Mselinux=1\r\n' > "$fixture/serial" +printf 'UKE_ROOT_ADMITTED\nrealrootok\nselinux=1\n' > "$fixture/expected" +normalize "$fixture/serial" > "$fixture/actual" +cmp "$fixture/expected" "$fixture/actual" +echo 'Serial CSI/OSC/DCS and shared-line observation fixtures passed' From 1f5adb9b34db1b19ec6e970f809c0d13c323184e Mon Sep 17 00:00:00 2001 From: Senemos Maintainers <75160848+MCC45TR@users.noreply.github.com> Date: Thu, 8 Oct 2026 19:57:57 +0300 Subject: [PATCH 5/7] Accept the exact TTY result after a concurrent serial login prompt --- src/boot-pair/serial-normalize.sh | 7 +++++++ tests/boot-pair-qemu.sh | 2 +- tests/boot-pair-serial-contract.sh | 9 ++++++++- 3 files changed, 16 insertions(+), 2 deletions(-) diff --git a/src/boot-pair/serial-normalize.sh b/src/boot-pair/serial-normalize.sh index 357b690..109e329 100644 --- a/src/boot-pair/serial-normalize.sh +++ b/src/boot-pair/serial-normalize.sh @@ -5,3 +5,10 @@ normalize() { tr -d '\r' < "$1" | sed -E \ "s/${escape}\\][^${escape}${bell}]*(${bell}|${escape}\\\\)//g;s/${escape}P[^${escape}]*${escape}\\\\//g;s/${escape}\\[[0-?]*[ -/]*[@-~]//g;s/${escape}[78M]//g" } +tty_command_observed() { + # ttyAMA0 also carries agetty output. Its prompt may precede the shell's + # independent write on the same line. Require the complete marker at the + # line end, separated from any preceding word; never accept an echoed + # command, a longer word or a partial marker. + normalize "$1" | grep -Eq '(^|[^[:alnum:]_])realrootok$' +} diff --git a/tests/boot-pair-qemu.sh b/tests/boot-pair-qemu.sh index f1618fe..d1816eb 100644 --- a/tests/boot-pair-qemu.sh +++ b/tests/boot-pair-qemu.sh @@ -64,7 +64,7 @@ for name in correct wrong-partname wrong-uuid; do done entered=0 for ((i=0;i<15;++i)); do - if normalize "$log" | grep -Fx realrootok >/dev/null; then entered=1; break; fi + if tty_command_observed "$log"; then entered=1; break; fi sleep 1 done ((entered)) || { echo 'Real-root local TTY keyboard command not observed' >&2; exit 1; } diff --git a/tests/boot-pair-serial-contract.sh b/tests/boot-pair-serial-contract.sh index f93ae64..8a53d12 100644 --- a/tests/boot-pair-serial-contract.sh +++ b/tests/boot-pair-serial-contract.sh @@ -12,4 +12,11 @@ printf '\033[32mUKE_ROOT_ADMITTED\033[0m\r\n\033]3008;fixture\033\\\033P+q6E616D printf 'UKE_ROOT_ADMITTED\nrealrootok\nselinux=1\n' > "$fixture/expected" normalize "$fixture/serial" > "$fixture/actual" cmp "$fixture/expected" "$fixture/actual" -echo 'Serial CSI/OSC/DCS and shared-line observation fixtures passed' +tty_command_observed "$fixture/serial" +printf 'localhost login: realrootok\r\n' > "$fixture/login" +tty_command_observed "$fixture/login" +for text in unrealrootok realrootokextra 'echo realrootok > /dev/ttyAMA0' realroot; do + printf '%s\n' "$text" > "$fixture/reject" + if tty_command_observed "$fixture/reject"; then echo 'Non-command marker accepted' >&2; exit 1; fi +done +echo 'Serial CSI/OSC/DCS, shared login line and marker-rejection fixtures passed' From edd0d18f9a22de9c8c418ac8681ad3265b55c021 Mon Sep 17 00:00:00 2001 From: Senemos Maintainers <75160848+MCC45TR@users.noreply.github.com> Date: Fri, 9 Oct 2026 07:04:02 +0300 Subject: [PATCH 6/7] Record the validated stock-ABL Core pair and owner-test boundaries --- README.md | 13 +- reports/FEDORA-BOOT-PAIR-2026-10-09.json | 222 +++++++++++++++++++++++ 2 files changed, 230 insertions(+), 5 deletions(-) create mode 100644 reports/FEDORA-BOOT-PAIR-2026-10-09.json diff --git a/README.md b/README.md index 274d0ef..d466d88 100644 --- a/README.md +++ b/README.md @@ -102,11 +102,14 @@ of the bootloader-selected DT and a local tablet-screen TTY candidate. The host builder never changes GPT, Android userdata, slots, init_boot, vendor_boot or dtbo. Actual Image/modules/DTB compilation, all 1,146 module byte comparisons against signed COPR release 1.5 and the linked initramfs check have passed. -EXT4 labeling/readback and full sparse decode checks have passed. A generic -ARM64 diagnostic VM reached the enforcing Fedora root and executed a local -TTY keyboard command; the final frozen recipe and negative-root cases are -under revalidation. Physical ABL, UFS, screen and input acceptance are separate -owner-test gates. +The [October 9 paired-image result](reports/FEDORA-BOOT-PAIR-2026-10-09.json) +records completed EXT4 labeling/readback, independent Android sparse decoding +and verified cache reuse. The exact Image reached the real enforcing Fedora +root in generic ARM64 QEMU and executed a local TTY keyboard command. Separate +wrong-partition-name and wrong-UUID cases were rejected. The local owner-test +delivery contains a 96 MiB boot image and a 556.23 MiB sparse system image +representing a 3 GiB filesystem. Physical ABL, UFS, screen and input acceptance +remain separate owner-test gates; no public binary release is claimed. See the [paired-image operator guide](https://github.com/MCC45TR/uke-linux-docs/blob/main/docs/testing/FEDORA-BOOT-PAIR.md) for coordinated UUIDs, the separate GPT `linux` prerequisite and rollback. Do not substitute the older initramfs-only image for this root-capable pair. diff --git a/reports/FEDORA-BOOT-PAIR-2026-10-09.json b/reports/FEDORA-BOOT-PAIR-2026-10-09.json new file mode 100644 index 0000000..e11b1a9 --- /dev/null +++ b/reports/FEDORA-BOOT-PAIR-2026-10-09.json @@ -0,0 +1,222 @@ +{ + "schema_version": 1, + "date": "2026-10-09", + "device": "uke", + "distribution": "fedora-rawhide", + "architecture": "aarch64", + "status": "host-and-generic-VM-validated-owner-test-candidate", + "candidate": "pair-03ea9569c96dcb08", + "recipe_sha256": "6bf3a30bb53fefeaeb2b1589c257d58ec1502caa4834ff99a3c0d31f6fd9caae", + "root_uuid": "03ea9569-c96d-cb08-0a64-53c6f816aa32", + "source": { + "builder_commit": "1f5adb9b34db1b19ec6e970f809c0d13c323184e", + "kernel_recipe_commit": "921d4984f6a558a11bdb41616d6bc7ee618b91f7", + "base": { + "configs": [ + { + "path": "configs/arm64-qcom.config", + "sha256": "ea08a26d2afe7d27411ca603c5e7690116f796a1c7956408156f24a09f8fdf06" + }, + { + "path": "configs/uke.config", + "sha256": "f84cf917915682a1214cbb06c0caee1956b1d27711ee196212fea6ec67d563fa" + }, + { + "path": "configs/fedora.config", + "sha256": "3d7ad09fbd77ecd803eb5d09be59114093d469260e0f6dc2b15fc0a849479744" + } + ], + "donor_commit": "32cad9ccd383ff4b37d8a5e7f88a8bfdcc07307e", + "dtb": "qcom/sm7675-xiaomi-uke.dtb", + "hardware_tested": false, + "patches": [ + { + "path": "patches/7.2.9/0001-cliffs-clock-handoff.patch", + "sha256": "a5946597c24f89d9750ecc807d48a28215877d8f59100cbcde63f4717c3a51f9" + }, + { + "path": "patches/7.2.9/0002-uke-rpmh-regulators.patch", + "sha256": "bc5b9634c6be79345545f86cdcb17bc6ce252f163034a66dbfd7dbd9fd1d0b47" + }, + { + "path": "patches/7.2.9/0003-cliffs-tlmm.patch", + "sha256": "8040aecc97ad055d8dad9a6b0dc76a9087e4742f2d5003b846f2af97906b53e4" + }, + { + "path": "patches/7.2.9/0004-cliffs-interconnect.patch", + "sha256": "ced8b8823875f77076638ecec583b1e2aac09d1a89d738a686b2e938f1a89dcd" + }, + { + "path": "patches/7.2.9/0005-uke-smmu-handoff.patch", + "sha256": "4e2e00cbfe6bf28819553f110de4e1b11004516934b8e3b6a15b11a413a7729b" + }, + { + "path": "patches/7.2.9/0006-uke-usb-v7-api.patch", + "sha256": "d744bd253d46ce6c96fdd3d0ed26f56ec5aa7f2dd7aabb6774dd08d2f6a0ecef" + }, + { + "path": "patches/7.2.9/0007-uke-independent-dts.patch", + "sha256": "119a715ca253a32f1ef5d4e20eb88aa619c1f7443458d35cc708014ff3575690" + }, + { + "path": "patches/7.2.9/0008-sm7675-ufs-phy.patch", + "sha256": "80aefafd680136bf187dc20c632e7d71b19644528055fe41c262b97e0826a968" + }, + { + "path": "patches/7.2.9/0009-cliffs-ufs-interconnect.patch", + "sha256": "fa1d9a1743fba4de7d163f48c8127b752f560d94bfecfde9590659c48b37debe" + }, + { + "path": "patches/7.2.9/0010-uke-ufs-dma-power-admission.patch", + "sha256": "e20d25186ab0fa1d1b62eae0d61b972ef2c8f812a5212c1e03acfbff09cd8e5e" + }, + { + "path": "patches/7.2.9/0011-cliffs-rpmh-snapshot-size.patch", + "sha256": "a65984bf0b3a5a021446508d58befc26d471ac62fbb5a7a5f784730643b50727" + }, + { + "path": "patches/7.2.9/0012-uke-gdsc-shared-gcc-regmap.patch", + "sha256": "04a04966bde864af9fd744af76af4faf0168fe579b81059aa451de82ab61c9c7" + }, + { + "path": "patches/7.2.9/0013-uke-usb-role-and-phy-error-gates.patch", + "sha256": "2610daab7f7b4ed8fcf53ecee8077df1c49abd4c58ebd40ffc2117b013672dbc" + } + ], + "schema_version": 1, + "signature_url": "https://cdn.kernel.org/pub/linux/kernel/v7.x/linux-7.2.9.tar.sign", + "signer": "647F28654894E3BD457199BE38DBBDC86092693E", + "source_date_epoch": 1791024250, + "source_sha256": "b4c5dfbe51a364a6c7f03869200f88c8e1f77403539005f14b7fc6bc91b8d8ba", + "source_url": "https://cdn.kernel.org/pub/linux/kernel/v7.x/linux-7.2.9.tar.xz", + "upstream_commit": "5fce161649b4d779d1b76d9fcd52dc77779774b8", + "version": "7.2.9" + }, + "boot_adaptation": "patches/boot/0001-arm64-uke-abl-dt-hook.patch", + "kernel_release": "7.2.9-senemos-uke", + "final_boot_config_sha256": "3df7001a2d42abd48c33d5a2bae8582f5c98867a7bae34e5f4fe87631735e02f", + "base_kernel_manifest_sha256": "a545fe2f6029346bbf1cb34b115b07a29f0933064151116328e1f8364f9fae54", + "boot_manifest_sha256": "c2b888e783fb98afa196cd71462d96f17bb4757eb3dd008b0cf3681fd36d9c77" + }, + "tool_images": { + "host": "40c9868d08bbad2cb58312fd4c8295ec2a3f132f95bd0eb150a45e05160c2866", + "kernel": "a6fb262f41c060702100f704f5c63ac48957682e84befb0812f08e248f2bec7e", + "root": "9e787753723cc715b6d46fabb9f7b17ceb5195d269224a3f8262fd47a05833ad", + "qemu": "9a044f1e709a13ab768bfad155ce969e5ae637ea2378b7277fbc2fad358daaf6" + }, + "compiler": "Clang 23.1.2 (Fedora 23.1.2-1.fc46)", + "kernel_packages": { + "copr_build": 11090323, + "version": "7.2.9-1.5.fc46.aarch64", + "signed": true, + "copr_signer": "DAFC3C5A881FB49C7167EE2D6F772E3D487BD13E", + "final_inventory_sha256": "cf010b7dfe82458f09bfd43d3f2e5a9110d120c5ca30cc92f7d3d2f548e2650d" + }, + "outputs": [ + { + "name": "fedora_boot.img", + "bytes": 100663296, + "sha256": "0850340c1bcc180b617c921cbd175330cd34d8c4f9274fbd680adef6df22e8e5", + "target": "boot_b", + "format": "Android boot v4; built-in initramfs; unsigned" + }, + { + "name": "system.img", + "bytes": 583253380, + "logical_bytes": 3221225472, + "sha256": "ecbf476699e33de7c1594b407a03e5db053801fe81557b914cc0eb64cac9b93a", + "decoded_sha256": "968c4a4c4c50a636a62a2b608dee5f18835416059c9a3919434fb3b6d893cb6d", + "target": "linux", + "format": "Android sparse RAW/FILL with complete EXT4 coverage" + } + ], + "boot_header": { + "header_version": 4, + "page_size": 4096, + "kernel_bytes": 63506944, + "ramdisk_bytes": 0, + "signature_bytes": 0, + "os_version_encoded": 570425768, + "payload_end": 63512576, + "image_bytes": 100663296, + "avb_footer_present": false + }, + "checks": { + "source_preparation": true, + "base_patches": 13, + "abl_adaptation_patch": 1, + "embedded_initramfs_byte_match": true, + "module_byte_match": 1146, + "android_v4_roundtrip": true, + "full_sparse_logical_coverage": true, + "sparse_roundtrip": true, + "all_inode_selinux_labels": true, + "ext4_readback": true, + "no_python": true, + "privacy": true, + "dt_transform_private_live_fixture": true, + "ram_and_reservations_preserved_in_host_fixture": true, + "malformed_dt_rejections": true, + "foreign_dt_unchanged": true, + "selinux_inode_contexts": 15335, + "sparse_chunks": 616, + "sparse_dont_care_chunks": 0, + "ext4_e2fsck": true, + "completed_entry_cache_verified": true, + "interrupted_result_sealing_verified": true + }, + "independent_sparse_decoder": { + "package": "android-tools-37.0.0-5.fc46", + "binary_sha256": "15794b07830edf961bd4964af7e29e6b783043365d2414afc71dee38ddb4f461", + "complete_3_GiB_byte_comparison": true, + "decoded_sha256": "968c4a4c4c50a636a62a2b608dee5f18835416059c9a3919434fb3b6d893cb6d" + }, + "qemu": { + "version": "11.1.2", + "machine": "virt", + "synthetic_device_identity": true, + "result": { + "schema_version": 1, + "evidence_class": "generic-arm64-qemu-root-fixture", + "kernel_sha256": "450200e882e74002211749ae9317ed2c6eaaff63ca87e6f62c0ebe548be1a657", + "test_script_sha256": "30c6b776f9491148f2235b2f646e9b90ee5374286aa80e9bcaf8dc89431cebcb", + "real_ext4_root_transition": true, + "root_pid1_systemd": true, + "selinux_enforcing": true, + "local_tty_keyboard_command": true, + "wrong_partition_name_rejected": true, + "wrong_filesystem_uuid_rejected": true, + "boot_tested": false, + "hardware_tested": false + }, + "result_sha256": "38d152814624e9d0f9da2a1d36ef0732d854c6b8eab7a9021fa7e730521000e9", + "limitations": [ + "No Qualcomm ABL, UFS, panel or USB-power emulation", + "TTY keyboard execution proves the generic guest service path only" + ] + }, + "device_prerequisites": [ + "Existing independent GPT partition named linux, at least 3221225472 bytes", + "Actual boot_b capacity and bootloader flash permission checked", + "Actual pre-test B images and working Android A/recovery return retained", + "Use both images from this exact coordinated pair" + ], + "remaining_device_gates": [ + "Unsigned Android boot v4 admission by stock ABL", + "Current-slot DT and RAM handoff", + "UFS controller and PHY at real boot", + "Visible 3200x2136 simplefb TTY and input", + "Physical fastboot sparse write and Android return", + "USB CDC ACM and separate ESP32 host/power qualification" + ], + "delivery": { + "local_owner_trial": true, + "published_binary_assets": false, + "automatic_flashing": false, + "gpt_creation": false, + "esp_required": false + }, + "boot_tested": false, + "hardware_tested": false, + "flash_executed": false +} From 649cba774ab1a0384fb11e071770ca03ba3dd73a Mon Sep 17 00:00:00 2001 From: Senemos Maintainers <75160848+MCC45TR@users.noreply.github.com> Date: Fri, 9 Oct 2026 09:03:04 +0300 Subject: [PATCH 7/7] Add read-only device preflight for coordinated Core image pairs --- README.md | 12 ++ src/device/preflight.sh | 197 +++++++++++++++++++++++++++++ src/image/core.sh | 11 +- tests/device-preflight-contract.sh | 117 +++++++++++++++++ tests/image-contract.sh | 2 + 5 files changed, 338 insertions(+), 1 deletion(-) create mode 100644 src/device/preflight.sh create mode 100644 tests/device-preflight-contract.sh diff --git a/README.md b/README.md index d466d88..9b61f13 100644 --- a/README.md +++ b/README.md @@ -114,6 +114,18 @@ See the [paired-image operator guide](https://github.com/MCC45TR/uke-linux-docs/ for coordinated UUIDs, the separate GPT `linux` prerequisite and rollback. Do not substitute the older initramfs-only image for this root-capable pair. +Before the owner's manual trial, run +`./ukelinux.sh --check-device --pair DELIVERY_DIRECTORY` from the workspace. +This verifies all nine delivery checksums, manifest/VM agreement, coordinated +root UUID and 3 GiB sparse header, then performs bounded read-only inventory +on one ADB or fastboot transport. Bootloader inventory checks Uke identity, +slot A, unlock state, `boot_b`/`linux` capacity and a non-logical `linux` target. +ADB can inspect physical-partition sysfs but still requires the bootloader +check. `--offline` only verifies local files; `--report NEW_FILE` saves a private +JSON result without unit identifiers or raw output. Exit 2 means device +prerequisites are unavailable. The check performs no device writes and cannot +establish backups, working Android return, physical boot or screen output. + `./ukelinux.sh --build boot --distro=fedora --help` describes the additional host-only `fedora_boot.img` route. It builds a raw ARM64 kernel with a built-in Fedora debug initramfs and wraps it in Android boot v4. Aloha and ESP are not diff --git a/src/device/preflight.sh b/src/device/preflight.sh new file mode 100644 index 0000000..d360127 --- /dev/null +++ b/src/device/preflight.sh @@ -0,0 +1,197 @@ +#!/usr/bin/env bash +# Sourced by ukelinux.sh. Device commands are bounded, read-only inventory. +device_preflight_help() { + cat <<'HELP' +Usage: ./ukelinux.sh --check-device --pair DIRECTORY [--offline] [--report FILE] +Verify a delivered stock-ABL pair and inspect one connected Uke transport. +--offline verifies local files without contacting ADB or fastboot. +--report writes a new JSON report; existing files are never overwritten. +Exit 0: local checks passed (offline), or bootloader geometry checks passed. +Exit 1: invalid options or pair. Exit 2: device prerequisites unavailable. +No flash, slot selection, reboot, unlock, root escalation or partition writes. +The report omits serial numbers, captured DT, private paths and raw output. +Bootloader checks do not establish tablet boot or the Android return route. +HELP +} + +device_size_number() { + local value=$1 + if [[ $value =~ ^0x[0-9a-fA-F]{1,12}$ ]]; then printf '%s\n' "$((value))"; + elif [[ $value =~ ^[0-9]{1,15}$ ]]; then printf '%s\n' "$((10#$value))"; + else return 1; fi +} + +device_fastboot_value() { + local serial=$1 key=$2 raw line value='' count=0 + raw=$(timeout 8 fastboot -s "$serial" getvar "$key" 2>&1) || return 1 + while IFS= read -r line; do + line=${line//$'\r'/} + line=${line#'(bootloader) '} + if [[ $line == "$key: "* ]]; then + value=${line#"$key: "}; ((count+=1)) + fi + done <<< "$raw" + [[ $count == 1 ]] || return 1 + printf '%s\n' "$value" +} + +device_pair_verify() { + local pair=$1 file hash line count=0 + local -A seen=() + [[ -d $pair && ! -L $pair && -f $pair/SHA256SUMS && ! -L $pair/SHA256SUMS ]] || die 'Missing regular pair directory or checksums' + while IFS= read -r line; do + [[ $line =~ ^([a-f0-9]{64})\ \ ([A-Za-z0-9./_-]+)$ ]] || die 'Invalid checksum record' + hash=${BASH_REMATCH[1]}; file=${BASH_REMATCH[2]} + case $file in + fedora_boot.img|system.img|manifest.json|qemu/result.json|packages.tsv|kernel.config|validation.json|INSTALL.md|README.txt) :;; + *) die 'Unexpected checksum path';; + esac + [[ ! -v seen[$file] ]] || die 'Duplicate checksum record' + seen[$file]=1; ((count+=1)) + [[ -f $pair/$file && ! -L $pair/$file ]] || die 'Missing regular delivery file' + [[ $file != qemu/result.json || ! -L $pair/qemu ]] || die 'Symlinked VM result directory' + [[ $(sha256sum "$pair/$file" | cut -d ' ' -f1) == "$hash" ]] || die "Delivery checksum failed: $file" + done < "$pair/SHA256SUMS" + [[ $count == 9 ]] || die 'Incomplete delivery checksum set' + local boot system vm + boot=$(sha256sum "$pair/fedora_boot.img" | cut -d ' ' -f1) + system=$(sha256sum "$pair/system.img" | cut -d ' ' -f1) + vm=$(sha256sum "$pair/qemu/result.json" | cut -d ' ' -f1) + jq -e --arg boot "$boot" --arg system "$system" --arg vm "$vm" \ + --argjson boot_bytes "$(stat -c %s "$pair/fedora_boot.img")" \ + --slurpfile result "$pair/qemu/result.json" ' + .profile.root_uuid as $uuid | + .schema_version==1 and .profile.device=="uke" and + .profile.mode=="stock-abl-ext4-root" and .profile.distribution=="fedora" and + .profile.release=="rawhide" and .profile.root_size_mib==3072 and + .profile.boot_partition_bytes==$boot_bytes and $boot_bytes==100663296 and + (.profile.root_uuid|test("^[a-f0-9]{8}(-[a-f0-9]{4}){3}-[a-f0-9]{12}$")) and + (.profile.kernel_command_line|contains("root=UUID="+$uuid+" ")) and + .sha256.boot==$boot and .sha256.system==$system and + .checks.module_byte_match==1146 and .checks.no_python==true and .checks.privacy==true and + .checks.sparse_roundtrip==true and .checks.full_sparse_logical_coverage==true and + .qemu_tested==true and .qemu_evidence.sha256==$vm and .qemu_evidence.result==$result[0] and + .qemu_evidence.result.real_ext4_root_transition==true and + .qemu_evidence.result.root_pid1_systemd==true and .qemu_evidence.result.selinux_enforcing==true and + .qemu_evidence.result.local_tty_keyboard_command==true and + .qemu_evidence.result.wrong_partition_name_rejected==true and + .qemu_evidence.result.wrong_filesystem_uuid_rejected==true and + .boot_tested==false and .hardware_tested==false and .flash_executed==false + ' "$pair/manifest.json" >/dev/null || die 'Pair manifest or VM result is incompatible' + # Read only the sparse header; the checksums bind the previously decoded + # complete payload. The logical size, not the compressed file size, matters. + local -a header + read -r -a header <<< "$(od -An -tu4 -N28 -v "$pair/system.img" | tr '\n' ' ')" + [[ ${#header[@]} == 7 && ${header[0]} == 3978755898 && ${header[1]} == 1 && + ${header[2]} == 786460 && ${header[3]} == 4096 && ${header[4]} == 786432 ]] || die 'Sparse header does not describe the reviewed 3 GiB filesystem' +} + +device_preflight_main() { + local pair='' report='' offline=0 key='' value='' command + while (($#)); do + case $1 in + --check-device) :;; --offline) offline=1;; + --help|-h) device_preflight_help; return 0;; + --pair=*|--report=*) key=${1%%=*}; value=${1#*=};; + --pair|--report) key=$1; shift; (($#)) || die "Missing value for $key"; value=$1;; + *) die "Unknown device-check option: $1";; + esac + case $key in --pair) pair=$value;; --report) report=$value;; esac + key=''; shift + done + for command in jq sha256sum stat od tr cut timeout; do + command -v "$command" >/dev/null 2>&1 || die "Missing host check tool: $command" + done + [[ -n $pair ]] || die '--pair must identify the coordinated delivery directory' + [[ -z $report || (! -e $report && ! -L $report) ]] || die 'Report must name a new file' + device_pair_verify "$pair" + local mode=offline status=local-pair-verified code=0 product=unknown slot=unknown unlocked=unknown + local boot_bytes=null linux_bytes=null physical=unknown userspace=unknown + local serial='' listing='' adb_count=0 fastboot_count=0 row token transport='' + local -a blockers=() + if ((offline == 0)); then + if command -v adb >/dev/null 2>&1; then + listing=$(timeout 8 adb devices -l 2>/dev/null) || listing='' + while read -r serial row token; do + if [[ $row == device ]]; then + ((adb_count+=1)) + [[ $token =~ transport_id:([0-9]+) ]] && transport=${BASH_REMATCH[1]} + fi + done <<< "$listing" + fi + if command -v fastboot >/dev/null 2>&1; then + listing=$(timeout 8 fastboot devices 2>/dev/null) || listing='' + while read -r row token; do + if [[ -n $row && $token == fastboot ]]; then serial=$row; ((fastboot_count+=1)); fi + done <<< "$listing" + fi + mode=unavailable; status=device-prerequisites-unavailable; code=2 + if ((adb_count + fastboot_count != 1)); then + blockers+=(exactly-one-ready-Uke-transport-required) + elif ((fastboot_count == 1)); then + mode=fastboot + value=$(device_fastboot_value "$serial" product) || value='' + [[ $value != uke ]] || product=uke + value=$(device_fastboot_value "$serial" current-slot) || value='' + [[ $value != a && $value != b ]] || slot=$value + value=$(device_fastboot_value "$serial" unlocked) || value='' + case $value in yes|no) unlocked=$value;; esac + value=$(device_fastboot_value "$serial" is-userspace) || value='' + case $value in yes|no) userspace=$value;; esac + value=$(device_fastboot_value "$serial" is-logical:linux) || value='' + case $value in no) physical=yes;; yes) physical=no;; esac + value=$(device_fastboot_value "$serial" partition-size:boot_b) || value='' + boot_bytes=$(device_size_number "$value") || boot_bytes=null + value=$(device_fastboot_value "$serial" partition-size:linux) || value='' + linux_bytes=$(device_size_number "$value") || linux_bytes=null + else + mode=adb + # Fixed command: properties and sysfs only. No su/adb root, block + # writes, captured FDT or unit identifiers enter this probe. + # Expansion happens in the device shell, never on the host. + # shellcheck disable=SC2016 + local probe='printf "product=%s\nslot=%s\nlocked=%s\n" "$(getprop ro.product.device)" "$(getprop ro.boot.slot_suffix)" "$(getprop ro.boot.flash.locked)"; for p in boot_b linux; do n=$(readlink -f /dev/block/by-name/$p 2>/dev/null); n=${n##*/}; case "$n" in ""|*[!A-Za-z0-9_-]*) continue;; esac; if [ -r /sys/class/block/$n/size ]; then printf "%s_sectors=" "$p"; cat /sys/class/block/$n/size; fi; if [ "$p" = linux ] && [ -f /sys/class/block/$n/partition ]; then while IFS= read -r line; do [ "$line" != PARTNAME=linux ] || printf "linux_physical=yes\n"; done < /sys/class/block/$n/uevent; fi; done' + listing='' + if [[ $transport =~ ^[0-9]+$ ]]; then listing=$(timeout 10 adb -t "$transport" shell -T "$probe" 2>/dev/null) || listing=''; fi + while IFS='=' read -r key value; do + value=${value//$'\r'/} + case $key:$value in + product:uke) product=uke;; slot:_a) slot=a;; slot:_b) slot=b;; + locked:0) unlocked=yes;; locked:1) unlocked=no;; linux_physical:yes) physical=yes;; + boot_b_sectors:*|linux_sectors:*) + if [[ $value =~ ^[0-9]{1,12}$ ]]; then + value=$((10#$value * 512)) + [[ $key != boot_b_sectors ]] || boot_bytes=$value + [[ $key != linux_sectors ]] || linux_bytes=$value + fi;; + esac + done <<< "$listing" + blockers+=(bootloader-fastboot-check-required) + fi + [[ $mode == unavailable || $product == uke ]] || blockers+=(device-identity-unconfirmed) + [[ $mode == unavailable || $slot == a ]] || blockers+=(preserved-Android-A-slot-not-current) + [[ $mode == unavailable || $unlocked == yes ]] || blockers+=(bootloader-unlock-unconfirmed) + [[ $mode == unavailable || ($boot_bytes != null && $boot_bytes -ge 100663296) ]] || blockers+=(boot_b-capacity-unconfirmed) + [[ $mode == unavailable || ($linux_bytes != null && $linux_bytes -ge 3221225472) ]] || blockers+=(linux-capacity-unconfirmed) + [[ $mode == unavailable || $physical == yes ]] || blockers+=(physical-linux-partition-unconfirmed) + [[ $mode != fastboot || $userspace == no ]] || blockers+=(bootloader-fastboot-required) + if ((${#blockers[@]} == 0)); then status=bootloader-geometry-compatible-owner-checks-required; code=0; fi + fi + local result blockers_json + blockers_json=$(printf '%s\n' "${blockers[@]}" | jq -Rsc 'split("\n")|map(select(length>0))') + result=$(jq -n --arg status "$status" --arg mode "$mode" --arg product "$product" --arg slot "$slot" \ + --arg unlocked "$unlocked" --arg physical "$physical" --arg userspace "$userspace" \ + --argjson boot "$boot_bytes" --argjson linux "$linux_bytes" --argjson blockers "$blockers_json" \ + --arg manifest "$(sha256sum "$pair/manifest.json" | cut -d ' ' -f1)" \ + '{schema_version:1,status:$status,local_pair_verified:true,manifest_sha256:$manifest, + transport:$mode,device:$product,current_slot:$slot,unlocked:$unlocked, + fastboot_userspace:$userspace,boot_b_bytes:$boot,linux_bytes:$linux, + linux_physical_partition:$physical,blockers:$blockers, + owner_checks:["Actual pre-test B backups retained","Working Android A and recovery return route","linux target exclusively available for Fedora"], + boot_tested:false,hardware_tested:false,device_write_executed:false}') + if [[ -n $report ]]; then + (umask 077; set -o noclobber; printf '%s\n' "$result" > "$report") || die 'Cannot create a new private report' + fi + printf '%s\n' "$result" + return "$code" +} diff --git a/src/image/core.sh b/src/image/core.sh index 9ca6c9a..6fb71d9 100644 --- a/src/image/core.sh +++ b/src/image/core.sh @@ -13,6 +13,7 @@ Senemos Uke Fedora Core filesystem builder (host only) Usage: ./ukelinux.sh --build core --distro=fedora [options] ./ukelinux.sh --build boot --distro=fedora --help ./ukelinux.sh --build boot-pair --distro=fedora --help + ./ukelinux.sh --check-device --pair DIRECTORY [--offline] [--report FILE] --release rawhide Reviewed first target (default) --jobs N At most two CPUs; defaults to two --esp-size N ESP filesystem size in MiB (default 256; minimum 128) @@ -74,12 +75,20 @@ recipe_hash() { "$dir"/configs/keys/*.asc | sha256sum | cut -d ' ' -f1 } image_main() { - local boot_target=0 pair_target=0 prior='' argument + local boot_target=0 pair_target=0 check_target=0 prior='' argument for argument in "$@"; do + [[ $argument != --check-device ]] || check_target=1 if [[ $argument == --build=boot-pair || ($prior == --build && $argument == boot-pair) ]]; then pair_target=1; fi if [[ $argument == --build=boot || ($prior == --build && $argument == boot) ]]; then boot_target=1; fi prior=$argument done + if ((check_target)); then + # shellcheck source=../device/preflight.sh + # shellcheck disable=SC1091 + source "$BUILDER/src/device/preflight.sh" + device_preflight_main "$@" + return + fi if ((pair_target)); then # shellcheck source=../boot-pair/entry.sh # shellcheck disable=SC1091 diff --git a/tests/device-preflight-contract.sh b/tests/device-preflight-contract.sh new file mode 100644 index 0000000..1c2b813 --- /dev/null +++ b/tests/device-preflight-contract.sh @@ -0,0 +1,117 @@ +#!/usr/bin/env bash +set -Eeuo pipefail +root=$(cd "$(dirname "$0")/.." && pwd) +# shellcheck source=../src/image/core.sh +# shellcheck disable=SC1091 +source "$root/src/image/core.sh" +fixture=$(mktemp -d) +trap 'rm -rf "$fixture"' EXIT +pair=$fixture/pair +mkdir -p "$pair/qemu" "$fixture/bin" +truncate -s 100663296 "$pair/fedora_boot.img" +# A tiny RAW/FILL-header fixture: preflight verifies header and the sealed +# digest, not the full sparse decoder already exercised by codec tests. +printf '\x3a\xff\x26\xed\x01\x00\x00\x00\x1c\x00\x0c\x00\x00\x10\x00\x00\x00\x00\x0c\x00\x68\x02\x00\x00\x00\x00\x00\x00' > "$pair/system.img" +printf '%s\n' '{"real_ext4_root_transition":true,"root_pid1_systemd":true,"selinux_enforcing":true,"local_tty_keyboard_command":true,"wrong_partition_name_rejected":true,"wrong_filesystem_uuid_rejected":true}' > "$pair/qemu/result.json" +for file in packages.tsv kernel.config validation.json INSTALL.md README.txt; do printf 'fixture\n' > "$pair/$file"; done +jq -n --arg boot "$(sha256sum "$pair/fedora_boot.img" | cut -d ' ' -f1)" \ + --arg system "$(sha256sum "$pair/system.img" | cut -d ' ' -f1)" \ + --arg vm "$(sha256sum "$pair/qemu/result.json" | cut -d ' ' -f1)" \ + --slurpfile result "$pair/qemu/result.json" \ + '{schema_version:1,profile:{device:"uke",mode:"stock-abl-ext4-root",distribution:"fedora",release:"rawhide",root_size_mib:3072,boot_partition_bytes:100663296,root_uuid:"03ea9569-c96d-cb08-0a64-53c6f816aa32",kernel_command_line:"root=UUID=03ea9569-c96d-cb08-0a64-53c6f816aa32 rw"},sha256:{boot:$boot,system:$system},checks:{module_byte_match:1146,no_python:true,privacy:true,sparse_roundtrip:true,full_sparse_logical_coverage:true},qemu_tested:true,qemu_evidence:{sha256:$vm,result:$result[0]},boot_tested:false,hardware_tested:false,flash_executed:false}' > "$pair/manifest.json" +cp "$pair/manifest.json" "$fixture/original-manifest" +checksums() { (cd "$pair"; sha256sum fedora_boot.img system.img manifest.json qemu/result.json packages.tsv kernel.config validation.json INSTALL.md README.txt > SHA256SUMS); } +checksums + +cat > "$fixture/bin/fastboot" <<'MOCK' +#!/usr/bin/env bash +set -eu +printf '%s\n' "$*" >> "$PREFLIGHT_LOG" +if [[ $* == devices ]]; then + case $PREFLIGHT_CASE in + none|adb|unauthorized) exit 0;; + multiple) printf 'fixture-one fastboot\nfixture-two fastboot\n';; + *) printf 'fixture-one fastboot\n';; + esac + exit 0 +fi +[[ $# == 4 && $1 == -s && $2 == fixture-one && $3 == getvar ]] || exit 99 +key=$4 +case $key in + product) value=uke; [[ $PREFLIGHT_CASE != foreign ]] || value=other;; + current-slot) value=a; [[ $PREFLIGHT_CASE != slot-b ]] || value=b;; + unlocked) value=yes; [[ $PREFLIGHT_CASE != locked ]] || value=no;; + is-userspace) value=no; [[ $PREFLIGHT_CASE != fastbootd ]] || value=yes;; + is-logical:linux) value=no; [[ $PREFLIGHT_CASE != logical ]] || value=yes;; + partition-size:boot_b) value=0x06000000; [[ $PREFLIGHT_CASE != small-boot ]] || value=0x1000;; + partition-size:linux) + value=0xc0000000 + case $PREFLIGHT_CASE in missing-linux) exit 1;; small-linux) value=1048576;; huge) value=999999999999999999999999999999;; esac;; + *) exit 99;; +esac +printf '(bootloader) %s: %s\n' "$key" "$value" >&2 +[[ $PREFLIGHT_CASE != duplicate ]] || printf '(bootloader) %s: %s\n' "$key" "$value" >&2 +MOCK +cat > "$fixture/bin/adb" <<'MOCK' +#!/usr/bin/env bash +set -eu +printf '%s\n' "$*" >> "$PREFLIGHT_LOG" +if [[ $* == 'devices -l' ]]; then + printf 'List of devices attached\n' + case $PREFLIGHT_CASE in + adb) printf 'private-serial device product:uke model:private device:uke transport_id:7\n';; + unauthorized) printf 'private-serial unauthorized transport_id:7\n';; + esac + exit 0 +fi +[[ $# == 5 && $1 == -t && $2 == 7 && $3 == shell && $4 == -T ]] || exit 99 +[[ $5 == *'/sys/class/block/'* && $5 != *'su '* && $5 != *'dd '* ]] || exit 99 +printf 'product=uke\nslot=_a\nlocked=0\nboot_b_sectors=196608\nlinux_sectors=6291456\nlinux_physical=yes\n' +MOCK +chmod +x "$fixture/bin/adb" "$fixture/bin/fastboot" +export PATH="$fixture/bin:$PATH" PREFLIGHT_LOG=$fixture/commands PREFLIGHT_CASE=none +run_check() { + local expected=$1 actual=0; shift + : > "$PREFLIGHT_LOG" + (image_main --check-device --pair "$pair" "$@") > "$fixture/result.json" 2> "$fixture/stderr" || actual=$? + [[ $actual == "$expected" ]] || { cat "$fixture/stderr"; printf 'Expected %s, got %s\n' "$expected" "$actual" >&2; exit 1; } + if rg -n '(^| )(flash|erase|format|reboot|set-active|unlock|root|remount)( |$)' "$PREFLIGHT_LOG"; then echo 'Device mutation command attempted' >&2; exit 1; fi + if [[ $expected != 1 ]]; then jq -e '.local_pair_verified and .device_write_executed==false and .boot_tested==false' "$fixture/result.json" >/dev/null; fi +} +run_check 0 --offline +[[ ! -s $PREFLIGHT_LOG ]] +run_check 2 +jq -e '.transport=="unavailable"' "$fixture/result.json" >/dev/null +PREFLIGHT_CASE=good run_check 0 +jq -e '.status=="bootloader-geometry-compatible-owner-checks-required" and .linux_bytes==3221225472' "$fixture/result.json" >/dev/null +for PREFLIGHT_CASE in multiple locked foreign slot-b fastbootd logical missing-linux small-linux small-boot duplicate huge unauthorized; do run_check 2; done +PREFLIGHT_CASE=adb run_check 2 +jq -e '.transport=="adb" and .linux_physical_partition=="yes" and .linux_bytes==3221225472 and .blockers==["bootloader-fastboot-check-required"]' "$fixture/result.json" >/dev/null +if rg -n 'private-serial|fixture-one|/home/|model:private' "$fixture/result.json"; then echo 'Private inventory leaked' >&2; exit 1; fi +rg -F -- '-t 7 shell -T' "$PREFLIGHT_LOG" >/dev/null +run_check 0 --offline --report "$fixture/report.json" +[[ $(stat -c %a "$fixture/report.json") == 600 ]] +run_check 1 --offline --report "$fixture/report.json" +run_check 1 --offline --build boot-pair +run_check 1 --pair +cp "$pair/SHA256SUMS" "$fixture/original-checksums" +printf '%s\n' "$(head -n 1 "$pair/SHA256SUMS")" >> "$pair/SHA256SUMS" +run_check 1 --offline +cp "$fixture/original-checksums" "$pair/SHA256SUMS" +printf tampered >> "$pair/system.img" +run_check 1 --offline +truncate -s 28 "$pair/system.img" +mv "$pair/system.img" "$fixture/system.img" +ln -s "$fixture/system.img" "$pair/system.img" +run_check 1 --offline +rm "$pair/system.img" +mv "$fixture/system.img" "$pair/system.img" +jq '.profile.kernel_command_line="root=UUID=wrong rw"' "$fixture/original-manifest" > "$pair/manifest.json" +checksums +run_check 1 --offline +cp "$fixture/original-manifest" "$pair/manifest.json" +jq '.wrong_filesystem_uuid_rejected=false' "$pair/qemu/result.json" > "$fixture/wrong-result" +mv "$fixture/wrong-result" "$pair/qemu/result.json" +checksums +run_check 1 --offline +echo 'Device preflight: offline identity, transport/read-only gates, private reports and corrupt-pair rejection passed' diff --git a/tests/image-contract.sh b/tests/image-contract.sh index a3fef5f..bf18582 100644 --- a/tests/image-contract.sh +++ b/tests/image-contract.sh @@ -37,4 +37,6 @@ verify_cache "$fixture/input" "$(sha256sum "$fixture/input" | cut -d ' ' -f1)" if (verify_cache "$fixture/input" "$(printf corrupted | sha256sum | cut -d ' ' -f1)") >/dev/null 2>&1; then exit 1; fi if (verify_cache "$fixture/missing" "$(sha256sum "$fixture/input" | cut -d ' ' -f1)") >/dev/null 2>&1; then exit 1; fi bash "$root/tests/target-privacy-policy.sh" +bash "$root/tests/device-preflight-contract.sh" +if command -v shellcheck >/dev/null 2>&1; then shellcheck "$root/src/device/preflight.sh"; fi printf '%s\n' 'Image CLI, pinned-input corruption, profile gates and privacy fixtures passed'