diff --git a/.github/workflows/p105.yml b/.github/workflows/p105.yml new file mode 100644 index 0000000..17cd5d9 --- /dev/null +++ b/.github/workflows/p105.yml @@ -0,0 +1,60 @@ +name: P105 ARMv7 desktop +on: + pull_request: + push: + branches: [main] + workflow_dispatch: + inputs: + build_root: + description: Build and audit the signed P105 NFS desktop artifact + type: boolean + default: false +permissions: + contents: read +jobs: + p105-and-j71b-tests: + runs-on: ubuntu-24.04 + strategy: + matrix: + python: ['3.9', '3.12'] + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + persist-credentials: false + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python }} + - run: python -m pip install uv + - run: uv sync --locked --python '${{ matrix.python }}' + - run: uv run --locked python -m unittest discover -s tests -v + p105-root: + if: github.event_name == 'workflow_dispatch' && inputs.build_root + needs: p105-and-j71b-tests + runs-on: ubuntu-24.04-arm + timeout-minutes: 120 + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + persist-credentials: false + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + - run: python -m pip install uv + - run: sudo apt-get update && sudo apt-get install -y podman + - run: uv sync --locked + - name: Build a signed Arch ARMv7 package snapshot and desktop + run: uv run --locked python system/p105/build --release 'preview-${{ github.run_id }}' --output out/p105-build + - name: Assemble and audit the public NFS root + run: | + uv run --locked python -m release.p105.build --base out/p105-build/result --output out/p105-release + uv run --locked python -m release.p105.audit out/p105-release + uv run --locked python -m release.p105.smoke_desktop --release out/p105-release --builder-image "$(cat out/p105-build/work/builder-image.id)" --output out/p105-desktop-check + - uses: actions/upload-artifact@v4 + with: + name: p105-nfs-root + path: out/p105-release/* + compression-level: 0 + if-no-files-found: error + retention-days: 7 diff --git a/BUILD.md b/BUILD.md index c6307cc..682a566 100644 --- a/BUILD.md +++ b/BUILD.md @@ -101,3 +101,38 @@ Choose new evidence directories. `wlrctl` is a test-only native AArch64 helper, ## Publish Follow [Publishing Omarchy A](docs/releasing.md). Build/audit success is not physical-device qualification: test a fresh installation and a cold-restart `orchard launch` on supported hardware before announcing the release. The experimental desktop auto-logs in and grants passwordless sudo; this is disclosed in the README and must not be described as a secure daily-use system. + + +## P105 ARMv7 desktop and NFS root + +The P105 producer uses the companion CLI/hardware commits recorded by the +editable workspace. Initialize them with `./orchard --version`. The hardware +package must be 0.1.11 or later. An ARM64 Linux container host with Podman is +required for the Cortex-A9 build environment; Apple Silicon Macs can use +Podman's VM. The P105 container registers its ARM emulator in its own build +namespace. + +```sh +uv run python system/p105/build --release YOUR_NEW_VERSION --output out/p105-build +uv run python -m release.p105.build --base out/p105-build/result --output out/p105-release +uv run python -m release.p105.audit out/p105-release +``` + +`system/build-rootfs p105` dispatches to the same producer when its Python +environment contains the active hardware package. `--bootstrap` and +`--bootstrap-signature` select cached copies of the pinned official archive. +`--package-cache` accepts a directory of previously downloaded Arch packages; +each selected package is checked against the synchronized database and its +signature. Source-built components retain their source archives and licenses. + +The release contains a public ARMv7 root, a complete catalog and source +receipts. Its `rootfs.json` describes an NFS root and the P105 kernel/runtime +contract. The owner's private boot manifest remains separate. The installed +root stays on the NFS host throughout the session. + +Prepare the host directory with numeric guest ownership and explicit private +owner inputs as described in [the P105 desktop instructions](desktop/p105/README.md). +Qualification requires boots from this finished artifact through the native +Orchard CLI and physical input checks. The desktop enables automatic console +login and passwordless sudo for `tablet`, following the project's experimental +image policy. diff --git a/README.md b/README.md index 323b99a..4fa6c48 100644 --- a/README.md +++ b/README.md @@ -116,3 +116,16 @@ See [CONTRIBUTING.md](CONTRIBUTING.md) for bring-up guidance and [BUILD.md](BUIL ## Troubleshooting See [Troubleshooting](docs/troubleshooting.md) for USB connectivity, diagnostics, and failed installations. + + +## Original iPad mini Wi-Fi (P105) + +The P105 contribution adds an ARMv7 Arch Linux ARM desktop builder with +Labwc/Pixman and Qt software rendering. Its companion Orchard adapter boots +private RAM recovery and hands off to the desktop over USB/NFS. The host +serves the root filesystem throughout the Linux session. + +See [the P105 desktop instructions](desktop/p105/README.md) for the native +builder and owner preparation. This profile covers `iPad2,5` / P105AP. The +measured touch latency and calibration issue remain open; internal storage +and startup without a host belong to later device work. diff --git a/cli b/cli index 4f96e58..47e6c79 160000 --- a/cli +++ b/cli @@ -1 +1 @@ -Subproject commit 4f96e5805975d00ec4b89fe4064e8c1acbb1bcc4 +Subproject commit 47e6c79a26ed77a1e6860be448d5a7d25acf3e16 diff --git a/desktop/p105/README.md b/desktop/p105/README.md new file mode 100644 index 0000000..42a54ac --- /dev/null +++ b/desktop/p105/README.md @@ -0,0 +1,269 @@ +# P105 desktop staging + +This directory stages Omarchy A's Labwc and Quickshell desktop for the original +iPad mini Wi-Fi, iPad2,5 / P105AP. It uses an Arch Linux ARM armv7h userspace and +the 768 x 1024 panel at scale 1. + +Boot access, kernel drivers, firmware preparation and the P105 runtime are +supplied separately. The current local bring-up uses Cascadia and an NFS root +on the host. This desktop payload contains no Apple firmware or device +calibration. + +## Build the keyboard + +Run in an armv7h Arch Linux ARM build environment. Install the build dependencies: + +~~~sh +pacman -S --needed base-devel python wayland libxkbcommon pango scdoc pkgconf patch +python desktop/p105/build-keyboard --output out/p105-keyboard +~~~ + +The recipe downloads the source archive pinned in desktop/sources.json, applies +the existing Omarchy A keyboard patch and checks the ELF32 hard-float ABI. It +retains the archive, patch, upstream license and a build receipt. + +Omafox and Omacalc have armv7h recipes under packages/. Build both as an +unprivileged user: + +~~~sh +python desktop/p105/build-apps --output out/p105-apps +~~~ + +The helper runs makepkg and Omacalc's upstream offscreen tests, retaining +source archives, package metadata and ABI checks. Sign the resulting local +packages with an explicitly trusted local build key before installing them +with pacman -U when LocalFileSigLevel requires signatures. The browser +extension staged below is the same pinned, signed XPI used by the existing +desktop. + +## Build desktop support + +After preparing the synchronized ARMv7h package snapshot, build the existing +pinned Labwc source with Omarchy A's Super-key patch and the startup cursor +helper. The same command prepares the pinned Python Bluetooth library: + +~~~sh +python desktop/p105/build-support --output out/p105-support --jobs 2 +~~~ + +Additional compiler dependencies are meson, ninja, wlroots0.20, +wayland-protocols, libsfdo, libxml2, glib2, cairo, librsvg, xcb-util-wm, +xorg-xwayland and gettext. The build retains source archives and licenses, +then records their hashes, compiler identity and complete package list. +It performs no package installation. + +## Stage and verify + +Check out Omarchy at the commit in desktop/upstream.lock before staging: + +~~~sh +python desktop/p105/stage.py \ + --output out/p105-desktop \ + --upstream out/pinned-omarchy \ + --keyboard out/p105-keyboard/wvkbd-mobintl \ + --support out/p105-support \ + --keyboard-sha256 "$(sha256sum out/p105-keyboard/wvkbd-mobintl | cut -d' ' -f1)" +python desktop/p105/stage.py --verify out/p105-desktop +~~~ + +The target must be a new directory. Staging verifies the Omarchy commit and clean asset paths, +keyboard and support binary hashes/ABIs, and the pinned browser extension. The final manifest +covers the complete payload after the P105 overrides. Verify this directory +before merging it into a populated root filesystem. + +The root builder must create the tablet user with UID 1000, set ownership of +/home/tablet, install the Arch packages and configure PAM/logind, user D-Bus +and seatd. The staged getty override starts that user's console session. +The login helper requires that session and starts Labwc with Pixman and Qt +Quick's software renderer. The staged payload supplies the source-built Labwc +binary and cursor helper, Bluetooth pairing agent, and a backlight observer. +Source-built Labwc must remain paired with its recorded wlroots ABI during +hardware qualification. + +The active menu keeps Apps, theme/background selection, browser learning +links, About, the native display/network/Bluetooth/battery panels, and the +shutdown operation. Its receipt lists every retained and omitted +source entry. PC package installation, update/configuration tools and +Hyprland controls remain outside this profile. Suspend and hibernate stay +absent while their kernel contracts lack hardware qualification. Cascadia +currently powers off when asked to reboot, so the Reboot entry is also +absent. Starting Linux again requires the Mac boot flow. + +The P105 stage adds a read-only keyboard visibility signal to the built-in +Network panel's scoped facade. This lets its password field regain focus +when the on-screen keyboard changes state. The callback returns a boolean +for that built-in entry; the shared desktop source stays unchanged. + +The P105 override leaves volume, the side switch and Hold to Cascadia's +p105-keys service. Labwc maps KEY_HOMEPAGE / XF86HomePage to the application +grid. Cascadia's stock button daemon consumes Home events, so delivery to +the compositor requires an external runtime forwarder. The opt-in +--desktop-home forwarder lives in the hardware work outside this desktop +change. The user confirmed that physical Home opens the application grid +with that runtime forwarder installed. + +The generic desktop readiness helper captures one compositor frame to +/dev/null and records its boot ID, shell PID and Wayland socket. It does not +write an image or announce an Orchard installation. + +## Firefox defaults for 512 MiB + +The P105 policy adds experimental defaults for Firefox 157. It disables +content-process prelaunch and new-tab preloading, sets the shared content +pool to two processes, and caps each isolated origin's pool at one process. +Startup and new tabs open blank, while the welcome page stays closed. The +policy keeps the packaged Omafox extension and existing unrelated settings. + +Every preference uses Status: default with an explicit type, so user values +can override it. Site isolation and sandbox settings retain their original +values. Separate sites and Firefox's helper processes still consume memory; +the content-pool preferences provide no global process or memory cap. + +Mozilla documents these controls in its +[Preferences policy](https://firefox-admin-docs.mozilla.org/reference/policies/preferences/), +[Firefox 157 process defaults](https://github.com/mozilla-firefox/firefox/blob/FIREFOX_157_0_RELEASE/modules/libpref/init/all.js) +and [first-run behavior](https://github.com/mozilla-firefox/firefox/blob/FIREFOX_157_0_RELEASE/browser/components/asrouter/docs/first-run.md). +The complete source pointers are in firefox-sources.json. Compare process +counts and memory on the iPad after restarting Firefox. + +## Check the desktop in a build container + +After merging the staged payload and installing its signed application +packages, run as an unprivileged user: + +~~~sh +python desktop/p105/smoke-headless \ + --stage out/p105-desktop \ + --output out/p105-headless-check +~~~ + +The helper gives Labwc a temporary home and D-Bus session, creates a +768 x 1024 headless output and checks the actual shell IPC, application grid +and theme carousel. Its output includes screenshots and result.json. The +first thumbnail scan can delay the carousel, so the check waits for a +changed viewport. The emulated session uses a longer IPC ceiling; physical +response times require a separate check. + +Hardware services, charging and input access must be verified on the iPad. + +## Local physical qualification + +The original iPad mini Wi-Fi, iPad2,5 / P105AP with an A5 and 512 MiB RAM, +ran this desktop as native Arch Linux ARM armv7h code on Cascadia Linux +6.12 through a USB/NFS root. A repeat boot reached graphical.target in +27.478 seconds. Both the system manager and tablet user manager reported +zero failed units after FUSE was built in, and the document portal ran. +The user confirmed touch input with 2 + 2 = 4 in Omacalc. Foot and Firefox +launched on the iPad. + +The external kernel/runtime contract used systemd namespaces and cgroups, +built-in FUSE, uinput and a 256 MiB LZ4 zram device. Kernel builds, hardware +services and the Home forwarder are supplied outside this desktop change. +The user confirmed physical Home opens the application grid. Wi-Fi scanning +and association succeeded, with the password entered through the on-iPad UI. +Bluetooth pairing awaits a device test, and audio playback remains unverified. + +One settled Firefox sample with three restored tabs showed 208 MiB RAM +available and 99 MiB logical swap in use. These values describe that saved +session and workload. Content-pool defaults retain separate site/helper +processes and provide no global memory cap. + +## Scope + +The payload reuses the actual Omarchy A shell, app grid, menus, theme scripts, +Labwc decorations and Wayland keyboard. The existing desktop port has a +limited Omarchy CLI. This staging path does not add the full desktop PC +installer or a persistent installation on the iPad's NAND. + +Runtime packages also include inotify-tools, libvips, wl-clipboard and +imagemagick for device watching, theme thumbnails and image metadata. + +This directory covers experimental desktop staging. Supported releases +require separate hardware/boot integration and further qualification. +Staging verification establishes file integrity and target ABI; the local +physical observations above cover the tested device and workload. + + +## Native Omarchy A root build + +`system/p105/build` composes a clean ARMv7 root from signed Arch Linux ARM +packages. It builds the keyboard and desktop support in the same synchronized +package environment as Quickshell and Qt, then stages this profile. The +hardware overlay comes from the active hardware package. Private firmware and +calibration arrive from the owner's RAM recovery during NFS handoff. + +```sh +uv run python system/p105/build --release YOUR_NEW_VERSION --output out/p105-build +uv run python -m release.p105.build --base out/p105-build/result --output out/p105-release +uv run python -m release.p105.audit out/p105-release +``` + +The generic `system/build-rootfs p105` entry accepts the same arguments with a +configured Python environment. `build-state.json` retains the image identity +and work volume; package and source receipts accompany the result. A manual +P105 workflow can retain the audited public artifact without publishing it. +Existing J71b producers keep their own build path. + +An owner can expand a verified release on the NFS host with administrator +access to preserve UID/GID and executable modes: + +```sh +sudo .venv/bin/python -m release.p105.prepare_nfs \ + --release out/p105-release --destination /absolute/new/nfs-root \ + --owner-inputs /private/p105/owner-inputs +``` + +Owner inputs are explicit files with hashes in `owner-inputs.json`. They can +supply the tablet's authorized SSH keys, an owner SSH host key and Wi-Fi +connection files. Keep this directory private. The prepared root has its own +private receipt and can be exported over NFSv3/TCP to `10.55.0.2`. Its path +belongs to the host's NFS configuration. + +Boot the private RAM bundle with the companion Orchard CLI and select that +export: + +```sh +./orchard --profile p105 boot /private/p105/boot/boot.toml \ + --nfs-root 10.55.0.1:/absolute/new/nfs-root +``` + +The Mac supplies the root for the entire desktop session. The native image +retains the 256 MiB LZ4 zram setup and software rendering policy. Bluetooth +pairing and audio playback await physical qualification. The slow touch and +intermittent calibration failure recorded during the recovery work remain +open hardware issues. + + +The NFS host must provide a case-sensitive filesystem. On macOS, a separate +case-sensitive APFS disk image can hold the prepared root while keeping the +working export available. Keep that image mounted for the entire session. +The preparer checks case sensitivity before extracting files. + +An owner input receipt has this structure, with each digest computed from +its corresponding private file: + +```json +{"schema": 1, "profile": "p105", "files": {"home/tablet/.ssh/authorized_keys": "SHA256_HEX"}} +``` + +The finished artifact can also run in a disposable headless Cortex-A9 test +container. The test adds its emulator and a temporary machine identity to +the validation container. The public archive retains its original bytes: + +```sh +uv run python -m release.p105.smoke_desktop \ + --release out/p105-release \ + --builder-image "$(cat out/p105-build/work/builder-image.id)" \ + --output out/p105-desktop-check +``` + +The result records the audited root hash and whether the app grid and theme +carousel responded. Screenshots accompany the result. The P105 workflow runs +this check for manually requested root builds. Physical input and radio +behavior require their own device checks. + +Candidate `0.1.1-p105.1` passed two native Orchard CLI boots on the original +iPad mini Wi-Fi. Physical Home and Omacalc worked, and the owner entered a +Wi-Fi password with the on-screen keyboard. The saved connection returned +after reboot. See [the P105 validation record](../../docs/p105-validation.md) +for the tested root hash and remaining issues. diff --git a/desktop/p105/build-apps b/desktop/p105/build-apps new file mode 100755 index 0000000..97b0813 --- /dev/null +++ b/desktop/p105/build-apps @@ -0,0 +1,86 @@ +#!/usr/bin/env python3 +"""Build P105's pinned Omacalc and Omafox packages without package installation.""" + +import argparse +import hashlib +import json +import os +from pathlib import Path +import platform +import shutil +import subprocess +import tempfile + +import stage + +HERE = Path(__file__).resolve().parent + + +def sha(path): + return hashlib.sha256(Path(path).read_bytes()).hexdigest() + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--output", type=Path, required=True) + args = parser.parse_args() + output = args.output.resolve() + if output.exists(): + parser.error("Choose a new output directory") + if os.geteuid() == 0 or platform.machine() not in {"armv7l", "armv8l"}: + parser.error("Run as an unprivileged user inside the ARMv7h build environment") + for tool in ("makepkg", "cc", "readelf", "qmake6", "bsdtar", "pacman"): + if not shutil.which(tool): + parser.error("Missing build tool: " + tool) + output.parent.mkdir(parents=True, exist_ok=True) + try: + with tempfile.TemporaryDirectory(prefix="p105-apps-") as temporary: + work = Path(temporary) + result = work / "result" + result.mkdir() + components = {} + for name, executable in (("omacalc", "usr/bin/omacalc"), ("omafox", "usr/lib/omafox/omafox-native-host")): + recipe = HERE / "packages" / name / "PKGBUILD" + source = work / name + source.mkdir() + shutil.copy2(recipe, source / "PKGBUILD") + component = result / name + component.mkdir() + (component / "sources").mkdir() + environment = {**os.environ, "PKGDEST": str(component), + "SRCDEST": str(component / "sources"), "MAKEFLAGS": "-j2"} + subprocess.run(["makepkg", "--cleanbuild", "--noconfirm"], cwd=source, env=environment, check=True) + archives = [path for path in component.iterdir() if path.is_file() and ".pkg.tar." in path.name] + if len(archives) != 1: + raise ValueError("Unexpected package output for " + name) + package = archives[0] + binary = work / (name + "-elf") + with binary.open("wb") as stream: + subprocess.run(["bsdtar", "-xOf", str(package), executable], stdout=stream, check=True) + stage.check_armhf(binary, name) + shutil.copy2(recipe, component / "PKGBUILD") + for metadata in (".PKGINFO", ".BUILDINFO"): + (component / metadata[1:]).write_bytes(subprocess.check_output(["bsdtar", "-xOf", str(package), metadata])) + (component / "SRCINFO").write_text(subprocess.check_output(["makepkg", "--printsrcinfo"], cwd=source, env=environment, text=True)) + components[name] = {"package": package.relative_to(result).as_posix(), "package_sha256": sha(package), + "recipe_sha256": sha(recipe), "elf_sha256": sha(binary)} + (result / "packages.lock").write_text(subprocess.check_output(["pacman", "-Q"], text=True)) + record = { + "schema": 1, "architecture": "armv7h", "components": components, + "compiler": subprocess.check_output(["cc", "--version"], text=True).splitlines()[0], + "compiler_target": subprocess.check_output(["cc", "-dumpmachine"], text=True).strip(), + "files": {path.relative_to(result).as_posix(): sha(path) + for path in sorted(result.rglob("*")) if path.is_file()}, + } + (result / "manifest.json").write_text(json.dumps(record, indent=2) + "\n") + with tempfile.TemporaryDirectory(prefix=".p105-export-", dir=output.parent) as export_work: + staged = Path(export_work) / "result" + shutil.copytree(result, staged) + os.replace(staged, output) + print("Built ARMv7h application packages:", output) + except (OSError, ValueError, subprocess.CalledProcessError) as error: + parser.error(str(error)) + + +if __name__ == "__main__": + main() diff --git a/desktop/p105/build-keyboard b/desktop/p105/build-keyboard new file mode 100755 index 0000000..a3f27bc --- /dev/null +++ b/desktop/p105/build-keyboard @@ -0,0 +1,112 @@ +#!/usr/bin/env python3 +"""Build the patched Omarchy A Wayland keyboard in Arch Linux ARM armv7h.""" + +import argparse +import hashlib +import json +import os +from pathlib import Path +import platform +import shutil +import struct +import subprocess +import sys +import tarfile +import tempfile +from urllib.error import HTTPError, URLError +from urllib.request import urlopen + +HERE = Path(__file__).resolve().parent +DESKTOP = HERE.parent + + +def sha(path): + return hashlib.sha256(Path(path).read_bytes()).hexdigest() + + +def fetch(entry, output): + for url in entry["urls"]: + try: + with urlopen(url, timeout=60) as response, output.open("wb") as stream: + shutil.copyfileobj(response, stream) + except (HTTPError, URLError, TimeoutError) as error: + output.unlink(missing_ok=True) + print("Source unavailable:", error, flush=True) + continue + if sha(output) != entry["sha256"]: + output.unlink() + raise ValueError("Keyboard source hash differs") + return url + raise ValueError("Pinned keyboard source is unavailable") + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--output", type=Path, required=True) + parser.add_argument("--jobs", type=int, default=2) + args = parser.parse_args() + output = args.output.resolve() + if output.exists(): + parser.error("Choose a new output directory") + if platform.machine() not in {"armv7l", "armv8l"}: + parser.error("Run inside an armv7h Arch Linux ARM build environment") + if args.jobs < 1: + parser.error("--jobs must be positive") + if sys.version_info < (3, 12): + parser.error("Python 3.12 or newer is required for filtered archive extraction") + for tool in ("cc", "make", "patch", "pkg-config", "readelf"): + if not shutil.which(tool): + parser.error("Missing build tool: " + tool) + pins = json.loads((DESKTOP / "sources.json").read_text())["wvkbd"] + patch = DESKTOP / "keyboard/wvkbd-0.20.patch" + output.parent.mkdir(parents=True, exist_ok=True) + try: + with tempfile.TemporaryDirectory(prefix="p105-keyboard-") as temporary: + work = Path(temporary) + archive = work / "wvkbd-v0.20.tar.gz" + url = fetch(pins, archive) + source = work / "source" + source.mkdir() + with tarfile.open(archive, "r:gz") as tar: + tar.extractall(source, filter="data") + directories = [path for path in source.iterdir() if path.is_dir()] + if len(directories) != 1: + raise ValueError("Unexpected keyboard source archive layout") + source = directories[0] + subprocess.run(["patch", "--batch", "--fuzz=0", "-p1", "-i", str(patch)], cwd=source, check=True) + subprocess.run(["make", "-j" + str(args.jobs), "wvkbd-mobintl"], cwd=source, check=True) + binary = source / "wvkbd-mobintl" + header = binary.read_bytes()[:52] + if (len(header) != 52 or header[:6] != b"\x7fELF\x01\x01" + or struct.unpack_from("=3.12 and a positive --jobs value are required") + for tool in ("cc", "meson", "ninja", "patch", "pkg-config", "readelf", "wayland-scanner", "pacman"): + if not shutil.which(tool): + parser.error("Missing build tool: " + tool) + output.parent.mkdir(parents=True, exist_ok=True) + try: + with tempfile.TemporaryDirectory(prefix="p105-support-") as temporary: + work = Path(temporary) + result = work / "result" + result.mkdir() + components = { + "labwc": compile_labwc(work, result / "labwc", args.jobs), + "cursor": compile_cursor(work, result / "cursor"), + } + subprocess.run([sys.executable, str(DESKTOP / "build-bluetooth-tools"), + "--output", str(result / "bluetooth")], check=True) + components["bluetooth"] = {"source": json.loads((DESKTOP / "bluetooth/dbus-next.json").read_text())} + (result / "packages.lock").write_text(subprocess.check_output(["pacman", "-Q"], text=True)) + record = { + "schema": 1, "architecture": "armv7h", "components": components, + "compiler": subprocess.check_output(["cc", "--version"], text=True).splitlines()[0], + "compiler_target": subprocess.check_output(["cc", "-dumpmachine"], text=True).strip(), + "files": {path.relative_to(result).as_posix(): sha(path) + for path in sorted(result.rglob("*")) if path.is_file()}, + } + (result / "manifest.json").write_text(json.dumps(record, indent=2) + "\n") + with tempfile.TemporaryDirectory(prefix=".p105-export-", dir=output.parent) as export_work: + staged = Path(export_work) / "result" + shutil.copytree(result, staged) + os.replace(staged, output) + print("Built ARMv7h desktop support:", output) + except (OSError, ValueError, subprocess.CalledProcessError) as error: + parser.error(str(error)) + + +if __name__ == "__main__": + main() diff --git a/desktop/p105/firefox-defaults.json b/desktop/p105/firefox-defaults.json new file mode 100644 index 0000000..44750b6 --- /dev/null +++ b/desktop/p105/firefox-defaults.json @@ -0,0 +1,37 @@ +{ + "dom.ipc.processPrelaunch.enabled": { + "Value": false, + "Status": "default", + "Type": "boolean" + }, + "dom.ipc.processCount": { + "Value": 2, + "Status": "default", + "Type": "number" + }, + "dom.ipc.processCount.webIsolated": { + "Value": 1, + "Status": "default", + "Type": "number" + }, + "browser.newtab.preload": { + "Value": false, + "Status": "default", + "Type": "boolean" + }, + "browser.aboutwelcome.enabled": { + "Value": false, + "Status": "default", + "Type": "boolean" + }, + "browser.startup.page": { + "Value": 0, + "Status": "default", + "Type": "number" + }, + "browser.newtabpage.enabled": { + "Value": false, + "Status": "default", + "Type": "boolean" + } +} diff --git a/desktop/p105/firefox-sources.json b/desktop/p105/firefox-sources.json new file mode 100644 index 0000000..aa20803 --- /dev/null +++ b/desktop/p105/firefox-sources.json @@ -0,0 +1,12 @@ +{ + "validated_firefox_version": "157.0", + "policy_documentation": "https://firefox-admin-docs.mozilla.org/reference/policies/preferences/", + "versioned_sources": { + "process_pool_defaults": "https://github.com/mozilla-firefox/firefox/blob/FIREFOX_157_0_RELEASE/modules/libpref/init/all.js", + "prelaunch_and_onboarding_defaults": "https://github.com/mozilla-firefox/firefox/blob/FIREFOX_157_0_RELEASE/browser/app/profile/firefox.js", + "prelaunch_memory_policy": "https://github.com/mozilla-firefox/firefox/blob/FIREFOX_157_0_RELEASE/dom/ipc/PreallocatedProcessManager.cpp", + "prelaunch_pool_size": "https://github.com/mozilla-firefox/firefox/blob/FIREFOX_157_0_RELEASE/modules/libpref/init/StaticPrefList.yaml", + "preferences_policy_implementation": "https://github.com/mozilla-firefox/firefox/blob/FIREFOX_157_0_RELEASE/browser/components/enterprisepolicies/Policies.sys.mjs", + "onboarding_behavior": "https://github.com/mozilla-firefox/firefox/blob/FIREFOX_157_0_RELEASE/browser/components/asrouter/docs/first-run.md" + } +} diff --git a/desktop/p105/hw-display b/desktop/p105/hw-display new file mode 100755 index 0000000..aa28b64 --- /dev/null +++ b/desktop/p105/hw-display @@ -0,0 +1,12 @@ +#!/bin/bash +# Name the sole kernel backlight for the shell's physical-key observer. +set -euo pipefail +base=${IPADLINUX_BACKLIGHT_DIR:-/sys/class/backlight} +selected='' +for device in "$base"/*; do + [[ -d $device && -r $device/brightness && -r $device/max_brightness ]] || continue + [[ -z $selected ]] || exit 1 + selected=${device##*/} +done +[[ -n $selected ]] || exit 1 +printf '%s\n' "$selected" diff --git a/desktop/p105/labwc-autostart b/desktop/p105/labwc-autostart new file mode 100755 index 0000000..63eb5d2 --- /dev/null +++ b/desktop/p105/labwc-autostart @@ -0,0 +1,10 @@ +#!/bin/bash +set -u +/usr/libexec/omarchy-wtype -M alt -M logo h -m logo -m alt > "$XDG_RUNTIME_DIR/omarchy-startup-cursor.log" 2>&1 || true +/usr/local/libexec/arch-ipad-scale-output > "$XDG_RUNTIME_DIR/arch-ipad-display.log" 2>&1 || true +systemctl --user import-environment WAYLAND_DISPLAY XDG_CURRENT_DESKTOP XDG_SESSION_TYPE \ + > "$XDG_RUNTIME_DIR/arch-ipad-user-env.log" 2>&1 +export OMARCHY_PATH="$HOME/.local/share/omarchy-ipad" +printf '0\n' > "$XDG_RUNTIME_DIR/omarchy-keyboard-visible" +"$OMARCHY_PATH/bin/omarchy-keyboard" run > "$XDG_RUNTIME_DIR/omarchy-keyboard.log" 2>&1 & +qs -p "$OMARCHY_PATH/shell/shell.qml" > "$XDG_RUNTIME_DIR/omarchy-shell.log" 2>&1 & diff --git a/desktop/p105/launch-browser b/desktop/p105/launch-browser new file mode 100755 index 0000000..71befa5 --- /dev/null +++ b/desktop/p105/launch-browser @@ -0,0 +1,12 @@ +#!/bin/bash +# Labwc adapter for the browser installed by the P105 package profile. +set -euo pipefail +arguments=() +for argument in "$@"; do + if [[ $argument == --private ]]; then + arguments+=(--private-window) + else + arguments+=("$argument") + fi +done +exec /usr/bin/firefox "${arguments[@]}" diff --git a/desktop/p105/launch-terminal b/desktop/p105/launch-terminal new file mode 100755 index 0000000..5007395 --- /dev/null +++ b/desktop/p105/launch-terminal @@ -0,0 +1,4 @@ +#!/bin/bash +# Enter the native Wayland terminal without a compositor-specific launcher. +set -euo pipefail +exec /usr/bin/foot "$@" diff --git a/desktop/p105/launch-webapp b/desktop/p105/launch-webapp new file mode 100755 index 0000000..d62979b --- /dev/null +++ b/desktop/p105/launch-webapp @@ -0,0 +1,4 @@ +#!/bin/bash +# Open Omarchy's learning links with the installed browser. +set -euo pipefail +exec /usr/local/bin/omarchy-launch-browser "$@" diff --git a/desktop/p105/login-session b/desktop/p105/login-session new file mode 100755 index 0000000..14fa9c3 --- /dev/null +++ b/desktop/p105/login-session @@ -0,0 +1,22 @@ +#!/bin/bash +# Enter from the tablet user's PAM/logind console session. +set -euo pipefail +[[ $(tty) == /dev/tty1 && ${USER:-} == tablet && -z ${SSH_CONNECTION:-} ]] || exit 1 +[[ -n ${XDG_SESSION_ID:-} && -d ${XDG_RUNTIME_DIR:-/nonexistent} && -O $XDG_RUNTIME_DIR ]] || { + echo 'No active logind session/runtime directory' >&2 + exit 1 +} +[[ -S $XDG_RUNTIME_DIR/bus && -O $XDG_RUNTIME_DIR/bus ]] || { + echo 'No PAM/systemd user D-Bus socket' >&2 + exit 1 +} +export DBUS_SESSION_BUS_ADDRESS="unix:path=$XDG_RUNTIME_DIR/bus" +export OMARCHY_PATH="$HOME/.local/share/omarchy-ipad" +export XDG_SESSION_TYPE=wayland XDG_CURRENT_DESKTOP=labwc +export OMARCHY_TOUCH_MODE=1 +export WLR_RENDERER=pixman QT_QUICK_BACKEND=software QT_QPA_PLATFORM=wayland +export LIBGL_ALWAYS_SOFTWARE=1 LIBSEAT_BACKEND=seatd MOZ_ENABLE_WAYLAND=1 +export PATH=/usr/local/bin:/usr/bin +systemctl --user import-environment DBUS_SESSION_BUS_ADDRESS XDG_SESSION_TYPE XDG_CURRENT_DESKTOP +/usr/local/libexec/arch-ipad-first-theme +exec /usr/bin/labwc diff --git a/desktop/p105/packages/omacalc/PKGBUILD b/desktop/p105/packages/omacalc/PKGBUILD new file mode 100644 index 0000000..3bef396 --- /dev/null +++ b/desktop/p105/packages/omacalc/PKGBUILD @@ -0,0 +1,37 @@ +# P105 build of Omacalc's pinned upstream source. +pkgname=omacalc +pkgver=0.3.1 +pkgrel=1 +pkgdesc="Omarchy calculator" +arch=('armv7h') +url='https://github.com/omacom/omacalc' +license=('MIT' 'OFL-1.1') +depends=('qt6-base' 'qt6-declarative' 'xdg-desktop-portal') +makedepends=('base-devel') +_commit=573d7106f5c6c0225864eb8634ae37ddcb657f63 +source=("$pkgname-$pkgver.tar.gz::https://codeload.github.com/omacom/omacalc/tar.gz/$_commit") +sha256sums=('6a4ad731acb4f68226b975e1b817b2907fea1534d52d1e769564a76a48f3eafc') + +build() { + cd "$srcdir/$pkgname-$_commit" + mkdir -p build + cd build + qmake6 ../omacalc.pro + make + LC_ALL=C readelf -h omacalc | grep -q 'Machine:.*ARM' + LC_ALL=C readelf -h omacalc | grep -q 'hard-float ABI' +} + +check() { + cd "$srcdir/$pkgname-$_commit" + QT_QPA_PLATFORM=offscreen bin/test +} + +package() { + cd "$srcdir/$pkgname-$_commit" + install -Dm755 build/omacalc "$pkgdir/usr/bin/omacalc" + install -Dm644 pkgbuild/omacalc.desktop "$pkgdir/usr/share/applications/omacalc.desktop" + install -Dm644 pkgbuild/omacalc.svg "$pkgdir/usr/share/icons/hicolor/scalable/apps/omacalc.svg" + install -Dm644 LICENSE "$pkgdir/usr/share/licenses/omacalc/LICENSE" + install -Dm644 fonts/OFL.txt "$pkgdir/usr/share/licenses/omacalc/OFL.txt" +} diff --git a/desktop/p105/packages/omafox/PKGBUILD b/desktop/p105/packages/omafox/PKGBUILD new file mode 100644 index 0000000..add695b --- /dev/null +++ b/desktop/p105/packages/omafox/PKGBUILD @@ -0,0 +1,28 @@ +# Maintainer: Fabio Cantoni + +pkgname=omafox +pkgver=1.1.0 +pkgrel=1 +pkgdesc='Synchronize Firefox and Zen Browser themes with Omarchy' +arch=('armv7h') +url='https://github.com/cover/omafox' +license=('MIT') +depends=('bash' 'glibc') +optdepends=( + 'firefox: runs the separately distributed Omafox extension' + 'zen-browser-bin: runs the separately distributed Omafox extension with Zen integration' +) +source=("$pkgname-$pkgver.tar.gz::$url/archive/refs/tags/v$pkgver.tar.gz") +sha256sums=('b610174e19edf2940127761f2883a14e5ba8644bc42c75ead8148305c7caa4f9') + +build() { + cd "$srcdir/${pkgname}-${pkgver}" + make + LC_ALL=C readelf -h build/omafox-native-host | grep -q 'Machine:.*ARM' + LC_ALL=C readelf -h build/omafox-native-host | grep -q 'hard-float ABI' +} + +package() { + cd "$srcdir/${pkgname}-${pkgver}" + make DESTDIR="$pkgdir" PREFIX=/usr install +} diff --git a/desktop/p105/scale-output b/desktop/p105/scale-output new file mode 100755 index 0000000..cf80d23 --- /dev/null +++ b/desktop/p105/scale-output @@ -0,0 +1,11 @@ +#!/bin/bash +# Preserve the P105 panel's native timing and use one logical pixel per pixel. +set -euo pipefail +state=$(wlr-randr) +mapfile -t outputs < <(awk '/^[^[:space:]]/ {print $1}' <<< "$state") +if (( ${#outputs[@]} != 1 )) || ! grep -q ' Enabled: yes' <<< "$state" || + ! grep -Eq '^[[:space:]]+(768x1024|1024x768) px, .*current' <<< "$state"; then + echo 'P105 native single-panel mode unavailable; scale left unchanged' >&2 + exit 0 +fi +wlr-randr --output "${outputs[0]}" --scale 1 diff --git a/desktop/p105/sign-apps b/desktop/p105/sign-apps new file mode 100755 index 0000000..d90a269 --- /dev/null +++ b/desktop/p105/sign-apps @@ -0,0 +1,80 @@ +#!/usr/bin/env python3 +"""Sign verified local ARMv7 packages with a temporary key; do not install them.""" + +import argparse +import hashlib +import json +import os +from pathlib import Path +import shutil +import subprocess +import tempfile + + +def sha(path): + return hashlib.sha256(Path(path).read_bytes()).hexdigest() + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--apps", type=Path, required=True, help="output from p105/build-apps") + parser.add_argument("--output", type=Path, required=True) + args = parser.parse_args() + apps, output = args.apps.resolve(), args.output.resolve() + if output.exists(): + parser.error("Choose a new signed-package output directory") + if not shutil.which("gpg") or not shutil.which("gpgconf"): + parser.error("GnuPG is required") + try: + record = json.loads((apps / "manifest.json").read_text()) + if (record.get("schema"), record.get("architecture")) != (1, "armv7h"): + raise ValueError("Applications need an ARMv7h build receipt") + files = {path.relative_to(apps).as_posix(): sha(path) + for path in sorted(apps.rglob("*")) + if path.is_file() and not path.is_symlink() and path != apps / "manifest.json"} + if files != record["files"]: + raise ValueError("Application files differ from their build receipt") + if set(record["components"]) != {"omacalc", "omafox"}: + raise ValueError("Expected the P105 Omacalc and Omafox package set") + output.parent.mkdir(parents=True, exist_ok=True) + with tempfile.TemporaryDirectory(prefix="p105-signing-") as key_directory: + key_home = Path(key_directory) + command = ["gpg", "--homedir", str(key_home), "--batch", "--pinentry-mode", "loopback", "--passphrase", ""] + try: + subprocess.run([*command, "--quick-generate-key", "Omarchy P105 local build", "ed25519", "sign", "0"], check=True) + listing = subprocess.check_output([*command, "--with-colons", "--list-secret-keys"], text=True) + fingerprint = next(line.split(":")[9] for line in listing.splitlines() if line.startswith("fpr:")) + with tempfile.TemporaryDirectory(prefix=".p105-signed-", dir=output.parent) as export_work: + result = Path(export_work) / "result" + result.mkdir() + public_key = result / "local-build-public.asc" + public_key.write_bytes(subprocess.check_output([*command, "--armor", "--export", fingerprint])) + packages = {} + for name, component in record["components"].items(): + source = (apps / component["package"]).resolve() + if not source.is_relative_to(apps) or sha(source) != component["package_sha256"]: + raise ValueError("Package differs from its build receipt") + package = result / source.name + shutil.copy2(source, package) + signature = result / (package.name + ".sig") + subprocess.run([*command, "--local-user", fingerprint, "--detach-sign", "--output", str(signature), str(package)], check=True) + subprocess.run([*command, "--verify", str(signature), str(package)], check=True) + packages[name] = package.name + receipt = { + "schema": 1, "architecture": "armv7h", "key_fingerprint": fingerprint, + "key_scope": "Local P105 source builds; private signing key deleted after this invocation", + "apps_manifest_sha256": sha(apps / "manifest.json"), "packages": packages, + "files": {path.name: sha(path) for path in sorted(result.iterdir())}, + } + (result / "manifest.json").write_text(json.dumps(receipt, indent=2) + "\n") + os.replace(result, output) + finally: + subprocess.run(["gpgconf", "--homedir", str(key_home), "--kill", "gpg-agent"], check=False) + print("Signed local ARMv7h packages:", output) + print("Public build-key fingerprint:", fingerprint) + except (OSError, ValueError, KeyError, StopIteration, subprocess.CalledProcessError) as error: + parser.error(str(error)) + + +if __name__ == "__main__": + main() diff --git a/desktop/p105/smoke-headless b/desktop/p105/smoke-headless new file mode 100755 index 0000000..388f814 --- /dev/null +++ b/desktop/p105/smoke-headless @@ -0,0 +1,135 @@ +#!/usr/bin/env python3 +"""Exercise an installed P105 desktop on a temporary headless Wayland display.""" + +import argparse +import json +import os +from pathlib import Path +import shutil +import signal +import stat +import subprocess +import sys +import tempfile +import time + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--stage", type=Path, required=True) + parser.add_argument("--output", type=Path, required=True) + parser.add_argument("--in-session", action="store_true", help=argparse.SUPPRESS) + args = parser.parse_args() + if os.geteuid() == 0: + parser.error("Run as an unprivileged user in the prepared ARMv7h container") + for command in ("labwc", "qs", "grim", "wlr-randr", "omarchy-shell", "dbus-run-session"): + if not shutil.which(command): + parser.error("Install the staged desktop and its packages first: " + command) + if not args.in_session: + return subprocess.call(["dbus-run-session", "--", sys.executable, str(Path(__file__).resolve()), + "--stage", str(args.stage.resolve()), "--output", str(args.output.resolve()), "--in-session"]) + output = args.output.resolve() + if output.exists(): + parser.error("Choose a new output directory") + output.mkdir(parents=True) + result = {"schema": 1, "test": "headless P105 desktop", "physical_hardware": False, "passed": False} + try: + with tempfile.TemporaryDirectory(prefix="p105-ui-smoke-") as temporary: + work = Path(temporary) + home, runtime = work / "home", work / "runtime" + shutil.copytree(args.stage.resolve() / "home/tablet", home, symlinks=True) + runtime.mkdir(mode=0o700) + environment = { + **os.environ, "HOME": str(home), "XDG_RUNTIME_DIR": str(runtime), + "XDG_CONFIG_HOME": str(home / ".config"), "XDG_CACHE_HOME": str(home / ".cache"), + "XDG_DATA_HOME": str(home / ".local/share"), "XDG_STATE_HOME": str(home / ".local/state"), + "OMARCHY_PATH": str(home / ".local/share/omarchy-ipad"), "OMARCHY_TOUCH_MODE": "1", + "XDG_SESSION_TYPE": "wayland", "XDG_CURRENT_DESKTOP": "labwc", "PATH": "/usr/local/bin:/usr/bin", + "WLR_BACKENDS": "headless", "WLR_HEADLESS_OUTPUTS": "1", "WLR_RENDERER": "pixman", + "QT_QUICK_BACKEND": "software", "QT_QPA_PLATFORM": "wayland", "LIBGL_ALWAYS_SOFTWARE": "1", + "OMARCHY_SHELL_IPC_TIMEOUT": "15s", + } + subprocess.run(["dbus-update-activation-environment", "HOME", "XDG_RUNTIME_DIR", "XDG_CONFIG_HOME", + "XDG_CACHE_HOME", "XDG_DATA_HOME", "XDG_STATE_HOME", "XDG_CURRENT_DESKTOP"], + env=environment, check=True) + with (output / "first-theme.log").open("w") as log: + subprocess.run(["/usr/local/libexec/arch-ipad-first-theme"], env=environment, cwd=home, + stdout=log, stderr=subprocess.STDOUT, timeout=45, check=True) + with (output / "labwc.log").open("w") as log: + compositor = subprocess.Popen(["labwc", "-C", str(home / ".config/labwc"), "-d"], + env=environment, cwd=home, stdout=log, stderr=subprocess.STDOUT, + start_new_session=True) + try: + deadline = time.monotonic() + 90 + while time.monotonic() < deadline: + if compositor.poll() is not None: + raise RuntimeError("Labwc exited before its Wayland socket was ready") + sockets = [path for path in runtime.glob("wayland-*") if stat.S_ISSOCK(path.stat().st_mode)] + if sockets: + environment["WAYLAND_DISPLAY"] = sockets[0].name + break + time.sleep(0.25) + else: + raise RuntimeError("Headless Wayland socket did not appear") + subprocess.run(["dbus-update-activation-environment", "WAYLAND_DISPLAY", "QT_QPA_PLATFORM"], + env=environment, check=True) + state = subprocess.check_output(["wlr-randr"], env=environment, text=True, timeout=10) + name = next(line.split()[0] for line in state.splitlines() if line and not line[0].isspace()) + subprocess.run(["wlr-randr", "--output", name, "--custom-mode", "768x1024@60Hz"], + env=environment, timeout=10, check=True) + result["output"] = subprocess.check_output(["wlr-randr"], env=environment, text=True, timeout=10) + while time.monotonic() < deadline: + ping = subprocess.run(["omarchy-shell", "shell", "ping"], env=environment, + text=True, capture_output=True, timeout=20) + if ping.returncode == 0 and ping.stdout.strip() == "ok": + break + if compositor.poll() is not None: + raise RuntimeError("Labwc exited while loading the shell") + time.sleep(0.5) + else: + raise RuntimeError("Omarchy shell did not answer IPC: " + ping.stderr.strip()) + time.sleep(8) + subprocess.run(["grim", str(output / "desktop.png")], env=environment, timeout=10, check=True) + subprocess.run(["grim", "-g", "0,96 768x832", str(output / "desktop-center.png")], + env=environment, timeout=10, check=True) + subprocess.run(["omarchy-shell", "omarchy.dock", "apps"], env=environment, timeout=20, check=True) + time.sleep(3) + subprocess.run(["grim", str(output / "apps.png")], env=environment, timeout=10, check=True) + subprocess.run(["omarchy-shell", "omarchy.dock", "apps"], env=environment, timeout=20, check=True) + subprocess.run(["omarchy-shell", "shell", "summon", "omarchy.image-picker", '{"source":"themes"}'], + env=environment, timeout=20, check=True) + theme_deadline = time.monotonic() + 40 + while time.monotonic() < theme_deadline: + time.sleep(2) + subprocess.run(["grim", "-g", "0,96 768x832", str(output / "themes-center.png")], + env=environment, timeout=10, check=True) + if (output / "themes-center.png").read_bytes() != (output / "desktop-center.png").read_bytes(): + break + else: + raise RuntimeError("Theme picker did not change the viewport") + subprocess.run(["grim", str(output / "themes.png")], env=environment, timeout=10, check=True) + result["plugins"] = json.loads(subprocess.check_output(["omarchy-shell", "shell", "listPlugins"], + env=environment, text=True, timeout=10)) + result["shell_ping"] = "ok" + result["passed"] = True + finally: + if compositor.poll() is None: + os.killpg(compositor.pid, signal.SIGTERM) + try: + compositor.wait(timeout=5) + except subprocess.TimeoutExpired: + os.killpg(compositor.pid, signal.SIGKILL) + compositor.wait() + for log_path in runtime.glob("*.log"): + shutil.copy2(log_path, output / log_path.name) + except (OSError, ValueError, RuntimeError, StopIteration, subprocess.SubprocessError) as error: + result["error"] = str(error) + (output / "result.json").write_text(json.dumps(result, indent=2) + "\n") + print("Headless desktop", "passed:" if result["passed"] else "failed:", output) + if not result["passed"]: + print(result.get("error", "Unknown failure"), file=sys.stderr) + return 0 if result["passed"] else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/desktop/p105/stage.py b/desktop/p105/stage.py new file mode 100644 index 0000000..659a73b --- /dev/null +++ b/desktop/p105/stage.py @@ -0,0 +1,299 @@ +#!/usr/bin/env python3 +"""Stage Omarchy A's P105 desktop into a new directory.""" + +import argparse +import hashlib +import json +import os +from pathlib import Path +import shutil +import struct +import subprocess +import sys +import xml.etree.ElementTree as ET + +HERE = Path(__file__).resolve().parent +DESKTOP = HERE.parent +PROJECT = DESKTOP.parent +PAYLOAD = Path("home/tablet/.local/share/omarchy-ipad") +KEYBOARD = PAYLOAD / "trials/wvkbd-mobintl-v0.20-font18-special15-state" +MANIFEST = "arch-ipad-ui-manifest.json" +RECEIPT = "p105-desktop.json" + + +def sha(path): + return hashlib.sha256(Path(path).read_bytes()).hexdigest() + + +def check_armhf(path, label="Keyboard"): + """Check the target ABI without executing the binary on the build host.""" + header = Path(path).read_bytes()[:52] + if len(header) != 52 or header[:6] != b"\x7fELF\x01\x01": + raise ValueError(label + " must be a little-endian ELF32 ARM executable") + if struct.unpack_from("&2; exit 2; } +case "$1" in + shutdown) exec /usr/bin/systemctl poweroff --no-wall --no-ask-password ;; + *) echo 'Usage: omarchy-system shutdown' >&2; exit 2 ;; +esac diff --git a/docs/p105-validation.md b/docs/p105-validation.md new file mode 100644 index 0000000..672d62a --- /dev/null +++ b/docs/p105-validation.md @@ -0,0 +1,92 @@ +# P105 native desktop validation + +The phase 4 contribution builds the original iPad mini Wi-Fi desktop through +Omarchy A's own producers. Its target is Arch Linux ARM `armv7h`, with the +P105 runtime supplied by hardware 0.1.11. A host supplies the NFS root for the +entire Linux session. + +## Build and artifact checks on 27 October 2026 + +The candidate `0.1.1-p105.1` was assembled from the pinned official ARMv7 +bootstrap and a synchronized set of signed Arch packages. The producer +checked detached package signatures and retained the repository databases, +resolved package records and package inventory. Source builds ran as an +unprivileged account inside the disposable build environment. + +| Check | Result | +| --- | --- | +| Runtime architecture | ARM ELF32 hard float for the desktop and systemd | +| Desktop components | Rebuilt keyboard, Labwc 0.20.2, cursor helper, Omafox and Omacalc | +| ABI pairing | Labwc and wlroots 0.20.2 from the same package snapshot | +| Clean root | 81,542 archive members; 2.29 GiB expanded file data | +| Public artifact | Complete catalog and compatibility receipt verified | +| Owner data | Separate scan passed over 65,666 regular files using the known private inputs | +| Finished-root desktop | Headless Cortex-A9 session passed at 768 × 1024 and scale 1 | +| Desktop interaction | App grid and theme carousel responded; screenshots retained | +| Experimental privilege policy | UID 1000 `tablet`, automatic console login and passwordless sudo; package sudo ownership and mode preserved | + +The headless test runs the finished archive in its own disposable container. +Its temporary emulator and machine identity remain in that validation +container. Initial checks through a nested emulated chroot hit a QEMU crash; +direct execution of the artifact passed. The public root's archive bytes +remain unchanged by validation. + +The Omarchy A suite runs 95 tests on Python 3.9.6 and 3.14.6, with 91 passing +and four skipped on this host. The merged CLI +passes 225 tests on both versions. Minimal Linux's merged suite passes 56. +The editable workspace starts with the compatible P105 dependency pair. + +## Source and upstream integration + +The desktop profile came from the source preserved during phase 1. Original +checkout heads and Git status still match that snapshot. The contribution +starts at Omarchy A main `3d1a09d4b1`; CLI and Minimal Linux integrate their +latest shared-cache and experimental RAM workflow changes. Their local P105 +commits supply the workspace submodule pins. Publish those companion commits +before publishing parent commits that reference them. + +Existing README text is preserved. README changes add the P105 sections and +instructions. J71b keeps its producer and release path; a separate P105 +workflow checks the profiles and can build the NFS artifact on manual dispatch. + +## Physical qualification + +The candidate booted twice on the original iPad mini Wi-Fi through the installed +Orchard CLI and its NFS handoff. Each test started from iOS after a clean Linux +shutdown and had a distinct Linux boot identity. All five source checkouts and +the old workspace were denied to the host Python process during both boots. +The new root lives on a separate case-sensitive host image. The prior working +export is retained. +Owner Wi-Fi and SSH inputs were applied privately after the public archive +passed its audit. + +| Device check | Measured result | +| --- | --- | +| Running system | Arch Linux ARM, `armv7l`, kernel `6.12.0`, release `0.1.1-p105.1` | +| Root and packages | New native NFS root; package inventory exactly matches the release | +| System and user managers | Zero failed units on both boots | +| Hardware runtime | Platform, touch, keys and zram active on both boots; all eight payload checks passed each time | +| Recovery | One USB serial reader in the current root on each boot; rescue executable verified in RAM | +| Storage fence | Flash controller disabled and MTD inventory empty on both boots | +| Desktop | Physical 768 × 1024 frames captured with the complete shell after both boots | +| Owner input | Home opened the app grid; Omacalc displayed 4 for 2 + 2 during the first session | +| Network password | Owner entered the password with the on-screen keyboard and connected during the first session | +| Network access | Saved Wi-Fi reconnected after reboot; certificate-verified HTTPS passed in each session | +| LAN SSH | Existing host identity and authorized owner key worked for `tablet` after both boots; sudo passed | + +The shell took time to finish drawing after system services reported ready, +with partial bars visible in the first captures. + +Touch latency and the intermittent calibration startup failure from the earlier +hardware qualification remain open. Both native desktop boots started their +touch service without intervention. Automatic time synchronization remains +open; `systemd-timesyncd` was inactive in the first session. Bluetooth pairing and audio playback +await their own device tests. + +The tested public root has SHA-256 +`66de9098b3b12caccc3ca21d2e8b5b3723afecfeded75a8cce5acc95e53f4e0c`. +Its local qualification receipt records the device results against that hash. +The producer marks each newly built candidate as awaiting physical testing. + +Build receipts and test evidence remain in the owner's local phase 4 directory. +See [the P105 build instructions](../desktop/p105/README.md). diff --git a/hardware b/hardware index d60da6b..407c910 160000 --- a/hardware +++ b/hardware @@ -1 +1 @@ -Subproject commit d60da6b31a07f84a76fe24df27fcd398e60363ee +Subproject commit 407c9101c0066ecdfa1a1ad2750ef91947450141 diff --git a/release/p105/__init__.py b/release/p105/__init__.py new file mode 100644 index 0000000..c897647 --- /dev/null +++ b/release/p105/__init__.py @@ -0,0 +1 @@ +"""P105 ARMv7 desktop artifacts for a host-served NFS root.""" diff --git a/release/p105/audit.py b/release/p105/audit.py new file mode 100644 index 0000000..6e30d86 --- /dev/null +++ b/release/p105/audit.py @@ -0,0 +1,145 @@ +"""Audit the finished public P105 root and its compatibility receipt.""" +import argparse +import hashlib +import json +from pathlib import Path, PurePosixPath +import posixpath +import re +import tarfile + +FORBIDDEN = { + 'lib/firmware/mtcal.bin', 'usr/lib/firmware/mtcal.bin', + 'usr/lib/firmware/P105.mtprops', 'usr/lib/firmware/apple/sgx543.fw', + 'usr/lib/firmware/brcm/brcmfmac4334.bin', 'usr/lib/firmware/brcm/BCM4334B0.hcd', + 'etc/orchard/p105.json', 'root/.ssh/authorized_keys', +} + + +def sha(path): + digest=hashlib.sha256() + with Path(path).open('rb') as stream: + for chunk in iter(lambda:stream.read(1024*1024),b''): + digest.update(chunk) + return digest.hexdigest() + + +def name(value): + path=PurePosixPath(value) + if path.is_absolute() or '..' in path.parts: + raise ValueError('Unsafe P105 root path: '+value) + return str(path) + + +def inventory(path): + catalog={} + captured={} + wanted={'etc/passwd','etc/shadow','etc/machine-id','etc/cascadia/systemd-root', + 'etc/cascadia/p105-hardware-input.json','etc/sudoers.d/90-tablet-passwordless', + 'home/tablet/.config/labwc/autostart','home/tablet/.config/labwc/rc.xml', + 'usr/local/libexec/arch-ipad-login-session'} + with tarfile.open(path,'r:gz') as archive: + for member in archive: + key=name(member.name) + if key in catalog: + raise ValueError('Duplicate P105 root member: '+key) + if member.islnk() or not (member.isfile() or member.isdir() or member.issym()): + raise ValueError('Unsupported P105 root member: '+key) + if (key in FORBIDDEN or key.startswith(('root/.ssh/','home/tablet/.ssh/', + 'etc/NetworkManager/system-connections/')) or + key.startswith('etc/ssh/ssh_host_')): + if not member.isdir(): + raise ValueError('Private input in public P105 root: '+key) + item={'mode':member.mode,'uid':member.uid,'gid':member.gid} + if member.isdir(): + item['type']='directory' + elif member.issym(): + target=member.linkname + resolved=posixpath.normpath(target.lstrip('/') if target.startswith('/') else + posixpath.join(posixpath.dirname(key),target)) + if resolved=='..' or resolved.startswith('../'): + raise ValueError('Root symlink escapes the guest: '+key) + item.update(type='symlink',target=target) + else: + digest=hashlib.sha256() + head=b'' + tail=b'' + saved=bytearray() + with archive.extractfile(member) as stream: + for block in iter(lambda:stream.read(1024*1024),b''): + digest.update(block) + if not head: + head=block[:52] + window=tail+block + # Signed programs such as gnutls-cli embed public test + # keys. Credential files are checked as text here; + # owner-specific bytes receive a separate build audit. + if not head.startswith(b'\x7fELF') and re.search(rb'-----BEGIN (?:OPENSSH|RSA|EC|DSA|PGP) PRIVATE KEY(?: BLOCK)?-----[\r\n]+[A-Za-z0-9+/=\r\n]{64,}',window): + raise ValueError('Private key in public P105 root: '+key) + tail=window[-128:] + if key in wanted: + if len(saved)+len(block)>1024*1024: + raise ValueError('Oversized P105 policy file: '+key) + saved.extend(block) + if head.startswith(b'\x7fELF'): + arm32=head[:7]==b'\x7fELF\x01\x01\x01' and head[18:20]==b'\x28\x00' + # v4l-utils supplies architecture-independent eBPF objects + # for its remote-control protocols. + bpf=head[:7]==b'\x7fELF\x02\x01\x01' and head[16:20]==b'\x01\x00\xf7\x00' + if not (arm32 or bpf): + raise ValueError('Non-ARM32 ELF in P105 root: '+key) + item.update(type='file',bytes=member.size,sha256=digest.hexdigest()) + if key in wanted: + captured[key]=bytes(saved) + catalog[key]=item + return catalog,captured + + +def audit(directory): + directory=Path(directory) + root=json.loads((directory/'rootfs.json').read_text()) + if (root.get('kind'),root.get('profile'),root.get('architecture'))!=('omarchy-a.nfs-root','p105','armv7h'): + raise ValueError('Expected a P105 ARMv7 NFS root receipt') + asset=root['root'] + if asset['file']!='rootfs.tar.gz' or asset['compression']!='gzip': + raise ValueError('Unexpected P105 root stream') + path=directory/asset['file'] + if path.stat().st_size!=asset['bytes'] or sha(path)!=asset['sha256']: + raise ValueError('P105 root differs from its receipt') + catalog,files=inventory(path) + expected=json.loads((directory/'rootfs.catalog.json').read_text()) + if catalog!=expected or sha(directory/'rootfs.catalog.json')!=root['catalog_sha256']: + raise ValueError('P105 root inventory differs from its receipt') + if len(catalog)!=asset['members'] or sum(v.get('bytes',0) for v in catalog.values())!=asset['expanded_bytes']: + raise ValueError('P105 root dimensions differ') + if files.get('etc/cascadia/systemd-root')!=b'p105-systemd-v1\n' or files.get('etc/machine-id')!=b'': + raise ValueError('P105 root has incompatible hardware policy or machine identity') + if not any(row.startswith(b'tablet:x:1000:1000:') for row in files['etc/passwd'].splitlines()): + raise ValueError('P105 root needs the tablet account at UID 1000') + sudo=catalog.get('usr/bin/sudo',{}) + if sudo.get('uid')!=0 or sudo.get('mode',0)&0o4777!=0o4755: + raise ValueError('P105 root must retain the package sudo owner and mode') + if catalog.get('etc/sudoers.d/90-tablet-passwordless',{}).get('mode')!=0o440: + raise ValueError('P105 sudo policy mode differs') + if any(row.split(b':')[1].strip(b'!*') for row in files['etc/shadow'].splitlines() if row): + raise ValueError('Password material in public P105 root') + if b'WLR_RENDERER=pixman QT_QUICK_BACKEND=software' not in files['usr/local/libexec/arch-ipad-login-session']: + raise ValueError('P105 software rendering policy differs') + hardware=json.loads(files['etc/cascadia/p105-hardware-input.json']) + for key,digest in hardware['files'].items(): + if catalog.get(key,{}).get('sha256')!=digest: + raise ValueError('P105 hardware runtime differs: '+key) + if root['compatibility']['runtime']!='p105-systemd-v1' or root['compatibility']['flash_controller']!='disabled': + raise ValueError('P105 compatibility fence differs') + print('Verified public P105 ARMv7 NFS root:',directory) + return root + + +def main(): + parser=argparse.ArgumentParser(description=__doc__) + parser.add_argument('directory',type=Path) + args=parser.parse_args() + audit(args.directory) + + +if __name__=='__main__': + main() diff --git a/release/p105/build.py b/release/p105/build.py new file mode 100644 index 0000000..40b01f1 --- /dev/null +++ b/release/p105/build.py @@ -0,0 +1,55 @@ +"""Assemble a public P105 NFS root release from the native userspace producer.""" +import argparse +import json +from pathlib import Path +import shutil + +from .audit import audit, inventory, sha + + +def build(base,output): + base,output=Path(base),Path(output) + if output.exists() or output.is_symlink(): + raise ValueError('Choose a fresh P105 release directory') + archive=base/'rootfs.tar.gz' + receipt=json.loads((base/'rootfs.tar.gz.json').read_text()) + if (receipt.get('kind'),receipt.get('profile'),receipt.get('architecture'))!=('omarchy-a.p105.base','p105','armv7h'): + raise ValueError('Expected a native P105 base receipt') + if archive.stat().st_size!=receipt['bytes'] or sha(archive)!=receipt['sha256']: + raise ValueError('P105 base stream differs from its receipt') + if sha(base/'packages.lock')!=receipt['packages_lock_sha256']: + raise ValueError('P105 package inventory differs from its receipt') + catalog,_=inventory(archive) + output.mkdir(parents=True) + for source,target in [('rootfs.tar.gz','rootfs.tar.gz'),('packages.lock','packages.lock'), + ('rootfs.tar.gz.json','build-receipt.json'),('desktop-sources.tar.gz','desktop-sources.tar.gz')]: + shutil.copy2(base/source,output/target) + (output/'rootfs.catalog.json').write_text(json.dumps(catalog,sort_keys=True,separators=(',',':'))+'\n') + record={'schema':1,'kind':'omarchy-a.nfs-root','distro':'omarchy-a','profile':'p105','architecture':'armv7h', + 'release':receipt['release'],'min_orchard':'0.1.11','protocol':1,'boot_method':'jailbreak-kdfu', + 'qualification':'pending-physical-boot','root':{'file':'rootfs.tar.gz','bytes':archive.stat().st_size, + 'sha256':receipt['sha256'],'compression':'gzip','members':len(catalog), + 'expanded_bytes':sum(v.get('bytes',0) for v in catalog.values())}, + 'catalog_sha256':sha(output/'rootfs.catalog.json'), + 'packages_lock_sha256':receipt['packages_lock_sha256'], + 'sources_sha256':sha(output/'desktop-sources.tar.gz'), + 'compatibility':{'board':'apple,p105ap','runtime':'p105-systemd-v1','kernel':'6.12.0', + 'kernel_commit':'adc218676eef25575469234709c2d87185ca223a','flash_controller':'disabled', + 'hardware_package':receipt['hardware']['package'],'hardware_overlay':receipt['hardware']['files']}, + 'desktop':{'renderer':'pixman','qtquick':'software','panel':[768,1024],'scale':1}, + 'root_service':'host NFSv3/TCP over USB for the entire session'} + (output/'rootfs.json').write_text(json.dumps(record,indent=2)+'\n') + audit(output) + return record + + +def main(): + parser=argparse.ArgumentParser(description=__doc__) + parser.add_argument('--base',type=Path,required=True,help='result directory from system/build-rootfs p105') + parser.add_argument('--output',type=Path,required=True) + args=parser.parse_args() + build(args.base,args.output) + + +if __name__=='__main__': + main() diff --git a/release/p105/prepare_nfs.py b/release/p105/prepare_nfs.py new file mode 100644 index 0000000..fda9dc4 --- /dev/null +++ b/release/p105/prepare_nfs.py @@ -0,0 +1,129 @@ +"""Expand an audited P105 root and apply explicit owner inputs for an NFS host.""" +import argparse +import json +import os +from pathlib import Path +import posixpath +import shutil +import tarfile + +from .audit import audit, name, sha + + +def extract(archive,root): + """Keep guest links inside the staging root while preserving numeric owners.""" + with tarfile.open(archive,'r:gz') as stream: + for member in stream: + key=name(member.name) + if key=='.': + os.chown(root,member.uid,member.gid) + root.chmod(member.mode) + continue + path=root/key + if not path.parent.resolve().is_relative_to(root.resolve()): + raise ValueError('Guest extraction parent escapes the root: '+key) + path.parent.mkdir(parents=True,exist_ok=True) + if member.isdir(): + if path.is_symlink(): + raise ValueError('Directory would replace a link: '+key) + path.mkdir(exist_ok=True) + os.chown(path,member.uid,member.gid) + path.chmod(member.mode) + elif member.issym(): + target=member.linkname + if target.startswith('/'): + target=posixpath.relpath(target.lstrip('/'),posixpath.dirname(key) or '.') + destination=posixpath.normpath(posixpath.join(posixpath.dirname(key),target)) + if destination=='..' or destination.startswith('../'): + raise ValueError('Guest extraction link escapes the root: '+key) + path.symlink_to(target) + os.lchown(path,member.uid,member.gid) + elif member.isfile(): + fd=os.open(path,os.O_WRONLY|os.O_CREAT|os.O_EXCL|os.O_NOFOLLOW,0o600) + with os.fdopen(fd,'wb') as output,stream.extractfile(member) as source: + shutil.copyfileobj(source,output) + os.fchown(output.fileno(),member.uid,member.gid) + os.fchmod(output.fileno(),member.mode) + else: + raise ValueError('Unsupported guest extraction member') + + +def owner_files(directory): + record=json.loads((directory/'owner-inputs.json').read_text()) + if record.get('schema')!=1 or record.get('profile')!='p105': + raise ValueError('Expected explicit P105 owner inputs') + allowed={'etc/ssh/ssh_host_ed25519_key','etc/ssh/ssh_host_ed25519_key.pub', + 'home/tablet/.ssh/authorized_keys'} + files={} + for key,digest in record['files'].items(): + if name(key)!=key or (key not in allowed and not key.startswith('etc/NetworkManager/system-connections/')): + raise ValueError('Unexpected private owner input') + source=directory/key + if (source.is_symlink() or not source.resolve().is_relative_to(directory.resolve()) or + not source.is_file() or source.stat().st_size>1024*1024 or sha(source)!=digest): + raise ValueError('Owner input differs from its receipt') + files[key]=source + return files + + +def prepare(release,destination,owner=None): + release,destination=Path(release),Path(destination).expanduser().absolute() + if destination.exists() or destination.is_symlink() or destination==Path('/'): + raise ValueError('Choose a new private NFS root') + root=audit(release) + files=owner_files(Path(owner)) if owner else {} + if os.geteuid()!=0: + raise ValueError('NFS staging needs administrator access to preserve guest UID/GID and sudo modes') + destination.mkdir(parents=True,mode=0o755) + probe=destination/'.p105-case-probe' + probe.write_bytes(b'') + sensitive=not (destination/'.P105-CASE-PROBE').exists() + probe.unlink() + if not sensitive: + destination.rmdir() + raise ValueError('Stage the Linux root on a case-sensitive filesystem') + extract(release/'rootfs.tar.gz',destination) + for key,source in files.items(): + target=destination/key + if not target.parent.resolve().is_relative_to(destination.resolve()) or target.is_symlink(): + raise ValueError('Owner destination escapes the prepared root') + target.parent.mkdir(parents=True,exist_ok=True) + shutil.copyfile(source,target) + tablet=key.startswith('home/tablet/') + os.chown(target,1000 if tablet else 0,1000 if tablet else 0) + target.chmod(0o644 if key.endswith('.pub') else 0o600) + if tablet: + os.chown(target.parent,1000,1000) + target.parent.chmod(0o700) + if 'etc/ssh/ssh_host_ed25519_key' in files: + config=destination/'etc/ssh/sshd_config.d/10-p105-owner.conf' + config.parent.mkdir(parents=True,exist_ok=True) + config.write_text('HostKey /etc/ssh/ssh_host_ed25519_key\nPasswordAuthentication no\n' + 'KbdInteractiveAuthentication no\nPubkeyAuthentication yes\n' + 'PermitRootLogin prohibit-password\nAllowUsers root tablet\nUsePAM yes\n') + config.chmod(0o644) + wants=destination/'etc/systemd/system/multi-user.target.wants' + wants.mkdir(parents=True,exist_ok=True) + (wants/'sshd.service').symlink_to('../../../../usr/lib/systemd/system/sshd.service') + (destination/'etc/hostname').write_text('p105-omarchy\n') + (destination/'etc/hosts').write_text('127.0.0.1 localhost\n::1 localhost\n127.0.1.1 p105-omarchy\n') + result={'schema':1,'private':True,'publishable':False,'profile':'p105','release':root['release'], + 'public_root_sha256':root['root']['sha256'],'owner_files':list(files), + 'link_policy':'guest absolute links normalized to equivalent relative staging links'} + receipt=destination.parent/(destination.name+'.private.json') + receipt.write_text(json.dumps(result,indent=2)+'\n');receipt.chmod(0o600) + print('Prepared private P105 NFS root:',destination) + return result + + +def main(): + parser=argparse.ArgumentParser(description=__doc__) + parser.add_argument('--release',type=Path,required=True) + parser.add_argument('--destination',type=Path,required=True) + parser.add_argument('--owner-inputs',type=Path) + args=parser.parse_args() + prepare(args.release,args.destination,args.owner_inputs) + + +if __name__=='__main__': + main() diff --git a/release/p105/smoke_desktop.py b/release/p105/smoke_desktop.py new file mode 100644 index 0000000..7b9ad92 --- /dev/null +++ b/release/p105/smoke_desktop.py @@ -0,0 +1,69 @@ +"""Exercise a finished P105 artifact in a disposable ARMv7 desktop session.""" +import argparse +import json +import os +from pathlib import Path +import re +import secrets +import shutil +import subprocess + +from .audit import audit + +PROJECT=Path(__file__).resolve().parents[2] + + +def smoke(release,builder_image,output): + release,output=Path(release),Path(output).absolute() + if output.exists() or output.is_symlink(): + raise ValueError('Choose a fresh P105 desktop validation directory') + if not re.fullmatch(r'(?:sha256:)?[0-9a-f]{64}',builder_image): + raise ValueError('Provide the exact image ID from the P105 producer') + root=audit(release) + output.mkdir(parents=True) + context=output/'context';context.mkdir() + try: + os.link(release/'rootfs.tar.gz',context/'rootfs.tar.gz') + except OSError: + shutil.copy2(release/'rootfs.tar.gz',context/'rootfs.tar.gz') + shutil.copy2(PROJECT/'desktop/p105/smoke-headless',context/'smoke-headless') + (context/'Containerfile').write_text('FROM '+builder_image+' AS tools\nFROM scratch\n' + 'ADD rootfs.tar.gz /\nCOPY --from=tools /usr/libexec/omarchy-build /usr/libexec/omarchy-build\n' + 'COPY --chmod=0755 smoke-headless /usr/libexec/omarchy-build/smoke-headless\n' + 'ENTRYPOINT ["/usr/libexec/omarchy-build/with-arm"]\n') + image_id=output/'test-image.id' + with (output/'image-build.log').open('w') as log: + subprocess.run(['podman','build','--iidfile',str(image_id),'-f',str(context/'Containerfile'),str(context)], + stdout=log,stderr=subprocess.STDOUT,check=True) + image=image_id.read_text().strip() + container='p105-desktop-test-'+secrets.token_hex(8) + command=['podman','run','--name',container,'--privileged','--network','none','-e','QEMU_CPU=cortex-a9',image, + '/bin/bash','--noprofile','--norc','-c', + 'dbus-uuidgen > /etc/machine-id; exec runuser -u tablet -- python ' + '/usr/libexec/omarchy-build/smoke-headless --stage / --output /tmp/p105-desktop-validation'] + with (output/'desktop.log').open('w') as log: + process=subprocess.run(command,stdout=log,stderr=subprocess.STDOUT) + subprocess.run(['podman','cp',container+':/tmp/p105-desktop-validation',str(output/'evidence')],check=True) + subprocess.run(['podman','rm',container],check=True,stdout=subprocess.DEVNULL) + evidence=json.loads((output/'evidence/result.json').read_text()) + result={'schema':1,'profile':'p105','architecture':'armv7h','physical_hardware':False, + 'root_sha256':root['root']['sha256'],'passed':process.returncode==0 and evidence.get('passed') is True, + 'test_image':image,'exit':process.returncode} + (output/'result.json').write_text(json.dumps(result,indent=2)+'\n') + if not result['passed']: + raise RuntimeError('P105 artifact desktop failed; inspect '+str(output/'evidence')) + print('Verified finished P105 desktop artifact:',output) + return result + + +def main(): + parser=argparse.ArgumentParser(description=__doc__) + parser.add_argument('--release',type=Path,required=True) + parser.add_argument('--builder-image',required=True) + parser.add_argument('--output',type=Path,required=True) + args=parser.parse_args() + smoke(args.release,args.builder_image,args.output) + + +if __name__=='__main__': + main() diff --git a/system/build-rootfs b/system/build-rootfs index 8a70b99..e643b56 100755 --- a/system/build-rootfs +++ b/system/build-rootfs @@ -4,6 +4,10 @@ umask 077 here=$(cd "$(dirname "$0")" && pwd) project=$(cd "$here/.." && pwd) +if [[ ${1:-} == p105 ]]; then + shift + exec python3 "$here/p105/build" "$@" +fi device=${1:-} [[ $# -gt 0 ]] && shift output='' desktop='' release='' diff --git a/system/p105/Containerfile b/system/p105/Containerfile new file mode 100644 index 0000000..6ecaf70 --- /dev/null +++ b/system/p105/Containerfile @@ -0,0 +1,17 @@ +FROM docker.io/tonistiigi/binfmt@sha256:400a4873b838d1b89194d982c45e5fb3cda4593fbfd7e08a02e76b03b21166f0 AS emulator +FROM docker.io/library/alpine@sha256:e311ae748be7f56ae5cba304bc488f0786df68ee507513f654a5cc8597cffb42 AS bootstrap +RUN apk add --no-cache build-base gnupg tar gzip +COPY bootstrap.tar.gz bootstrap.tar.gz.sig archlinuxarm-build.asc with-arm.c /inputs/ +RUN mkdir -m 700 /inputs/gnupg && \ + gpg --homedir /inputs/gnupg --batch --import /inputs/archlinuxarm-build.asc && \ + gpg --homedir /inputs/gnupg --batch --status-fd 1 --verify /inputs/bootstrap.tar.gz.sig /inputs/bootstrap.tar.gz > /inputs/signature.status && \ + grep -q '^\[GNUPG:\] VALIDSIG 68B3537F39A313B3E574D06777193F152BDBE6A6 ' /inputs/signature.status && \ + echo 'ebe0fa119dea45e3af0aa61785676bdbf417bc5f754d529b87255dbf0ca4e3f0 /inputs/bootstrap.tar.gz' | sha256sum -c - && \ + mkdir /armroot && tar -xpf /inputs/bootstrap.tar.gz -C /armroot --exclude='./dev/*' && \ + cc -static -O2 /inputs/with-arm.c -o /inputs/with-arm +FROM scratch +COPY --from=bootstrap /armroot/ / +COPY --from=bootstrap /inputs/with-arm /usr/libexec/omarchy-build/with-arm +COPY --from=emulator /usr/bin/qemu-arm /usr/libexec/omarchy-build/qemu-arm +LABEL org.orchard.target.architecture="armv7h" +ENTRYPOINT ["/usr/libexec/omarchy-build/with-arm"] diff --git a/system/p105/archlinuxarm-build.asc b/system/p105/archlinuxarm-build.asc new file mode 100644 index 0000000..c551dd1 --- /dev/null +++ b/system/p105/archlinuxarm-build.asc @@ -0,0 +1,114 @@ +-----BEGIN PGP PUBLIC KEY BLOCK----- +Version: Hockeypuck 2.2 +Comment: Hostname: + +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 +=lA9r +-----END PGP PUBLIC KEY BLOCK----- diff --git a/system/p105/bootstrap.json b/system/p105/bootstrap.json new file mode 100644 index 0000000..2458986 --- /dev/null +++ b/system/p105/bootstrap.json @@ -0,0 +1,11 @@ +{ + "schema": 1, + "architecture": "armv7h", + "sha256": "ebe0fa119dea45e3af0aa61785676bdbf417bc5f754d529b87255dbf0ca4e3f0", + "signer": "68B3537F39A313B3E574D06777193F152BDBE6A6", + "urls": [ + "https://ca.us.mirror.archlinuxarm.org/os/multi/ArchLinuxARM-2026.08-armv7-rootfs.tar.gz", + "https://ca.us.mirror.archlinuxarm.org/os/ArchLinuxARM-armv7-latest.tar.gz" + ], + "signer_reference": "https://archlinuxarm.org/about/downloads" +} diff --git a/system/p105/build b/system/p105/build new file mode 100755 index 0000000..14ecb18 --- /dev/null +++ b/system/p105/build @@ -0,0 +1,131 @@ +#!/usr/bin/env python3 +"""Build the P105 ARMv7 desktop and clean NFS root from signed Arch packages.""" +import argparse +import hashlib +from importlib.metadata import version +import json +import os +from pathlib import Path +import shutil +import subprocess +import tempfile +from urllib.request import urlopen + +HERE = Path(__file__).resolve().parent +PROJECT = HERE.parents[1] + + +def sha(path): + digest = hashlib.sha256() + with Path(path).open('rb') as stream: + for chunk in iter(lambda: stream.read(1024 * 1024), b''): + digest.update(chunk) + return digest.hexdigest() + + +def bootstrap(context, archive=None, signature=None): + pin = json.loads((HERE/'bootstrap.json').read_text()) + if bool(archive) != bool(signature): + raise ValueError('Provide the bootstrap and its detached signature together') + if archive: + if sha(archive) != pin['sha256']: + raise ValueError('ARMv7 bootstrap differs from its pin') + shutil.copyfile(archive, context/'bootstrap.tar.gz') + shutil.copyfile(signature, context/'bootstrap.tar.gz.sig') + else: + failures = [] + for url in pin['urls']: + try: + for suffix in ('', '.sig'): + with urlopen(url+suffix, timeout=60) as source, (context/('bootstrap.tar.gz'+suffix)).open('wb') as target: + shutil.copyfileobj(source, target) + if sha(context/'bootstrap.tar.gz') != pin['sha256']: + raise ValueError('ARMv7 bootstrap differs from its pin') + break + except (OSError, ValueError) as error: + failures.append(str(error)) + else: + raise ValueError('Pinned ARMv7 bootstrap is unavailable: '+'; '.join(failures)) + for name in ('archlinuxarm-build.asc', 'with-arm.c', 'Containerfile'): + shutil.copy2(HERE/name, context/name) + + +def hardware_overlay(target): + import orchard_hardware + if tuple(int(part) for part in version('orchard-hardware').split('.')[:3]) < (0, 1, 11): + raise ValueError('P105 needs hardware 0.1.11 or later') + resources = Path(orchard_hardware.__file__).parent/'runtime/p105/overlay' + shutil.copytree(resources, target) + for directory in [target,*[p for p in target.rglob('*') if p.is_dir()]]: + directory.chmod(0o755) + for path in target.rglob('*'): + if path.is_file(): + path.chmod(0o755 if path.read_bytes().startswith(b'#!') else 0o644) + return {'package': version('orchard-hardware'), 'runtime': 'p105-systemd-v1', + 'files': {str(p.relative_to(target)): sha(p) for p in sorted(target.rglob('*')) if p.is_file()}} + + +def build(args): + output = args.output.expanduser().absolute() + if output.exists() or output.is_symlink(): + raise ValueError('Choose a fresh P105 build output') + if args.jobs < 1 or not args.release or any(c not in 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789._-' for c in args.release): + raise ValueError('Supply a release identifier and a positive job count') + output.mkdir(parents=True) + work = output/'work' + work.mkdir() + context = work/'bootstrap-context' + context.mkdir() + bootstrap(context, args.bootstrap, args.bootstrap_signature) + overlay = work/'hardware-overlay' + hardware = hardware_overlay(overlay) + (overlay/'p105-hardware-input.json').write_text(json.dumps(hardware, indent=2)+'\n') + image_id = work/'builder-image.id' + subprocess.run(['podman', 'build', '--iidfile', str(image_id), '-f', str(context/'Containerfile'), str(context)], check=True) + image = image_id.read_text().strip() + volume = 'omarchy-p105-'+os.urandom(12).hex() + subprocess.run(['podman','volume','create',volume],check=True,stdout=subprocess.DEVNULL) + state = {'schema':1,'image':image,'volume':volume,'release':args.release,'hardware':hardware, + 'bootstrap':json.loads((HERE/'bootstrap.json').read_text()),'status':'building'} + (output/'build-state.json').write_text(json.dumps(state,indent=2)+'\n') + command = ['podman','run','--rm','--privileged','-e','QEMU_CPU=cortex-a9', + '-v',volume+':/work:rw','-v',str(PROJECT)+':/repo:ro', + '-v',str(overlay)+':/hardware:ro'] + if args.package_cache: + command += ['-v',str(args.package_cache.resolve())+':/package-cache:ro'] + command += [image,'/bin/bash','--noprofile','--norc','/repo/system/p105/build-rootfs', + '--release',args.release,'--jobs',str(args.jobs)] + subprocess.run(command,check=True) + result = output/'result' + result.mkdir() + with tempfile.TemporaryDirectory(prefix='p105-export-', dir=work) as temporary: + archive=Path(temporary)/'result.tar' + # The native wrapper mounts binfmt; a fresh container needs that setup + # even when it only exports the target architecture's completed files. + with archive.open('wb') as stream: + subprocess.run(['podman','run','--rm','--privileged','--network','none','-e','QEMU_CPU=cortex-a9', + '-v',volume+':/work:ro',image,'/usr/bin/tar','-C','/work/result','-cf','-','.'], + stdout=stream,check=True) + subprocess.run(['tar','-xf',str(archive),'-C',str(result)],check=True) + state['status']='complete' + (output/'build-state.json').write_text(json.dumps(state,indent=2)+'\n') + print('P105 userspace and desktop:',result) + + +def main(): + parser=argparse.ArgumentParser(description=__doc__) + parser.add_argument('--output',type=Path,required=True) + parser.add_argument('--release',required=True) + parser.add_argument('--bootstrap',type=Path) + parser.add_argument('--bootstrap-signature',type=Path) + parser.add_argument('--package-cache',type=Path) + parser.add_argument('--jobs',type=int,default=2) + args=parser.parse_args() + try: + build(args) + except (OSError,ValueError,subprocess.CalledProcessError) as error: + parser.exit(1,'P105 build: '+str(error)+'\n') + + +if __name__=='__main__': + main() diff --git a/system/p105/build-rootfs b/system/p105/build-rootfs new file mode 100755 index 0000000..ea1bed0 --- /dev/null +++ b/system/p105/build-rootfs @@ -0,0 +1,82 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: GPL-2.0-or-later +# Run in the disposable ARMv7 build environment, with a new /work volume. +set -euo pipefail +release= jobs=2 +while (($#)); do + case "$1" in + --release) release=$2; shift 2;; + --jobs) jobs=$2; shift 2;; + *) echo "Unknown P105 build argument: $1" >&2; exit 2;; + esac +done +[[ $EUID == 0 && $(uname -m) == armv7l && -n "$release" && ! -e /work/root ]] || exit 1 +repo=$(cd "$(dirname "$0")/../.." && pwd) +cache=() +[[ ! -d /package-cache ]] || cache=(--package-cache /package-cache) +bash "$repo/system/p105/prepare-root" --snapshot /work/snapshot "${cache[@]}" +useradd --create-home --uid 1001 --user-group --shell /bin/bash builder +install -d -o builder -g builder /work/components /work/desktop /work/upstream +runuser -u builder -- python "$repo/desktop/p105/build-keyboard" --output /work/components/keyboard --jobs "$jobs" +runuser -u builder -- python "$repo/desktop/p105/build-support" --output /work/components/support --jobs "$jobs" +runuser -u builder -- python "$repo/desktop/p105/build-apps" --output /work/components/apps +runuser -u builder -- python "$repo/desktop/p105/sign-apps" --apps /work/components/apps --output /work/components/signed-apps +commit=$(sed -n 's/^omarchy_commit=//p' "$repo/desktop/upstream.lock") +url=$(sed -n 's/^omarchy_url=//p' "$repo/desktop/upstream.lock") +runuser -u builder -- git -C /work/upstream init -q +runuser -u builder -- git -C /work/upstream fetch --depth 1 "$url" "$commit" +runuser -u builder -- git -C /work/upstream checkout -q --detach FETCH_HEAD +keyboard_hash=$(sha256sum /work/components/keyboard/wvkbd-mobintl | cut -d' ' -f1) +runuser -u builder -- python "$repo/desktop/p105/stage.py" --output /work/desktop/payload \ + --upstream /work/upstream --keyboard /work/components/keyboard/wvkbd-mobintl \ + --keyboard-sha256 "$keyboard_hash" --support /work/components/support + +# Resolve the clean runtime from the exact database used by source builds. +root=/work/root +install -d "$root/var/lib/pacman/sync" "$root/var/cache/pacman/pkg" "$root/etc" +cp /work/snapshot/sync/*.db "$root/var/lib/pacman/sync/" +mapfile -t requested < <(sed '/^# Build the patched/,$d; s/[[:space:]]*#.*$//; /^[[:space:]]*$/d' "$repo/system/p105/packages.txt") +pacman --root "$root" --gpgdir /etc/pacman.d/gnupg --cachedir /work/snapshot/cache \ + -S --noconfirm "${requested[@]}" +fingerprint=$(python -c 'import json; print(json.load(open("/work/components/signed-apps/manifest.json"))["key_fingerprint"])') +pacman-key --add /work/components/signed-apps/local-build-public.asc +pacman-key --lsign-key "$fingerprint" +mapfile -t apps < <(python -c 'import json; print("\n".join("/work/components/signed-apps/"+p for p in json.load(open("/work/components/signed-apps/manifest.json"))["packages"].values()))') +pacman --root "$root" --gpgdir /etc/pacman.d/gnupg --noconfirm -U "${apps[@]}" +useradd --root "$root" --create-home --uid 1000 --user-group --shell /bin/bash tablet +usermod --root "$root" --append --groups seat,video,input,audio,wheel tablet +usermod --root "$root" --password '*' root +usermod --root "$root" --password '*' tablet +cp -a --no-preserve=ownership /work/desktop/payload/. "$root/" +cp -a --no-preserve=ownership /hardware/. "$root/" +cp -a --no-preserve=ownership "$repo/system/p105/overlay/." "$root/" +chmod 755 "$root/usr/local/libexec/p105-zram" +chmod 440 "$root/etc/sudoers.d/90-tablet-passwordless" +install -d "$root/etc/cascadia" "$root/etc/ipadlinux" +mv "$root/p105-hardware-input.json" "$root/etc/cascadia/p105-hardware-input.json" +printf 'p105-systemd-v1\n' > "$root/etc/cascadia/systemd-root" +printf '%s\n' "$release" > "$root/etc/ipadlinux/release" +chown -R 1000:1000 "$root/home/tablet" +for unit in sleep suspend hibernate hybrid-sleep suspend-then-hibernate; do + ln -sf /dev/null "$root/etc/systemd/system/$unit.target" +done +for unit in serial-getty@ttyGS0.service systemd-oomd.service systemd-networkd.service systemd-networkd.socket systemd-networkd-wait-online.service systemd-resolved.service; do + ln -sf /dev/null "$root/etc/systemd/system/$unit" +done +systemctl --root="$root" enable getty@tty1.service NetworkManager.service bluetooth.service seatd.service p105-zram.service p105-hardware.target p105-acm-console.service +systemctl --root="$root" --global enable pipewire.socket pipewire-pulse.socket wireplumber.service +systemctl --root="$root" set-default graphical.target +: > "$root/etc/machine-id" +rm -f "$root/var/lib/dbus/machine-id" "$root/etc/ssh"/ssh_host_* +rm -rf "$root/etc/pacman.d/gnupg" +install -d /work/result +pacman --root "$root" -Q > /work/result/packages.lock +tar --hard-dereference --numeric-owner --one-file-system -C "$root" \ + --exclude='./dev/*' --exclude='./proc/*' --exclude='./sys/*' \ + --exclude='./run/*' --exclude='./tmp/*' --exclude='./var/tmp/*' \ + --exclude='./var/cache/pacman/pkg/*' --exclude='./var/log/*' \ + -cf - . | gzip -1 > /work/result/rootfs.tar.gz +python "$repo/system/p105/receipt.py" --root "$root" --result /work/result --release "$release" \ + --snapshot /work/snapshot --components /work/components --desktop /work/desktop/payload +tar -czf /work/result/desktop-sources.tar.gz -C /work components snapshot/sync snapshot/resolved.tsv snapshot/sync.sha256 snapshot/packages.lock +echo 'P105 ARMv7 root and desktop complete' diff --git a/system/p105/overlay/etc/polkit-1/rules.d/60-p105-tablet-network.rules b/system/p105/overlay/etc/polkit-1/rules.d/60-p105-tablet-network.rules new file mode 100644 index 0000000..7c45482 --- /dev/null +++ b/system/p105/overlay/etc/polkit-1/rules.d/60-p105-tablet-network.rules @@ -0,0 +1,6 @@ +polkit.addRule(function(action, subject) { + if (subject.user == "tablet" && subject.local && subject.active && + action.id.indexOf("org.freedesktop.NetworkManager.") == 0) { + return polkit.Result.YES; + } +}); diff --git a/system/p105/overlay/etc/sudoers.d/90-tablet-passwordless b/system/p105/overlay/etc/sudoers.d/90-tablet-passwordless new file mode 100644 index 0000000..027b8dd --- /dev/null +++ b/system/p105/overlay/etc/sudoers.d/90-tablet-passwordless @@ -0,0 +1 @@ +tablet ALL=(ALL:ALL) NOPASSWD: ALL diff --git a/system/p105/overlay/etc/sysctl.d/60-p105-zram.conf b/system/p105/overlay/etc/sysctl.d/60-p105-zram.conf new file mode 100644 index 0000000..29ca208 --- /dev/null +++ b/system/p105/overlay/etc/sysctl.d/60-p105-zram.conf @@ -0,0 +1,2 @@ +vm.swappiness=100 +vm.page-cluster=0 diff --git a/system/p105/overlay/etc/systemd/journald.conf.d/20-p105.conf b/system/p105/overlay/etc/systemd/journald.conf.d/20-p105.conf new file mode 100644 index 0000000..7c1a36f --- /dev/null +++ b/system/p105/overlay/etc/systemd/journald.conf.d/20-p105.conf @@ -0,0 +1,4 @@ +[Journal] +Storage=volatile +RuntimeMaxUse=4M +RuntimeKeepFree=8M diff --git a/system/p105/overlay/etc/systemd/logind.conf.d/60-p105-power.conf b/system/p105/overlay/etc/systemd/logind.conf.d/60-p105-power.conf new file mode 100644 index 0000000..4c3908a --- /dev/null +++ b/system/p105/overlay/etc/systemd/logind.conf.d/60-p105-power.conf @@ -0,0 +1,5 @@ +[Login] +HandlePowerKey=ignore +HandleSuspendKey=ignore +HandleHibernateKey=ignore +IdleAction=ignore diff --git a/system/p105/overlay/etc/systemd/sleep.conf.d/60-p105.conf b/system/p105/overlay/etc/systemd/sleep.conf.d/60-p105.conf new file mode 100644 index 0000000..02b3548 --- /dev/null +++ b/system/p105/overlay/etc/systemd/sleep.conf.d/60-p105.conf @@ -0,0 +1,5 @@ +[Sleep] +AllowSuspend=no +AllowHibernation=no +AllowHybridSleep=no +AllowSuspendThenHibernate=no diff --git a/system/p105/overlay/etc/systemd/system/getty@tty1.service.d/20-p105-platform.conf b/system/p105/overlay/etc/systemd/system/getty@tty1.service.d/20-p105-platform.conf new file mode 100644 index 0000000..a2901d4 --- /dev/null +++ b/system/p105/overlay/etc/systemd/system/getty@tty1.service.d/20-p105-platform.conf @@ -0,0 +1,3 @@ +[Unit] +Requires=p105-hardware.target p105-zram.service +After=p105-hardware.target p105-zram.service diff --git a/system/p105/overlay/etc/udev/rules.d/70-p105-backlight.rules b/system/p105/overlay/etc/udev/rules.d/70-p105-backlight.rules new file mode 100644 index 0000000..c4ec7c1 --- /dev/null +++ b/system/p105/overlay/etc/udev/rules.d/70-p105-backlight.rules @@ -0,0 +1 @@ +ACTION=="add", SUBSYSTEM=="backlight", KERNEL=="apple-pmu-wled", RUN+="/usr/bin/chgrp video /sys/class/backlight/%k/brightness", RUN+="/usr/bin/chmod g+w /sys/class/backlight/%k/brightness" diff --git a/system/p105/overlay/usr/lib/systemd/system/p105-zram.service b/system/p105/overlay/usr/lib/systemd/system/p105-zram.service new file mode 100644 index 0000000..a2d0d91 --- /dev/null +++ b/system/p105/overlay/usr/lib/systemd/system/p105-zram.service @@ -0,0 +1,10 @@ +[Unit] +Description=Compressed RAM swap for the 512 MB P105 +After=systemd-udev-trigger.service +Before=getty@tty1.service +[Service] +Type=oneshot +ExecStart=/usr/local/libexec/p105-zram +RemainAfterExit=yes +[Install] +WantedBy=multi-user.target diff --git a/system/p105/overlay/usr/local/libexec/p105-zram b/system/p105/overlay/usr/local/libexec/p105-zram new file mode 100755 index 0000000..8072466 --- /dev/null +++ b/system/p105/overlay/usr/local/libexec/p105-zram @@ -0,0 +1,10 @@ +#!/bin/sh +set -eu +[ -d /sys/block/zram0 ] +grep -q '^/dev/zram0 ' /proc/swaps && exit 0 +if [ "$(cat /sys/block/zram0/disksize)" = 0 ]; then + printf 'lz4\n' > /sys/block/zram0/comp_algorithm + printf '268435456\n' > /sys/block/zram0/disksize +fi +/usr/bin/mkswap /dev/zram0 +/usr/bin/swapon --priority 100 /dev/zram0 diff --git a/system/p105/packages.txt b/system/p105/packages.txt new file mode 100644 index 0000000..0b843a0 --- /dev/null +++ b/system/p105/packages.txt @@ -0,0 +1,69 @@ +# ARMv7h userspace. Cascadia supplies the p105 kernel and hardware support. +base +archlinuxarm-keyring +pacman +systemd +sudo +python +openssh +labwc +quickshell +qt6-base +qt6-declarative +qt6-wayland +qt6-svg +qt6-imageformats +foot +ttf-dejavu +ttf-jetbrains-mono-nerd +ttf-nerd-fonts-symbols +firefox +fastfetch +starship +seatd +networkmanager +bluez +bluez-utils +upower +grim +wlr-randr +xdg-desktop-portal +xdg-desktop-portal-wlr +pipewire +pipewire-pulse +wireplumber +socat +jq +pango +cairo +wayland +libxkbcommon +inotify-tools +libvips +wl-clipboard +imagemagick + +# Build the patched keyboard, Labwc, Omafox and Omacalc in this ARMv7h container. +base-devel +scdoc +pkgconf +patch +git +cmake +ninja +meson +wlroots0.20 +libsfdo +libxml2 +glib2 +librsvg +xcb-util-wm +xorg-xwayland +gettext +qt6-shadertools +wayland-protocols +curl +tar +gzip +xz +zstd diff --git a/system/p105/prepare-root b/system/p105/prepare-root new file mode 100755 index 0000000..911c817 --- /dev/null +++ b/system/p105/prepare-root @@ -0,0 +1,112 @@ +#!/usr/bin/env bash +# Run inside the verified Arch Linux ARM armv7h container. This changes its /. +set -euo pipefail + +snapshot= +package_cache= +packages_file=$(cd "$(dirname "$0")" && pwd)/packages.txt +while (($#)); do + case "$1" in + --snapshot) [[ $# -ge 2 ]] || exit 2; snapshot=$2; shift 2;; + --packages) [[ $# -ge 2 ]] || exit 2; packages_file=$2; shift 2;; + --package-cache) [[ $# -ge 2 ]] || exit 2; package_cache=$2; shift 2;; + *) printf 'Unknown argument: %s\n' "$1" >&2; exit 2;; + esac +done +[[ -n "$snapshot" ]] || { echo 'Usage: prepare-root --snapshot NEW_DIRECTORY [--packages FILE]' >&2; exit 2; } +[[ $EUID == 0 && $(uname -m) == armv7l ]] || { echo 'Requires root inside an ARMv7 Arch Linux ARM container' >&2; exit 1; } +[[ -e /run/.containerenv || -e /.dockerenv ]] || { echo 'Run this script inside the build container' >&2; exit 1; } +for command in pacman pacman-key pacman-conf sha256sum base64 install; do + command -v "$command" >/dev/null || { printf 'Missing command: %s\n' "$command" >&2; exit 1; } +done +[[ -f "$packages_file" && ! -e "$snapshot" ]] || { echo 'Package list must exist and snapshot directory must be new' >&2; exit 1; } +snapshot=$(realpath -m -- "$snapshot") +[[ "$snapshot" != / ]] || exit 1 +mapfile -t requested < <(sed -E 's/[[:space:]]*#.*$//; /^[[:space:]]*$/d' "$packages_file") +[[ ${#requested[@]} -gt 0 ]] || { echo 'Package list is empty' >&2; exit 1; } +for package in "${requested[@]}"; do + [[ "$package" =~ ^[a-z0-9][a-z0-9@._+-]*$ ]] || { printf 'Invalid package name: %s\n' "$package" >&2; exit 1; } + case "$package" in + linux|linux-headers|linux-lts|linux-lts-headers|linux-armv7*|linux-aarch64*|linux-rpi*|linux-raspberrypi*) + printf 'Cascadia supplies the kernel; remove %s from the package list\n' "$package" >&2; exit 1;; + esac +done + +install -d "$snapshot/cache" "$snapshot/sync" +if [[ -n "$package_cache" ]]; then + [[ -d "$package_cache" ]] || { echo 'Package cache must be an existing directory' >&2; exit 1; } + find "$package_cache" -maxdepth 1 -type f -name '*.pkg.tar.*' -exec cp -n --reflink=auto -t "$snapshot/cache" -- {} + +fi +cp -- "$packages_file" "$snapshot/packages.requested.txt" +cp -- /etc/pacman.conf "$snapshot/pacman.conf.before" +pacman -Q > "$snapshot/packages.before.lock" + +# Use official ARM repositories and retain mandatory signature verification. +# Pacman's guest downloader cannot install its syscall/filesystem sandbox under +# QEMU user emulation. The enclosing Podman container keeps its own seccomp policy. +cat > /etc/pacman.conf <<'PACMAN' +[options] +Architecture = armv7h +CheckSpace +SigLevel = Required DatabaseOptional +LocalFileSigLevel = Required +RemoteFileSigLevel = Required +DisableSandboxFilesystem +DisableSandboxSyscalls +IgnorePkg = linux linux-headers linux-lts linux-lts-headers linux-armv7 linux-armv7-headers linux-armv7-rc linux-armv7-rc-headers linux-aarch64 linux-aarch64-headers linux-rpi linux-rpi-headers linux-raspberrypi linux-raspberrypi-headers + +[core] +Include = /etc/pacman.d/mirrorlist + +[extra] +Include = /etc/pacman.d/mirrorlist + +[alarm] +Include = /etc/pacman.d/mirrorlist +PACMAN +# shellcheck disable=SC2016 # Pacman expands these repository placeholders. +printf 'Server = https://ca.us.mirror.archlinuxarm.org/$arch/$repo\n' > /etc/pacman.d/mirrorlist +[[ $(pacman-conf Architecture) == armv7h ]] || exit 1 +cp -- /etc/pacman.conf "$snapshot/pacman.conf" +cp -- /etc/pacman.d/mirrorlist "$snapshot/mirrorlist" + +# Remove the generic bootstrap kernel before any package hooks can rebuild it. +mapfile -t kernels < <(pacman -Qq | sed -nE '/^(linux|linux-headers|linux-lts|linux-lts-headers|linux-armv7(-.*)?|linux-aarch64(-.*)?|linux-rpi(-.*)?|linux-raspberrypi(-.*)?)$/p') +printf '%s\n' "${kernels[@]}" > "$snapshot/kernels.removed.txt" +if ((${#kernels[@]})); then + pacman -R --noconfirm "${kernels[@]}" +fi + +pacman-key --init +pacman-key --populate archlinuxarm +pacman -Sy --noconfirm +cp -a /var/lib/pacman/sync/*.db "$snapshot/sync/" +(cd /var/lib/pacman/sync && sha256sum ./*.db) > "$snapshot/sync.sha256" + +# Resolve, download and install against the same database files. Include every +# upgrade from the bootstrap so Qt and Quickshell stay in one package snapshot. +printf 'name\tversion\tarchitecture\tfilename\tbytes\tsha256\tpgp_signature_base64\turl\n' > "$snapshot/resolved.tsv" +pacman -Sup --noconfirm --print-format $'%n\t%v\t%a\t%f\t%s\t%h\t%g\t%l' "${requested[@]}" >> "$snapshot/resolved.tsv" +pacman -Suw --noconfirm --cachedir "$snapshot/cache" "${requested[@]}" +(cd /var/lib/pacman/sync && sha256sum --check "$snapshot/sync.sha256") + +# Keep detached signatures beside packages for independent offline verification. +while IFS=$'\t' read -r name version architecture filename bytes digest signature url; do + [[ "$name" == name ]] && continue + [[ "$architecture" == armv7h || "$architecture" == any ]] || { printf 'Unexpected package architecture: %s\n' "$architecture" >&2; exit 1; } + [[ "$filename" != */* && "$digest" =~ ^[[:xdigit:]]{64}$ && -n "$signature" ]] || { printf 'Incomplete receipt for %s\n' "$name" >&2; exit 1; } + printf '%s %s\n' "$digest" "$snapshot/cache/$filename" | sha256sum --check --status + printf '%s' "$signature" | base64 --decode > "$snapshot/cache/$filename.sig" + pacman-key --verify "$snapshot/cache/$filename.sig" "$snapshot/cache/$filename" +done < "$snapshot/resolved.tsv" + +pacman -Su --noconfirm --cachedir "$snapshot/cache" "${requested[@]}" +(cd /var/lib/pacman/sync && sha256sum --check "$snapshot/sync.sha256") +pacman -Q > "$snapshot/packages.lock" +pacman -Dk +if pacman -Qq | sed -nE '/^(linux|linux-lts|linux-armv7(-.*)?|linux-aarch64(-.*)?|linux-rpi(-.*)?|linux-raspberrypi(-.*)?)$/p' | grep -q .; then + echo 'A generic kernel remains in the prepared root' >&2 + exit 1 +fi +date -u +'%Y-%m-%dT%H:%M:%SZ' > "$snapshot/complete" +printf 'Prepared ARMv7h container; signed package receipt: %s\n' "$snapshot" diff --git a/system/p105/receipt.py b/system/p105/receipt.py new file mode 100644 index 0000000..4569e3f --- /dev/null +++ b/system/p105/receipt.py @@ -0,0 +1,45 @@ +#!/usr/bin/env python3 +"""Record the P105 root, package snapshot and source-built desktop inputs.""" +import argparse +import hashlib +import json +from pathlib import Path +import struct + + +def sha(path): + digest=hashlib.sha256() + with Path(path).open('rb') as stream: + for chunk in iter(lambda:stream.read(1024*1024),b''): + digest.update(chunk) + return digest.hexdigest() + + +def main(): + parser=argparse.ArgumentParser(description=__doc__) + for name in ('root','result','snapshot','components','desktop'): + parser.add_argument('--'+name,type=Path,required=True) + parser.add_argument('--release',required=True) + args=parser.parse_args() + binaries=['usr/lib/systemd/systemd','usr/bin/qs','usr/bin/labwc','usr/bin/omacalc', + 'usr/libexec/omarchy-wtype','home/tablet/.local/share/omarchy-ipad/trials/wvkbd-mobintl-v0.20-font18-special15-state'] + for name in binaries: + header=(args.root/name).read_bytes()[:52] + if (len(header)!=52 or header[:7]!=b'\x7fELF\x01\x01\x01' or header[18:20]!=b'\x28\x00' or + not struct.unpack_from(' +#include +#include +#include + +int main(int argc, char **argv) { + if (argc < 2) return 2; + if (mkdir("/run/omarchy-binfmt", 0700)) { + perror("create build binfmt directory"); + return 1; + } + if (mount("binfmt_misc", "/run/omarchy-binfmt", "binfmt_misc", + MS_NOSUID | MS_NODEV, 0)) { + perror("mount build binfmt"); + return 1; + } + FILE *stream = fopen("/run/omarchy-binfmt/register", "w"); + if (!stream) { perror("open build binfmt"); return 1; } + const char *entry = ":omarchy-armv7:M::" + "\\x7fELF\\x01\\x01\\x01\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x02\\x00\\x28\\x00:" + "\\xff\\xff\\xff\\xff\\xff\\xff\\xff\\x00\\xff\\xff\\xff\\xff\\xff\\xff\\xff\\xff\\xfe\\xff\\xff\\xff:" + "/usr/libexec/omarchy-build/qemu-arm:PF"; + if (fputs(entry, stream) < 0 || fclose(stream)) { + perror("register build ARM handler"); + return 1; + } + execvp(argv[1], argv + 1); + perror("execute ARM build"); + return 1; +} diff --git a/tests/test_p105_nfs.py b/tests/test_p105_nfs.py new file mode 100644 index 0000000..0ac68f4 --- /dev/null +++ b/tests/test_p105_nfs.py @@ -0,0 +1,66 @@ +"""Private P105 staging preserves guest paths and rejects unrelated owner files.""" +import hashlib +import io +import json +import os +from pathlib import Path +import tarfile +import tempfile +import unittest + +from release.p105 import prepare_nfs + + +class P105NFS(unittest.TestCase): + def test_absolute_guest_link_is_staged_as_equivalent_relative_link(self): + with tempfile.TemporaryDirectory() as temporary: + base=Path(temporary);root=base/'root';root.mkdir();archive=base/'root.tar.gz' + with tarfile.open(archive,'w:gz') as stream: + m=tarfile.TarInfo('usr/local/bin/app');m.type=tarfile.SYMTYPE + m.linkname='/home/tablet/app';m.uid=os.getuid();m.gid=os.getgid();stream.addfile(m) + prepare_nfs.extract(archive,root) + link=root/'usr/local/bin/app' + self.assertEqual(link.resolve(),root.resolve()/'home/tablet/app') + self.assertFalse(os.readlink(link).startswith('/')) + + def test_extraction_refuses_an_outside_parent_link(self): + with tempfile.TemporaryDirectory() as temporary: + base=Path(temporary);root=base/'root';root.mkdir();outside=base/'outside';outside.mkdir() + (root/'etc').symlink_to(outside,target_is_directory=True) + archive=base/'root.tar.gz' + with tarfile.open(archive,'w:gz') as stream: + m=tarfile.TarInfo('etc/file');m.size=1;stream.addfile(m,io.BytesIO(b'x')) + with self.assertRaisesRegex(ValueError,'parent escapes'): + prepare_nfs.extract(archive,root) + self.assertEqual(list(outside.iterdir()),[]) + + def record(self,root,key,data=b'owner key'): + path=root/key;path.parent.mkdir(parents=True,exist_ok=True);path.write_bytes(data) + record={'schema':1,'profile':'p105','files':{key:hashlib.sha256(data).hexdigest()}} + (root/'owner-inputs.json').write_text(json.dumps(record)) + + def test_owner_profile_accepts_an_integrity_checked_tablet_key(self): + with tempfile.TemporaryDirectory() as temporary: + root=Path(temporary);key='home/tablet/.ssh/authorized_keys';self.record(root,key) + self.assertEqual(list(prepare_nfs.owner_files(root)),[key]) + (root/key).write_text('changed') + with self.assertRaisesRegex(ValueError,'differs'): + prepare_nfs.owner_files(root) + + def test_owner_profile_cannot_copy_unrelated_system_files(self): + with tempfile.TemporaryDirectory() as temporary: + root=Path(temporary);self.record(root,'etc/sudoers') + with self.assertRaisesRegex(ValueError,'Unexpected'): + prepare_nfs.owner_files(root) + + def test_owner_profile_rejects_a_linked_parent(self): + with tempfile.TemporaryDirectory() as temporary: + base=Path(temporary);root=base/'inputs';root.mkdir();outside=base/'outside';outside.mkdir() + key='home/tablet/.ssh/authorized_keys';self.record(root,key) + path=root/'home';path.rename(outside/'home');path.symlink_to(outside/'home') + with self.assertRaisesRegex(ValueError,'differs'): + prepare_nfs.owner_files(root) + + +if __name__=='__main__': + unittest.main() diff --git a/tests/test_p105_profile.py b/tests/test_p105_profile.py new file mode 100644 index 0000000..25d3b57 --- /dev/null +++ b/tests/test_p105_profile.py @@ -0,0 +1,326 @@ +"""P105 ABI, payload integrity and build-container safeguards.""" +import contextlib +import hashlib +import importlib.util +import io +import json +import os +from pathlib import Path +import platform +import re +import shutil +import struct +import subprocess +import tempfile +import unittest + + +PROJECT = Path(__file__).resolve().parents[1] +PROFILE = PROJECT / 'desktop/p105' +PREPARE = PROJECT / 'system/p105/prepare-root' +spec = importlib.util.spec_from_file_location('p105_stage', PROFILE / 'stage.py') +stage = importlib.util.module_from_spec(spec) +spec.loader.exec_module(stage) + + +def arm_elf(machine=40, flags=0x05000400): + ident = b'\x7fELF\x01\x01\x01' + bytes(9) + return struct.pack('<16sHHIIIIIHHHHHH', ident, 3, machine, 1, 0, 52, 0, + flags, 52, 32, 1, 40, 0, 0) + + +def digest(data): + return hashlib.sha256(data).hexdigest() + + +class P105ProfileTests(unittest.TestCase): + def setUp(self): + temporary = tempfile.TemporaryDirectory() + self.addCleanup(temporary.cleanup) + self.root = Path(temporary.name) + + def payload(self): + files = { + stage.KEYBOARD.as_posix(): arm_elf(), + 'usr/bin/labwc': arm_elf(), + 'usr/bin/labwc-copy': arm_elf(), + 'usr/libexec/omarchy-wtype': arm_elf(), + 'usr/share/doc/omarchy-p105/support-build.json': b'{"schema":1}\n', + 'home/tablet/.config/labwc/autostart': b'#!/bin/sh\nquickshell\n', + } + record = { + 'schema': 1, 'profile': 'p105', 'architecture': 'armv7h', + 'keyboard_sha256': digest(files[stage.KEYBOARD.as_posix()]), + 'support_manifest_sha256': digest(files['usr/share/doc/omarchy-p105/support-build.json']), + 'symlinks': {'usr/local/bin/compositor': '../../bin/labwc'}, + } + files[stage.RECEIPT] = json.dumps(record).encode() + for name, data in files.items(): + path = self.root / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(data) + link = self.root / 'usr/local/bin/compositor' + link.parent.mkdir(parents=True) + link.symlink_to(record['symlinks']['usr/local/bin/compositor']) + (self.root / stage.MANIFEST).write_text(json.dumps({name: digest(data) for name, data in files.items()})) + + def verify(self): + with contextlib.redirect_stdout(io.StringIO()): + stage.verify(self.root) + + def rewrite_receipt(self, field, value): + path = self.root / stage.RECEIPT + record = json.loads(path.read_text()) + record[field] = value + path.write_text(json.dumps(record)) + manifest_path = self.root / stage.MANIFEST + manifest = json.loads(manifest_path.read_text()) + manifest[stage.RECEIPT] = digest(path.read_bytes()) + manifest_path.write_text(json.dumps(manifest)) + + def test_only_little_endian_elf32_arm_hard_float_is_accepted(self): + binary = self.root / 'program' + valid = arm_elf() + binary.write_bytes(valid) + stage.check_armhf(binary) + for label, content in ( + ('truncated', valid[:51]), + ('ELF64', valid[:4] + b'\x02' + valid[5:]), + ('big endian', valid[:5] + b'\x02' + valid[6:]), + ('AArch64', arm_elf(machine=183)), + ('x86', arm_elf(machine=3)), + ('unspecified float ABI', arm_elf(flags=0x05000000)), + ('soft float', arm_elf(flags=0x05000200)), + ('conflicting float flags', arm_elf(flags=0x05000600)), + ): + with self.subTest(format=label): + binary.write_bytes(content) + with self.assertRaises(ValueError): + stage.check_armhf(binary) + + def test_payload_changes_missing_files_and_added_files_are_rejected(self): + self.payload() + self.verify() + path = self.root / 'home/tablet/.config/labwc/autostart' + original = path.read_bytes() + for content in (b'changed launcher', None): + with self.subTest(content=content): + if content is None: + path.unlink() + else: + path.write_bytes(content) + with self.assertRaisesRegex(ValueError, 'files differ'): + self.verify() + path.write_bytes(original) + (self.root / 'unexpected-helper').write_bytes(b'extra') + with self.assertRaisesRegex(ValueError, 'files differ'): + self.verify() + + def test_symlink_retargeting_and_unrecorded_dangling_links_are_rejected(self): + self.payload() + link = self.root / 'usr/local/bin/compositor' + link.unlink() + # Both destinations contain identical bytes; the link itself is pinned. + link.symlink_to('../../bin/labwc-copy') + with self.assertRaisesRegex(ValueError, 'symlinks differ'): + self.verify() + link.unlink() + link.symlink_to('../../bin/labwc') + (self.root / 'unrecorded-link').symlink_to('missing-file') + with self.assertRaisesRegex(ValueError, 'symlinks differ'): + self.verify() + + def test_receipt_profile_and_component_hashes_are_checked(self): + self.payload() + original = json.loads((self.root / stage.RECEIPT).read_text()) + for field, value in (('architecture', 'aarch64'), ('profile', 'j71b'), + ('keyboard_sha256', '0' * 64), + ('support_manifest_sha256', '0' * 64)): + with self.subTest(field=field): + self.rewrite_receipt(field, value) + with self.assertRaises(ValueError): + self.verify() + self.rewrite_receipt(field, original[field]) + + def test_menu_routes_supported_panels_and_excludes_unavailable_power_actions(self): + menu = self.root / 'menu.jsonc' + shutil.copyfile(PROJECT / 'desktop/shell/default/omarchy/omarchy-menu.jsonc', menu) + stage.adapt_menu(menu) + entries = json.loads(menu.read_text()) + for panel in ('monitor', 'network', 'bluetooth', 'power'): + key = 'setup.monitors' if panel == 'monitor' else 'setup.' + panel + self.assertEqual(entries[key]['action'], 'omarchy-shell omarchy.%s toggle' % panel) + power = {name: row['action'] for name, row in entries.items() if name.startswith('system.')} + self.assertEqual(power, {'system.shutdown': 'omarchy-system shutdown'}) + for action in ('reboot', 'suspend', 'hibernate', 'logout'): + with self.subTest(action=action): + result = subprocess.run(['bash', str(PROFILE / 'system-action'), action], + text=True, capture_output=True, timeout=5) + self.assertEqual(result.returncode, 2, result.stdout + result.stderr) + self.assertNotIn('install', entries) + self.assertNotIn('update', entries) + + def test_firefox_defaults_preserve_omafox_and_other_policies_and_preferences(self): + source = PROJECT / 'desktop/configuration/firefox-policies.json' + original = source.read_bytes() + document = json.loads(original) + extensions = document['policies']['ExtensionSettings'] + homepage = {'URL': 'https://example.invalid/', 'Locked': False} + retained = { + 'browser.custom.pref': {'Value': 'keep', 'Status': 'default', 'Type': 'string'}, + 'fission.autostart': {'Value': True, 'Status': 'default', 'Type': 'boolean'}, + 'security.sandbox.content.level': {'Value': 4, 'Status': 'default', 'Type': 'number'}, + } + document['policies'].update(Homepage=homepage, Preferences=retained) + target = self.root / 'policies.json' + target.write_text(json.dumps(document)) + receipt = stage.adapt_firefox(target) + policies = json.loads(target.read_text())['policies'] + self.assertEqual(source.read_bytes(), original) + self.assertEqual(set(policies), set(document['policies'])) + self.assertEqual(policies['ExtensionSettings'], extensions) + self.assertEqual(policies['Homepage'], homepage) + for name, entry in retained.items(): + self.assertEqual(policies['Preferences'][name], entry) + expected = { + 'dom.ipc.processPrelaunch.enabled': False, + 'dom.ipc.processCount': 2, + 'dom.ipc.processCount.webIsolated': 1, + 'browser.newtab.preload': False, + 'browser.aboutwelcome.enabled': False, + 'browser.startup.page': 0, + 'browser.newtabpage.enabled': False, + } + defaults = {name: entry for name, entry in policies['Preferences'].items() if name not in retained} + self.assertEqual(set(defaults), set(expected)) + for name, value in expected.items(): + self.assertEqual(defaults[name], { + 'Value': value, 'Status': 'default', + 'Type': 'boolean' if type(value) is bool else 'number', + }) + self.assertEqual(receipt['preferences'], defaults) + self.assertEqual(receipt['policy_sha256'], digest(target.read_bytes())) + self.assertEqual(receipt['defaults_sha256'], digest((PROFILE / 'firefox-defaults.json').read_bytes())) + + def test_firefox_rejects_malformed_policy_objects_before_writing(self): + target = self.root / 'policies.json' + for document in ({}, {'policies': []}, {'policies': None}, + {'policies': {'Preferences': []}}): + with self.subTest(document=document): + original = json.dumps(document).encode() + target.write_bytes(original) + with self.assertRaises(ValueError): + stage.adapt_firefox(target) + self.assertEqual(target.read_bytes(), original) + + def bridge_fixture(self, name='payload'): + payload = self.root / name + originals = {} + for relative in ('shell/shell.qml', 'shell/services/PluginShellApi.qml'): + source = PROJECT / 'desktop/shell' / relative + originals[relative] = source.read_bytes() + target = payload / relative + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes(originals[relative]) + return payload, originals + + def test_keyboard_bridge_scopes_both_facades_and_preserves_shared_sources(self): + payload, originals = self.bridge_fixture() + receipt = stage.adapt_keyboard_bridge(payload) + self.assertEqual(receipt['entry'], 'omarchy.network') + self.assertEqual(receipt['surface'], 'read-only keyboardVisible') + self.assertEqual(set(receipt['files']), set(originals)) + for relative, original in originals.items(): + self.assertEqual((PROJECT / 'desktop/shell' / relative).read_bytes(), original) + copied = (payload / relative).read_bytes() + self.assertNotEqual(copied, original) + self.assertEqual(receipt['files'][relative], digest(copied)) + api = (payload / 'shell/services/PluginShellApi.qml').read_text() + self.assertIn('readonly property bool keyboardVisible:', api) + shell = (payload / 'shell/shell.qml').read_text() + callbacks = re.findall(r'_keyboardVisibleLookup:\s*function\(\)\s*\{(.*?)\n\s*\}', + shell, flags=re.DOTALL) + self.assertEqual(len(callbacks), 2) + for identifier, body in zip(('key', 'target'), callbacks): + self.assertIn('currentManifest()', body) + self.assertIn(identifier + ' === "omarchy.network"', body) + self.assertIn('entry && entry.__isFirstParty && shell.keyboardVisible', body) + self.assertIn('return !!(', body) + + def test_keyboard_bridge_rejects_missing_anchors_before_writing_either_file(self): + for index, (relative, anchor) in enumerate(( + ('shell/services/PluginShellApi.qml', ' property var _serviceLookup: null\n'), + ('shell/shell.qml', ' pluginId: key,\n appLibrary:'), + ('shell/shell.qml', ' pluginId: target,\n barConfig:'), + )): + with self.subTest(anchor=anchor): + payload, originals = self.bridge_fixture('broken-' + str(index)) + changed = payload / relative + changed.write_text(changed.read_text().replace(anchor, '', 1)) + before = {name: (payload / name).read_bytes() for name in originals} + with self.assertRaisesRegex(ValueError, 'facade changed'): + stage.adapt_keyboard_bridge(payload) + self.assertEqual({name: (payload / name).read_bytes() for name in originals}, before) + for name, original in originals.items(): + self.assertEqual((PROJECT / 'desktop/shell' / name).read_bytes(), original) + + @unittest.skipUnless(shutil.which('node'), 'node is required to exercise the facade callbacks') + def test_keyboard_bridge_callbacks_gate_builtin_identity_and_visible_state(self): + payload, _ = self.bridge_fixture() + stage.adapt_keyboard_bridge(payload) + callbacks = re.findall(r'_keyboardVisibleLookup:\s*function\(\)\s*\{(.*?)\n\s*\}', + (payload / 'shell/shell.qml').read_text(), flags=re.DOTALL) + script = r''' +const assert = require('node:assert/strict'); +const callbacks = JSON.parse(process.argv[1]); +assert.equal(callbacks.length, 2); +for (const [index, body] of callbacks.entries()) { + const lookup = new Function('key', 'target', 'currentManifest', 'shell', body); + for (const id of ['omarchy.network', 'omarchy.network.clone', 'other.plugin']) { + for (const entry of [null, {}, {__isFirstParty: false}, {__isFirstParty: true}]) { + for (const visible of [false, true]) { + const key = index === 0 ? id : 'other.plugin'; + const target = index === 1 ? id : 'other.plugin'; + const result = lookup(key, target, () => entry, {keyboardVisible: visible}); + assert.equal(typeof result, 'boolean'); + assert.equal(result, id === 'omarchy.network' && entry?.__isFirstParty === true && visible); + } + } + } +} +''' + result = subprocess.run(['node', '-e', script, json.dumps(callbacks)], + text=True, capture_output=True, timeout=5) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + + @unittest.skipIf(os.geteuid() == 0 and platform.machine() == 'armv7l', + 'this case exercises refusal outside the supported build environment') + def test_prepare_root_refuses_the_host_before_creating_output(self): + destination = self.root / 'snapshot' + env = dict(os.environ) + env.pop('BASH_ENV', None) + # A missing package file also prevents writes if the host guard regresses. + result = subprocess.run(['bash', str(PREPARE), '--snapshot', str(destination), + '--packages', str(self.root / 'missing-packages')], + env=env, text=True, capture_output=True, timeout=5) + self.assertNotEqual(result.returncode, 0) + self.assertIn('Requires root inside an ARMv7', result.stderr) + self.assertFalse(destination.exists()) + + def test_preparation_policy_keeps_kernel_and_signature_guards_before_writes(self): + source = PREPARE.read_text() + preflight = source.split('install -d "$snapshot/cache"', 1)[0] + self.assertIn('$EUID == 0', preflight) + self.assertIn('/run/.containerenv', preflight) + self.assertIn('Cascadia supplies the kernel', preflight) + self.assertIn('linux-armv7*', preflight) + self.assertIn('linux-aarch64*', preflight) + configuration = source.split("<<'PACMAN'\n", 1)[1].split('\nPACMAN', 1)[0] + options = dict(line.split(' = ', 1) for line in configuration.splitlines() if ' = ' in line) + self.assertEqual(options['SigLevel'], 'Required DatabaseOptional') + self.assertEqual(options['LocalFileSigLevel'], 'Required') + self.assertEqual(options['RemoteFileSigLevel'], 'Required') + self.assertIn('linux-armv7', options['IgnorePkg'].split()) + requested = (PROJECT / 'system/p105/packages.txt').read_text().splitlines() + packages = {line.split('#', 1)[0].strip() for line in requested} + self.assertFalse(packages & {'linux', 'linux-armv7', 'linux-aarch64'}) diff --git a/tests/test_p105_release.py b/tests/test_p105_release.py new file mode 100644 index 0000000..3356d18 --- /dev/null +++ b/tests/test_p105_release.py @@ -0,0 +1,88 @@ +"""P105 public-root boundaries using disposable archives.""" +import io +from pathlib import Path +import struct +import tarfile +import tempfile +import unittest + +from release.p105 import audit + + +class P105Release(unittest.TestCase): + def archive(self, root, entries): + archive=root/'root.tar.gz' + with tarfile.open(archive,'w:gz') as stream: + for name,kind,value in entries: + member=tarfile.TarInfo(name) + if kind=='file': + member.size=len(value) + stream.addfile(member,io.BytesIO(value)) + else: + member.type=kind + if kind in (tarfile.SYMTYPE,tarfile.LNKTYPE):member.linkname=value + stream.addfile(member) + return archive + + def rejects(self, entries, message): + with tempfile.TemporaryDirectory() as temporary: + path=self.archive(Path(temporary),entries) + with self.assertRaisesRegex(ValueError,message):audit.inventory(path) + + def test_member_traversal_is_rejected(self): + for name in ('../outside','/etc/outside','usr/../../outside'): + with self.subTest(name=name):self.rejects([(name,'file',b'x')],'Unsafe') + + def test_link_traversal_and_special_members_are_rejected(self): + self.rejects([('usr/link',tarfile.SYMTYPE,'../../outside')],'escapes') + self.rejects([('dev/disk',tarfile.CHRTYPE,'')],'Unsupported') + self.rejects([('usr/hard',tarfile.LNKTYPE,'usr/file')],'Unsupported') + + def test_duplicate_normalized_paths_are_rejected(self): + self.rejects([('./etc/value','file',b'a'),('etc/value','file',b'b')],'Duplicate') + + def test_owner_inputs_are_rejected(self): + for name in ('etc/ssh/ssh_host_ed25519_key','home/tablet/.ssh/authorized_keys', + 'usr/lib/firmware/mtcal.bin','etc/orchard/p105.json', + 'etc/NetworkManager/system-connections/owner.nmconnection'): + with self.subTest(name=name):self.rejects([(name,'file',b'private input')],'Private input') + + def test_private_key_body_is_rejected(self): + key=b'-----BEGIN OPENSSH PRIVATE KEY-----\n'+b'A'*80+b'\n' + self.rejects([('unexpected/key','file',key)],'Private key') + + def test_documented_key_header_is_ordinary_source(self): + with tempfile.TemporaryDirectory() as temporary: + path=self.archive(Path(temporary),[('usr/lib/parser.py','file',b'header = "-----BEGIN OPENSSH PRIVATE KEY-----"\n')]) + catalog,_=audit.inventory(path) + self.assertEqual(catalog['usr/lib/parser.py']['type'],'file') + + def test_non_arm_executable_is_rejected(self): + header=bytearray(52);header[:7]=b'\x7fELF\x02\x01\x01';struct.pack_into('