diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index c9400fe..b8b36a7 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -23,6 +23,9 @@ on: reuse_run_id: description: Exact prior run supplying unchanged boot and userspace artifacts required: false + extra_devices: + description: Devices built and published beside J71b, such as j71t; the hardware tag must ship their components + required: false permissions: contents: read actions: read @@ -31,6 +34,8 @@ env: CLI_TAG: ${{ inputs.cli_tag || 'v0.1.8' }} CLI_ARTIFACT_DIR: ${{ inputs.cli_run_id != '' && 'out/dependency-wheels' || '' }} HARDWARE_ARTIFACT_DIR: ${{ inputs.hardware_run_id != '' && 'out/downloads/hardware' || '' }} + # Add j71t to the default with the first hardware release carrying its components. + EXTRA_DEVICES: ${{ inputs.extra_devices || '' }} jobs: test: runs-on: ubuntu-24.04 @@ -154,6 +159,10 @@ jobs: if: inputs.reuse_run_id == '' run: | python boot/build-loader --logo-header out/boot/omarchy-logo.h --output out/boot/m1n1.bin + for device in $EXTRA_DEVICES; do + python boot/build-loader --device "$device" --logo-header out/boot/omarchy-logo.h \ + --output "out/boot/$device-m1n1.bin" + done tar -C out -czf out/distro-boot.tar.gz boot - uses: actions/upload-artifact@v4 with: @@ -228,7 +237,12 @@ jobs: VERSION="preview-$RUN_ID" if [[ "$REF_TYPE" == tag ]]; then VERSION="${REF_NAME#v}"; fi bash system/build-rootfs j71b --desktop out/desktop --release "$VERSION" --output out/base - tar -C out -czf out/omarchy-userspace.tar.gz base cursor bluetooth labwc + bases=() + for device in $EXTRA_DEVICES; do + bash system/build-rootfs "$device" --desktop out/desktop --release "$VERSION" --output "out/base-$device" + bases+=("base-$device") + done + tar -C out -czf out/omarchy-userspace.tar.gz base "${bases[@]}" cursor bluetooth labwc - uses: actions/upload-artifact@v4 with: name: omarchy-userspace @@ -308,9 +322,30 @@ jobs: --cursor-tools out/cursor --bluetooth-tools out/bluetooth --labwc-tools out/labwc \ --output out/release python -m release.j71b.audit out/release/j71b-checkm8-pongo.toml + for device in $EXTRA_DEVICES; do + [[ "$device" =~ ^[a-z0-9][a-z0-9_-]*$ && "$device" != j71b ]] + DEVICE="$device" bash ci/dependencies --components-only + python ci/normalize-recovery-tools.py --archive "out/base-$device/rootfs.tar.gz" \ + --receipt "out/base-$device/rootfs.tar.gz.json" + parts="out/$device/components" + python -m "release.$device.build" --release "$VERSION" \ + --base "out/base-$device/rootfs.tar.gz" --base-receipt "out/base-$device/rootfs.tar.gz.json" \ + --modules-dir "$parts/kernel/lib/modules" --loader "out/boot/$device-m1n1.bin" \ + --kernel "$parts/kernel/Image.gz" --dtb "$parts/kernel/$device.dtb" \ + --pongo out/boot/pongo.bin --busybox "$parts/boot/busybox" \ + --dropbear "$parts/boot/dropbear" --dropbearkey "$parts/boot/dropbearkey" \ + --cursor-tools out/cursor --bluetooth-tools out/bluetooth --labwc-tools out/labwc \ + --output "out/release-$device" + python -m "release.$device.audit" "out/release-$device/$device-checkm8-pongo.toml" + python -m release.merge --release-dir out/release --add "out/release-$device" + done orchard resolve out/release/index.toml + for device in $EXTRA_DEVICES; do orchard resolve out/release/index.toml --profile "$device"; done python -m release.publish --tag "$TAG" --release-dir out/release --output out/publication cp out/release/rootfs.json out/release/build-receipt.json out/publication/ + for device in $EXTRA_DEVICES; do + cp "out/release/$device-rootfs.json" "out/release/$device-build-receipt.json" out/publication/ + done cp out/downloads/pongo-source.tar.gz out/publication/ git archive --format=tar.gz -o out/publication/omarchy-linux-source.tar.gz HEAD (cd out/publication && sha256sum ./* > ../SHA256SUMS && mv ../SHA256SUMS .) diff --git a/BUILD.md b/BUILD.md index 8dad28d..5d83c6f 100644 --- a/BUILD.md +++ b/BUILD.md @@ -28,9 +28,9 @@ Use `orchard-hardware>=0.1.9,<0.2`. To test unpublished packages, install compat ## Produce components - `desktop/build-sources` and `desktop/build`: desktop applications and payload. -- `system/build-rootfs`: signed Arch Linux ARM base and package receipt; see [system/j71b/README.md](system/j71b/README.md). +- `system/build-rootfs DEVICE`: signed Arch Linux ARM base and package receipt; see [system/j71b/README.md](system/j71b/README.md) and [system/j71t/README.md](system/j71t/README.md). - Installed hardware producers: kernel, DTB, modules and static BusyBox/Dropbear tools; see [hardware build instructions](https://github.com/OrchardLinux/hardware/blob/main/BUILD.md). -- `python boot/build-loader`: distro-branded loader. Pongo sources and build instructions are under `boot/pongo/`. +- `python boot/build-loader [--device DEVICE]`: distro-branded loader (default J71b). Pongo sources and build instructions are under `boot/pongo/`. Retain each component's receipt. Kernel, modules and DTB must be compatible. Do not change hardware activation or timing as part of presentation changes. @@ -59,6 +59,22 @@ python -m release.j71b.build \ Choose a new version and output directory for each build. Root builds first; recovery embeds its exact stream receipt, and the index is written last. Installed wheel resources supply hardware code and scripts. `image/j71b/image_policy.py` and `permissions.py` supply desktop readiness, systemd and privilege policy. +### More devices + +Each device is assembled separately from its own components, then merged into one release. J71t (iPad 5 Wi-Fi) uses `system/build-rootfs j71t`, `python boot/build-loader --device j71t`, the hardware's `j71t-components.tar.gz` and `image/j71t/`: + +```sh +python -m release.j71t.build --release 0.1.0-orchard.3 \ + --dtb out/j71t/components/kernel/j71t.dtb --loader out/j71t/m1n1.bin ... \ + --output out/releases/0.1.0-orchard.3-j71t +python -m release.j71t.audit out/releases/0.1.0-orchard.3-j71t/j71t-checkm8-pongo.toml +python -m release.merge --release-dir out/releases/0.1.0-orchard.3 \ + --add out/releases/0.1.0-orchard.3-j71t +orchard resolve out/releases/0.1.0-orchard.3/index.toml --profile j71t +``` + +The remaining arguments match J71b's. Device-specific files are named `j71t-rootfs.tar.zst`, `j71t-recovery.bin`, `j71t-rootfs.json` and `j71t-build-receipt.json`; J71b keeps its original names and both share the branded `pongo.bin`. `release.merge` refuses any differing file and rewrites one `index.toml` pinning each device. + ## Validate without a device ```sh diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 53920bb..c9b6686 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -13,4 +13,6 @@ The current J71b port is the reference implementation: - [Device protocol](https://github.com/OrchardLinux/cli/blob/main/docs/device-protocol.md): operations exposed by the runtime to the host. - Omarchy A's `image/j71b/` and `release/j71b/`: installed-system policy and release assembly. +J71t (iPad 5 Wi-Fi) shows what a further device adds here: `image/j71t/` (its platform helpers), `system/j71t/` (package list and device overlay), thin `release/j71t/` entry points, and an entry in `release/devices.py`. Record feature status in [docs/devices.md](docs/devices.md). + Keep device-specific hardware procedures in the hardware implementation and desktop policy in Omarchy A. The host coordinates operations and presents confirmations; the device runtime owns storage validation, hardware activation, extraction, and handoff. diff --git a/README.md b/README.md index 970d991..572b10a 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,9 @@ Includes a device-aware CLI and release format for installation, diagnostics, an | Device | Identifier | Status | | --- | --- | --- | | iPad 6 Wi-Fi | `iPad7,5` / J71b | Experimental; no GPU acceleration | +| iPad 5 Wi-Fi | `iPad6,11` / J71t | Experimental; no GPU acceleration; drivers tested on one unit; Orchard install not yet tested | + +See [supported devices and features](docs/devices.md) for per-feature status. This image uses **tethered boot**: starting Linux after a cold restart requires a host computer running `orchard launch`. diff --git a/boot/build-loader b/boot/build-loader index 7b77d2c..4afe78a 100755 --- a/boot/build-loader +++ b/boot/build-loader @@ -7,8 +7,13 @@ import sys import tempfile import orchard_hardware +# The splash only draws into m1n1's boot framebuffer. J71t applies the J71b +# patch unchanged on top of its own loader patches (m1n1 d5a10ac52a). +SPLASH = {'j71b': 'j71b', 'j71t': 'j71b'} + parser = argparse.ArgumentParser(description=__doc__) parser.add_argument('--output', required=True, type=Path) +parser.add_argument('--device', default='j71b', choices=sorted(SPLASH)) parser.add_argument('--resume', action='store_true') parser.add_argument('--logo-header', type=Path, help='pre-generated matching artwork header (CI)') args = parser.parse_args() @@ -19,7 +24,7 @@ with tempfile.TemporaryDirectory(prefix='omarchy-loader-') as temporary: subprocess.run([sys.executable, str(here / 'pongo/generate-logo.py'), '--size', '480', '--output', str(header)], check=True) subprocess.run([str(Path(orchard_hardware.__file__).parent / 'boot/m1n1/build'), - 'j71b', '--output', str(args.output), - '--extra-patch', str(here / 'm1n1/patches/j71b/0002-omarchy-splash.patch'), + args.device, '--output', str(args.output), + '--extra-patch', str(here / 'm1n1/patches' / SPLASH[args.device] / '0002-omarchy-splash.patch'), '--include-header', str(header), '--extra-cflags', '-DOMARCHY_SPLASH', *(['--resume'] if args.resume else [])], check=True) diff --git a/ci/dependencies b/ci/dependencies index ebf143b..a2cb06e 100644 --- a/ci/dependencies +++ b/ci/dependencies @@ -1,36 +1,48 @@ #!/usr/bin/env bash # Install a published, hashed wheel pair and verified public component bundle. +# DEVICE (default j71b) selects the bundle; other devices extract to out/DEVICE. set -euo pipefail : "${HARDWARE_TAG:?Set HARDWARE_TAG to an immutable hardware release}" : "${CLI_TAG:?Set CLI_TAG to an immutable CLI release}" [[ "$HARDWARE_TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ && "$CLI_TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]] || { echo 'Hardware and CLI tags must be vMAJOR.MINOR.PATCH' >&2; exit 2; } -python3 -m venv .venv -if [[ -n "${CLI_ARTIFACT_DIR:-}" ]]; then - .venv/bin/python -m pip install --only-binary=:all: "$CLI_ARTIFACT_DIR"/*.whl -else - .venv/bin/python -m pip install --only-binary=:all: -r \ - "https://github.com/OrchardLinux/cli/releases/download/$CLI_TAG/requirements.txt" -fi -.venv/bin/python - <<'PY' +mode=${1:-} +case "$#:$mode" in + 0:|1:--wheels-only|1:--components-only) ;; + *) echo 'Usage: ci/dependencies [--wheels-only|--components-only]' >&2; exit 2;; +esac +device=${DEVICE:-j71b} +[[ "$device" =~ ^[a-z0-9][a-z0-9_-]*$ ]] || { echo 'Invalid DEVICE' >&2; exit 2; } +if [[ "$mode" != --components-only ]]; then + python3 -m venv .venv + if [[ -n "${CLI_ARTIFACT_DIR:-}" ]]; then + .venv/bin/python -m pip install --only-binary=:all: "$CLI_ARTIFACT_DIR"/*.whl + else + .venv/bin/python -m pip install --only-binary=:all: -r \ + "https://github.com/OrchardLinux/cli/releases/download/$CLI_TAG/requirements.txt" + fi + .venv/bin/python - <<'PY' import importlib.metadata as m, os for package, variable in (('orchard-hardware', 'HARDWARE_TAG'), ('orchard-cli', 'CLI_TAG')): if 'v' + m.version(package) != os.environ[variable]: raise SystemExit('Published wheel pair does not match ' + variable) PY -.venv/bin/python -m pip check -if [[ "${1:-}" == --wheels-only ]]; then exit 0; fi -[[ $# == 0 ]] || { echo 'Usage: ci/dependencies [--wheels-only]' >&2; exit 2; } -mkdir -p out/downloads + .venv/bin/python -m pip check + if [[ "$mode" == --wheels-only ]]; then exit 0; fi +fi +bundle="$device-components.tar.gz" +destination=out +[[ $device == j71b ]] || destination="out/$device" +mkdir -p out/downloads "$destination" base="https://github.com/OrchardLinux/hardware/releases/download/$HARDWARE_TAG" if [[ -n "${HARDWARE_ARTIFACT_DIR:-}" ]]; then - cp "$HARDWARE_ARTIFACT_DIR/j71b-components.tar.gz" "$HARDWARE_ARTIFACT_DIR/SHA256SUMS" out/downloads/ + cp "$HARDWARE_ARTIFACT_DIR/$bundle" "$HARDWARE_ARTIFACT_DIR/SHA256SUMS" out/downloads/ else - curl -fL --retry 3 "$base/j71b-components.tar.gz" -o out/downloads/j71b-components.tar.gz + curl -fL --retry 3 "$base/$bundle" -o "out/downloads/$bundle" curl -fL --retry 3 "$base/SHA256SUMS" -o out/downloads/SHA256SUMS fi -grep -Eq ' (\./)?j71b-components.tar.gz$' out/downloads/SHA256SUMS -(cd out/downloads && grep -E ' (\./)?j71b-components.tar.gz$' SHA256SUMS | sha256sum --check --strict) -tar -xzf out/downloads/j71b-components.tar.gz -C out -.venv/bin/python -m pip check +grep -Eq " (\./)?$bundle\$" out/downloads/SHA256SUMS +(cd out/downloads && grep -E " (\./)?$bundle\$" SHA256SUMS | sha256sum --check --strict) +tar -xzf "out/downloads/$bundle" -C "$destination" +if [[ "$mode" != --components-only ]]; then .venv/bin/python -m pip check; fi diff --git a/docs/devices.md b/docs/devices.md new file mode 100644 index 0000000..3ec63c2 --- /dev/null +++ b/docs/devices.md @@ -0,0 +1,38 @@ +# Supported devices and features + +Omarchy A is experimental on every device. Boot is tethered and there is no GPU acceleration. "Works" means verified on hardware, "Partial" means it works with limits or only parts are verified, and "Not yet" means not available. + +| Device | Identifier | Chip / board | Tested | +| --- | --- | --- | --- | +| iPad 6 Wi-Fi | `iPad7,5` / J71b | T8010 (A10), board `0x18` | See the J71b column note | +| iPad 5 Wi-Fi | `iPad6,11` / J71t | S8003 (A9, TSMC), board `0x10` | One unit, iPadOS 16.7.10 | + +`iPad6,11` is also sold as J71s (Samsung S8000, chip `0x8000`). J71s and the cellular iPad 5 (`iPad6,12`, J72s/J72t) are not supported. + +## Features + +| Feature | J71b¹ | J71t² | +| --- | --- | --- | +| Wi-Fi | Supported (a) | **Works**: BCM4355 C0 over PCIe; MAC and calibration come from the iPad's own device tree; 5 GHz verified | +| Bluetooth | Supported (a) | **Works**: scan and keyboard pairing | +| Touch | Supported (a) | **Works**: N1 multitouch; firmware from the IPSW, per-unit calibration from the device tree | +| Display | Supported, no GPU acceleration (b) | **Works**: native KMS at 1536×2048 on the display pipe iBoot leaves running; the panel is `eDP-1`. Software rendering only. Booting with `apple_adbe_drm.enable=0` falls back to the boot framebuffer (simpledrm) | +| Brightness / ambient light | Backlight supported (c); ALS — | **Works** for brightness: the brightness slider and power-key screen toggle. The CT819 light sensor is exposed through IIO only, with no automatic brightness | +| Audio out | — | **Works**: speakers (CS42L81) through PipeWire's default ALSA profile. Headphone jack: routing code present, not validated | +| Microphones | — | **Works**: built-in stereo microphones (48 kHz capture verified) | +| Battery | Supported (d) | **Works**: bq27540 gauge, read-only telemetry; the status reads Discharging whenever current flows out, also at 100 % | +| Charging (detection, up to 2.4 A) | Partial: guarded 425 mA lease (e) | **Works**: computers (USB-A, and a Mac USB-C port through Apple's USB-C to Lightning cable), USB-C wall chargers and a Mac USB-C power adapter charge at up to 2.4 A, the board maximum (verified on the device on 2026-10-27). 5 V only: the iPad 5 has no USB Power Delivery. Charging policy runs in the kernel | +| NVMe install | Supported (f) | **Partial**: untested through Orchard. Installing to a Linux partition beside a shrunk iOS works with the omarchy-ipad5 port's own installer and kernel write fence | +| Suspend | Disabled by image policy (g) | **Not yet**: disabled by image policy, as on J71b; s2idle is only partially validated | +| USB gadget | Supported (h) | **Works**: USB networking and serial console to the host | +| Lightning camera adapter | — | **Partial**: switches to host mode, but no hub or device enumerates yet and host-mode input is limited to 100 mA | +| Accelerometer / auto-rotate | — | **Not yet** in this port: the AOP driver can only be built in, needs a boot-time release step from the omarchy-ipad5 port's own initramfs, and Orchard cannot yet generate its calibration from the device tree. Works on cold boot in omarchy-ipad5 | +| GPU | Not supported (b) | **Not yet**: software rendering only; GPU research code is excluded from this port | +| Video decode | — | **Partial**: V4L2 stateless H.264 decoder, bit-exact on a test stream. Its firmware comes from the IPSW and starts only on request, through debugfs; no media player is set up to use it | +| Cameras | — | **Not yet**: no driver | + +Also not in the J71t port: Touch ID / Secure Enclave (communication only, no fingerprint support), deeper CPU idle and overclocking profiles, and the shared cold-restart launcher (`orchard launch`), which needs J71t launcher assets first. Resize APFS support for iPad 5 has not been checked. + +¹ J71b entries state only what Orchard's own code and documentation show; "—" means not stated there (maintainer to confirm). Sources: (a) hardware `runtime/j71b/phase2-hardware` (Wi-Fi, touch and Bluetooth steps) and `devices/j71b/device.toml`; (b) [README](../README.md); (c) `system/j71b/overlay/usr/lib/udev/rules.d/70-j71b-backlight.rules` and the power-key screen toggle; (d) hardware `kernel/devicetree/j71b/battery-gauge.dtso` and `system/j71b/overlay/usr/lib/modprobe.d/30-j71b-battery.conf`; (e) `system/j71b/overlay/usr/lib/systemd/system/j71b-charge.service`; (f) [How it works](how-it-works.md); (g) `system/j71b/overlay/etc/systemd/sleep.conf.d/60-omarchy-a-no-suspend.conf`; (h) hardware `boot/initramfs/j71b/recovery/usb-gadget` and `omarchy-diagnostics.service`. + +² J71t statuses come from device tests of the same drivers in the release 2 kernel of the [omarchy-ipad5](https://github.com/brkosebay/omarchy-ipad5) port, which passed 13 of 13 device checks through that port's own boot path on one iPad6,11 J71t (S8003, board `0x10`) running iPadOS 16.7.10. Kernel source: [`a9/orchard-j71t`](https://github.com/brkosebay/linux/tree/a9/orchard-j71t). Charging was verified on the same unit on 2026-10-27 with the charger code of kernel patch 0013. The Orchard recovery, installation and handoff path has not yet run on a J71t. diff --git a/image/j71t/image_policy.py b/image/j71t/image_policy.py new file mode 100644 index 0000000..4d4e3c7 --- /dev/null +++ b/image/j71t/image_policy.py @@ -0,0 +1,66 @@ +"""Omarchy image-owned readiness, privilege and init policy.""" +import json +import os +from pathlib import Path +import subprocess + +DIAGNOSTICS_EXECUTABLE = '/usr/libexec/omarchy-dropbear' + + +def stop_diagnostics(): + subprocess.run(['systemctl', 'stop', 'omarchy-diagnostics.service'], + check=True, capture_output=True, timeout=15) + +from permissions import verify as verify_privileges, verify_session_access + +INIT = '/usr/lib/systemd/systemd' + + +def ready(*, ssh_revoked=False): + path = Path('/run/user/1000/omarchy-desktop-ready.json') + try: + if path.is_symlink() or path.stat().st_uid != 1000: + return False + record = json.loads(path.read_text()) + if record['boot_id'] != Path('/proc/sys/kernel/random/boot_id').read_text().strip(): + return False + pid = int(record['shell_pid']) + process = Path('/proc') / str(pid) + return (process.stat().st_uid == 1000 and + (process / 'exe').readlink().name in ('qs', 'quickshell') and + Path(record['wayland_socket']).is_socket() and + Path(record['wayland_socket']).stat().st_uid == 1000) + except (OSError, ValueError, KeyError): + return False + + + +def validate_root(root, release): + for helper in ('usr/lib/systemd/systemd', 'usr/libexec/j71t-check-platform', + 'usr/libexec/omarchy-diagnostics'): + if not os.access(root / helper, os.X_OK): + raise ValueError('Missing executable: ' + helper) + root_release = json.loads((root / 'etc/orchard/release.json').read_text()) + if root_release['release'] != release['release']: + raise ValueError('Root/recovery release mismatch') + + +def validate_handoff(root): + for helper in ('usr/lib/systemd/systemd', 'usr/libexec/j71t-check-platform', + 'usr/libexec/omarchy-diagnostics'): + if not os.access(root / helper, os.X_OK): + raise ValueError('Handoff executable missing: ' + helper) + + +def diagnostics(): + logs = {} + for name in ('omarchy-desktop-ready.log', 'omarchy-shell.log'): + path = Path('/run/user/1000') / name + try: + if not path.is_symlink() and path.stat().st_uid == 1000: + with path.open('rb') as stream: + stream.seek(max(0, path.stat().st_size - 12000)) + logs[name] = stream.read(12000).decode(errors='replace') + except OSError: + pass + return logs diff --git a/image/j71t/permissions.py b/image/j71t/permissions.py new file mode 100644 index 0000000..e0e2bfd --- /dev/null +++ b/image/j71t/permissions.py @@ -0,0 +1,65 @@ +"""Verify package-defined privileged files before a staged root can activate.""" +import hashlib +import json +import os +from pathlib import Path +import stat + +from archive import parent_fd, safe_name + + +def verify_session_access(root): + """The tablet must traverse/read/execute the public readiness entrypoint. + + Do not check the private staging root itself: it becomes 0755 only when + promoted. Open every descendant without following links or creating paths. + """ + current = os.open(root, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW) + parts = ('usr', 'libexec', 'omarchy-desktop-ready') + try: + for index, part in enumerate(parts): + flags = os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK + if index < len(parts) - 1: + flags |= os.O_DIRECTORY + following = os.open(part, flags, dir_fd=current) + os.close(current) + current = following + info = os.fstat(current) + if (info.st_mode & 0o005 != 0o005 or + (index == len(parts) - 1 and not stat.S_ISREG(info.st_mode))): + raise ValueError('Tablet cannot access desktop readiness entrypoint: ' + part) + finally: + os.close(current) + + +def verify(root, ownership=True): + records = json.loads((Path(root) / 'etc/omarchy/package-privileges.json').read_text()) + sudo = records.get('usr/bin/sudo', {}) + if sudo.get('uid') != 0 or sudo.get('mode') != 0o4755: + raise ValueError('Missing sudo ownership/setuid contract') + root_fd = os.open(root, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW) + try: + for name, expected in records.items(): + safe_name(name) + parent, leaf = parent_fd(root_fd, name) + try: + fd = os.open(leaf, os.O_RDONLY | os.O_NOFOLLOW, dir_fd=parent) + try: + info = os.fstat(fd) + valid_type = stat.S_ISREG(info.st_mode) if expected['type'] == 'file' else stat.S_ISDIR(info.st_mode) + if (not valid_type or stat.S_IMODE(info.st_mode) != expected['mode'] or + (ownership and (info.st_uid, info.st_gid) != (expected['uid'], expected['gid']))): + raise ValueError('Privileged package ownership/mode differs: ' + name) + if expected['type'] == 'file': + digest = hashlib.sha256() + for block in iter(lambda: os.read(fd, 1048576), b''): + digest.update(block) + if digest.hexdigest() != expected['sha256']: + raise ValueError('Privileged package content differs: ' + name) + finally: + os.close(fd) + finally: + os.close(parent) + finally: + os.close(root_fd) + return len(records) diff --git a/release/devices.py b/release/devices.py new file mode 100644 index 0000000..030047a --- /dev/null +++ b/release/devices.py @@ -0,0 +1,37 @@ +"""Per-device composition facts. Desktop, installer and audit code stays shared.""" + +# J71b artifact names predate multi-device releases and stay unchanged. Other +# devices prefix their device-specific files so one release can carry several. +DEVICES = { + 'j71b': {'prefix': '', 'backlight': '20e200080.backlight', + 'helpers': ('j71b-check-platform', 'j71b-charge-lease'), + 'units': ('j71b-charge.service', 'omarchy-diagnostics.service')}, + # J71t charging policy is in the kernel; there is no userspace lease. + 'j71t': {'prefix': 'j71t-', 'backlight': '20e200000.backlight', + 'helpers': ('j71t-check-platform',), + 'units': ('omarchy-diagnostics.service',)}, +} + +# Device-independent files of the J71b system layer that later devices reuse. +SHARED_SYSTEM = ( + 'etc/sudoers.d/90-tablet-passwordless', + 'etc/systemd/logind.conf.d/60-omarchy-a-power.conf', + 'etc/systemd/sleep.conf.d/60-omarchy-a-no-suspend.conf', + 'usr/lib/omarchy-bluetooth/policy.py', + 'usr/lib/systemd/system/omarchy-diagnostics.service', + 'usr/libexec/omarchy-bluetooth-pair', + 'usr/libexec/omarchy-desktop-ready', + 'usr/libexec/omarchy-diagnostics', + 'usr/libexec/omarchy-screen-toggle', +) + + +def device(profile): + if profile not in DEVICES: + raise ValueError('Unsupported Omarchy A device: ' + repr(profile)) + return DEVICES[profile] + + +def asset(profile, name): + """Device-specific release file name. The branded Pongo is shared.""" + return name if name == 'pongo.bin' else device(profile)['prefix'] + name diff --git a/release/j71b/audit.py b/release/j71b/audit.py index 09cb88f..33f6d8f 100644 --- a/release/j71b/audit.py +++ b/release/j71b/audit.py @@ -9,6 +9,7 @@ from orchard_hardware.boot.assemble import INITRAMFS_MAGIC from orchard_hardware import firmware_path +from release.devices import device as device_spec from release.manifests import read_release from .public_root import sha @@ -42,9 +43,10 @@ def audit_recovery(image): 'compressed_bytes': size, 'initramfs_sha256': parts['initramfs'][0]['sha256']} -def audit(manifest): +def audit(manifest, profile='j71b'): + helpers = device_spec(profile)['helpers'] record = read_release(manifest) - if record.get('schema') != 1 or record.get('protocol') != 1 or record.get('profile') != 'j71b': + if record.get('schema') != 1 or record.get('protocol') != 1 or record.get('profile') != profile: raise ValueError('Incompatible release manifest') paths = {} for role, item in record['assets'].items(): @@ -56,12 +58,12 @@ def audit(manifest): raise ValueError('Asset integrity mismatch: ' + role) paths[role] = path recovery = audit_recovery(paths['recovery']) - runtime = audit_runtime(paths['recovery'], record['assets']['root'], record['release']) + runtime = audit_runtime(paths['recovery'], record['assets']['root'], record['release'], profile) recovery['runtime_members_verified'] = runtime['members'] hashes, links, modes, owners, groups, sizes = {}, {}, {}, {}, {}, {} names, ancestors, directories = set(), set(), set() desktop = public = privileges = cursor = bluetooth = labwc = None - recipe = json.loads(firmware_path('j71b').read_text()) + recipe = json.loads(firmware_path(profile).read_text()) private = {item['destination'] for item in recipe['files'] + recipe['generated']} with subprocess.Popen(['zstd', '-d', '-q', '-c', str(paths['root'])], stdout=subprocess.PIPE) as decoder: with tarfile.open(fileobj=decoder.stdout, mode='r|') as archive: @@ -141,7 +143,7 @@ def audit(manifest): verify_labwc(labwc, hashes, modes, owners, groups) if 'home/tablet/.local/share/omarchy-ipad/shell/bluetooth-pairing/shell.qml' not in desktop: raise ValueError('Pairing UI is absent from desktop integrity manifest') - for name in ('j71b-check-platform', 'j71b-charge-lease', 'omarchy-diagnostics', 'omarchy-desktop-ready', 'omarchy-wtype', 'omarchy-screen-toggle', 'omarchy-bluetooth-pair'): + for name in (*helpers, 'omarchy-diagnostics', 'omarchy-desktop-ready', 'omarchy-wtype', 'omarchy-screen-toggle', 'omarchy-bluetooth-pair'): if modes.get('usr/libexec/' + name) != 0o755: raise ValueError('Required helper is not executable: ' + name) if links.get('lib') != (True, 'usr/lib') or owners.get('home/tablet') != 1000 or sizes.get('etc/machine-id') != 0: @@ -189,11 +191,11 @@ def verify_tablet_sudo(hashes, modes, owners, groups): raise ValueError('Missing or unsafe tablet passwordless sudo policy') -def main(): +def main(device='j71b'): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument('manifest', type=Path) args = parser.parse_args() - print(json.dumps(audit(args.manifest), indent=2)) + print(json.dumps(audit(args.manifest, device), indent=2)) if __name__ == '__main__': diff --git a/release/j71b/build.py b/release/j71b/build.py index e658e64..02605a9 100644 --- a/release/j71b/build.py +++ b/release/j71b/build.py @@ -8,13 +8,14 @@ import sys from .public_root import build as build_root, sha +from release.devices import asset from release.manifests import write as write_manifests from importlib.metadata import version as package_version PROJECT = Path(__file__).resolve().parents[2] -def main(): +def main(device='j71b'): parser = argparse.ArgumentParser(description=__doc__) for name in ('base', 'base-receipt', 'modules-dir', 'loader', 'kernel', 'dtb', 'pongo', 'busybox', 'dropbear', 'dropbearkey', 'cursor-tools', 'bluetooth-tools', 'labwc-tools', 'output'): @@ -24,32 +25,35 @@ def main(): help='compatible previous public root receipt for legacy staging cleanup') args = parser.parse_args() root = build_root(args.base, args.base_receipt, args.modules_dir, args.dropbear, - args.output, args.release, args.cursor_tools, args.bluetooth_tools, args.labwc_tools) + args.output, args.release, args.cursor_tools, args.bluetooth_tools, args.labwc_tools, device) command = [sys.executable, '-m', 'orchard_hardware.image.recovery'] for name in ('base', 'base_receipt', 'modules_dir', 'loader', 'kernel', 'dtb', 'busybox', 'dropbear', 'dropbearkey'): command.extend(['--' + name.replace('_', '-'), str(getattr(args, name))]) - command.extend(['--policy-dir', str(PROJECT / 'image/j71b'), - '--root-receipt', str(args.output / 'rootfs.json'), - '--output', str(args.output / 'recovery.bin')]) + command.extend(['--policy-dir', str(PROJECT / 'image' / device), + '--root-receipt', str(args.output / asset(device, 'rootfs.json')), + '--output', str(args.output / asset(device, 'recovery.bin'))]) + if device != 'j71b': + # Hardware releases before multi-device support only assemble J71b. + command.extend(['--device', device]) for receipt in args.cleanup_receipt: command.extend(['--cleanup-receipt', str(receipt)]) subprocess.run(command, check=True) shutil.copyfile(args.pongo, args.output / 'pongo.bin') assets = {} for role, name in (('pongo', 'pongo.bin'), ('recovery', 'recovery.bin'), ('root', 'rootfs.tar.zst')): - path = args.output / name - assets[role] = {'file': name, 'bytes': path.stat().st_size, 'sha256': sha(path)} + path = args.output / asset(device, name) + assets[role] = {'file': path.name, 'bytes': path.stat().st_size, 'sha256': sha(path)} assets['root']['expanded_bytes'] = root['expanded_bytes'] assets['root']['members'] = root['members'] assets['root']['compression'] = root['compression'] - manifest = {'schema': 1, 'protocol': 1, 'profile': 'j71b', 'release': args.release, + manifest = {'schema': 1, 'protocol': 1, 'profile': device, 'release': args.release, 'qualification': 'pending-fresh-hardware-installation', 'assets': assets} - published = write_manifests(args.output, args.release, root) + published = write_manifests(args.output, args.release, root, device=device) # Assembly provenance is separate from the public installation contract. manifest['hardware_package'] = package_version('orchard-hardware') manifest['inputs'] = {name: {'bytes': path.stat().st_size, 'sha256': sha(path)} for name, path in vars(args).items() if isinstance(path, Path) and path.is_file()} - (args.output / 'build-receipt.json').write_text(json.dumps(manifest, indent=2) + '\n') + (args.output / asset(device, 'build-receipt.json')).write_text(json.dumps(manifest, indent=2) + '\n') print('Public release ready:', published) print('Pinned index:', args.output / 'index.toml') diff --git a/release/j71b/common.py b/release/j71b/common.py index c3b8598..d97e24c 100644 --- a/release/j71b/common.py +++ b/release/j71b/common.py @@ -1,4 +1,4 @@ -"""Shared J71b root assembly invariants; no device, ADT or firmware access.""" +"""Shared root assembly invariants; no device, ADT or firmware access.""" from pathlib import Path PROJECT = Path(__file__).resolve().parents[2] @@ -7,6 +7,15 @@ GROUPS = ('seat', 'video', 'input') +def overlay(profile): + """A device's own system layer. J71b's also holds the shared desktop files.""" + return SYSTEM if profile == 'j71b' else PROJECT / 'system' / profile / 'overlay' + + +def network_rule(profile): + return 'etc/polkit-1/rules.d/60-' + profile + '-tablet-network.rules' + + def group_membership(data: bytes, *, shadow: bool) -> bytes: lines = data.decode().splitlines(keepends=True) for group in GROUPS: diff --git a/release/j71b/public_root.py b/release/j71b/public_root.py index 1204fc6..1201b30 100644 --- a/release/j71b/public_root.py +++ b/release/j71b/public_root.py @@ -9,8 +9,9 @@ import tarfile import tempfile -from .common import group_membership, SYSTEM, NETWORK_RULE +from .common import group_membership, SYSTEM, NETWORK_RULE, overlay, network_rule from .runtime import PROJECT, files as runtime_files +from release.devices import SHARED_SYSTEM, asset, device as device_spec from release.package_permissions import privileged_entries, apply as apply_package_permissions @@ -115,11 +116,13 @@ def desktop_additions(): return {name: ((PROJECT / 'desktop/shell/shell/bluetooth-pairing/shell.qml').read_bytes(), 0o644)} -def system_files(kernel_release): +def overlay_files(directory, kernel_release, names=None): additions = {} - for path in sorted(SYSTEM.rglob('*')): + for path in sorted(directory.rglob('*')): if path.is_file() and not path.is_symlink(): - name = path.relative_to(SYSTEM).as_posix() + name = path.relative_to(directory).as_posix() + if names is not None and name not in names: + continue data = path.read_bytes().replace(b'@KERNEL_RELEASE@', kernel_release.encode()) mode = 0o440 if name.startswith('etc/sudoers.d/') else ( 0o755 if name.startswith('usr/libexec/') else 0o644) @@ -127,7 +130,23 @@ def system_files(kernel_release): return additions -def build(base, base_receipt, modules_dir, dropbear, outdir, release, cursor_tools, bluetooth_tools=None, labwc_tools=None): +def system_files(kernel_release, profile='j71b'): + if profile == 'j71b': + return overlay_files(SYSTEM, kernel_release) + # Other devices reuse J71b's device-independent desktop/session files. + additions = overlay_files(SYSTEM, kernel_release, SHARED_SYSTEM) + if set(additions) != set(SHARED_SYSTEM): + raise ValueError('Missing shared system file') + for name, value in overlay_files(overlay(profile), kernel_release).items(): + if name in additions: + raise ValueError('Device system layer overrides a shared file: ' + name) + additions[name] = value + return additions + + +def build(base, base_receipt, modules_dir, dropbear, outdir, release, cursor_tools, bluetooth_tools=None, labwc_tools=None, + profile='j71b'): + device = device_spec(profile) if outdir.exists(): raise ValueError('Choose a new release output directory') record = json.loads(base_receipt.read_text()) @@ -135,18 +154,19 @@ def build(base, base_receipt, modules_dir, dropbear, outdir, release, cursor_too raise ValueError('Base root differs from receipt') privileges = privileged_entries(base) privileged_digests = {} - kernel_release, additions = runtime_files(modules_dir) + kernel_release, additions = runtime_files(modules_dir, profile) additions.update(cursor_files(cursor_tools)) additions.update(bluetooth_files(bluetooth_tools or PROJECT / 'out/desktop/bluetooth-tools')) additions.update(labwc_files(labwc_tools or PROJECT / 'out/desktop/labwc-tools')) desktop_extra = desktop_additions() additions.update(desktop_extra) additions['etc/omarchy/package-privileges.json'] = (json.dumps(privileges, sort_keys=True).encode(), 0o644) - additions.update(system_files(kernel_release)) + additions.update(system_files(kernel_release, profile)) additions['usr/libexec/omarchy-dropbear'] = (dropbear.read_bytes(), 0o755) - additions['etc/polkit-1/rules.d/60-j71b-tablet-network.rules'] = (NETWORK_RULE.read_bytes(), 0o644) + rule = NETWORK_RULE if profile == 'j71b' else overlay(profile) / network_rule(profile) + additions[network_rule(profile)] = (rule.read_bytes(), 0o644) additions['etc/orchard/release.json'] = (json.dumps({ - 'protocol': 1, 'release': release, 'profile': 'j71b', 'kernel_release': kernel_release}).encode(), 0o644) + 'protocol': 1, 'release': release, 'profile': profile, 'kernel_release': kernel_release}).encode(), 0o644) if privileges.keys() & additions.keys(): raise ValueError('Runtime must not override privileged package files') qml_path = 'home/tablet/.local/share/omarchy-ipad/shell/shell.qml' @@ -155,7 +175,7 @@ def build(base, base_receipt, modules_dir, dropbear, outdir, release, cursor_too seen, directories, expanded, count = set(), set(), 0, 0 with tempfile.TemporaryDirectory(dir=outdir.parent, prefix='.root-') as temp: temporary = Path(temp) - archive = temporary / 'rootfs.tar.zst' + archive = temporary / asset(profile, 'rootfs.tar.zst') with archive.open('wb') as raw, subprocess.Popen( ['zstd', '-T0', '-1', '--check', '-q', '-c'], stdin=subprocess.PIPE, stdout=raw) as compressor: with tarfile.open(fileobj=compressor.stdin, mode='w|') as target: @@ -221,7 +241,7 @@ def add_parents(name): member = tarfile.TarInfo(name) member.mode, member.size = mode, len(data) add(member, data) - for unit in ('j71b-charge.service', 'omarchy-diagnostics.service'): + for unit in device['units']: member = tarfile.TarInfo('etc/systemd/system/multi-user.target.wants/' + unit) member.type, member.linkname, member.mode = tarfile.SYMTYPE, '/usr/lib/systemd/system/' + unit, 0o777 if member.name not in seen: @@ -231,14 +251,15 @@ def add_parents(name): if compressor.wait(): raise ValueError('Public root compression failed') required = {'lib', 'usr/lib/systemd/systemd', 'etc/group', 'etc/gshadow', qml_path, - 'arch-ipad-ui-manifest.json', 'usr/libexec/j71b-check-platform', 'usr/libexec/omarchy-diagnostics'} + 'arch-ipad-ui-manifest.json', 'usr/libexec/' + profile + '-check-platform', + 'usr/libexec/omarchy-diagnostics'} if not required <= seen or not privileges.keys() <= seen or not replacements.keys() <= seen: raise ValueError('Incomplete public root or privileged package metadata') - receipt = {'schema': 1, 'kind': 'rootfs', 'profile': 'j71b', 'release': release, + receipt = {'schema': 1, 'kind': 'rootfs', 'profile': profile, 'release': release, 'bytes': archive.stat().st_size, 'sha256': sha(archive), 'compression': 'zstd', 'expanded_bytes': expanded, 'members': count, 'kernel_release': kernel_release, 'base_sha256': record['sha256']} - (temporary / 'rootfs.json').write_text(json.dumps(receipt, indent=2) + '\n') + (temporary / asset(profile, 'rootfs.json')).write_text(json.dumps(receipt, indent=2) + '\n') os.replace(temporary, outdir) return receipt @@ -248,8 +269,9 @@ def main(): for name in ('base', 'base-receipt', 'modules-dir', 'dropbear', 'outdir', 'cursor-tools', 'bluetooth-tools', 'labwc-tools'): parser.add_argument('--' + name, type=Path, required=True) parser.add_argument('--release', required=True) + parser.add_argument('--device', default='j71b') args = parser.parse_args() - print(json.dumps(build(args.base, args.base_receipt, args.modules_dir, args.dropbear, args.outdir, args.release, args.cursor_tools, args.bluetooth_tools, args.labwc_tools), indent=2)) + print(json.dumps(build(args.base, args.base_receipt, args.modules_dir, args.dropbear, args.outdir, args.release, args.cursor_tools, args.bluetooth_tools, args.labwc_tools, args.device), indent=2)) if __name__ == '__main__': diff --git a/release/j71b/recovery_audit.py b/release/j71b/recovery_audit.py index 3a17d3d..bf3a427 100644 --- a/release/j71b/recovery_audit.py +++ b/release/j71b/recovery_audit.py @@ -44,11 +44,11 @@ def read_newc(stream): yield name, mode, data -def audit_runtime(image, root_spec, release): +def audit_runtime(image, root_spec, release, profile='j71b'): receipt = json.loads(Path(str(image) + '.json').read_text()) part = next(row for row in receipt['components'] if row['name'] == 'initramfs') hashes, modes, records = {}, {}, {} - firmware = json.loads(firmware_path('j71b').read_text()) + firmware = json.loads(firmware_path(profile).read_text()) private = {row['destination'] for row in firmware['files'] + firmware['generated']} names = set() with image.open('rb') as raw: @@ -65,7 +65,7 @@ def audit_runtime(image, root_spec, release): hashes[name] = hashlib.sha256(data).hexdigest() if name in ('etc/orchard/release.json', 'etc/orchard/public.SHA256SUMS'): records[name] = data - prefix = 'usr/libexec/orchard-j71b/' + prefix = 'usr/libexec/orchard-' + profile + '/' required = {prefix + name for name in ('device.py', 'archive.py', 'staging.py', 'handoff.py', 'image_policy.py', 'permissions.py', 'storage.py', 'identity.py', 'adt.py')} required |= {'init', 'usr/bin/orchard-device', 'sbin/capture-adt', 'sbin/authorize-key', @@ -77,11 +77,11 @@ def audit_runtime(image, root_spec, release): 'usr/sbin/blkid', 'usr/sbin/sfdisk', 'usr/sbin/mke2fs'): if not stat.S_ISREG(modes[name]) or modes[name] & 0o111 != 0o111: raise ValueError('Recovery helper is not executable: ' + name) - if any('omarchy-j71b' in name or name == 'usr/bin/omarchy-device' for name in names): + if any('omarchy-' + profile in name or name == 'usr/bin/omarchy-device' for name in names): raise ValueError('Old agent namespace remains in recovery') config = json.loads(records['etc/orchard/release.json']) expected = {key: root_spec[key] for key in ('sha256', 'bytes', 'expanded_bytes', 'members', 'compression')} - if config['root'] != expected or config['release'] != release or config['protocol'] != 1 or config['profile'] != 'j71b': + if config['root'] != expected or config['release'] != release or config['protocol'] != 1 or config['profile'] != profile: raise ValueError('Recovery is not locked to the published root') public = records['etc/orchard/public.SHA256SUMS'].decode() for line in public.splitlines(): diff --git a/release/j71b/runtime.py b/release/j71b/runtime.py index 1e992a2..39cc774 100644 --- a/release/j71b/runtime.py +++ b/release/j71b/runtime.py @@ -5,5 +5,9 @@ PROJECT = Path(__file__).resolve().parents[2] -def files(modules_dir): - return hardware_files(modules_dir, PROJECT / 'image/j71b') +def files(modules_dir, profile='j71b'): + policy = PROJECT / 'image' / profile + if profile == 'j71b': + # Hardware releases before multi-device support take no profile. + return hardware_files(modules_dir, policy) + return hardware_files(modules_dir, policy, profile=profile) diff --git a/release/j71b/smoke-desktop-in-container b/release/j71b/smoke-desktop-in-container index 496e8da..c1e2f03 100644 --- a/release/j71b/smoke-desktop-in-container +++ b/release/j71b/smoke-desktop-in-container @@ -9,10 +9,10 @@ mount --bind /proc /candidate/proc mount --rbind /dev /candidate/dev install -d -m 700 -o 1000 -g 1000 /candidate/run/user/1000 # /candidate/sys is a private, unmounted directory, never host/device sysfs. -mkdir -p /candidate/sys/class/backlight/20e200080.backlight -printf '2047\n' >/candidate/sys/class/backlight/20e200080.backlight/max_brightness -printf '731\n' >/candidate/sys/class/backlight/20e200080.backlight/brightness -chown 1000:1000 /candidate/sys/class/backlight/20e200080.backlight/brightness +mkdir -p /candidate/sys/class/backlight/$OMA_BACKLIGHT +printf '2047\n' >/candidate/sys/class/backlight/$OMA_BACKLIGHT/max_brightness +printf '731\n' >/candidate/sys/class/backlight/$OMA_BACKLIGHT/brightness +chown 1000:1000 /candidate/sys/class/backlight/$OMA_BACKLIGHT/brightness chroot /candidate /usr/bin/setpriv --reuid=1000 --regid=1000 --clear-groups /usr/bin/sudo -n /usr/bin/id -u >/evidence/sudo.txt test "$(cat /evidence/sudo.txt)" = 0 printf 'PASSWORDLESS_SUDO_OK\n' >>/evidence/sudo.txt @@ -23,7 +23,7 @@ printf '73\n' >/candidate/tmp/power/BAT0/capacity printf 'Mains\n' >/candidate/tmp/power/AC/type printf '1\n' >/candidate/tmp/power/AC/online chroot /candidate /usr/bin/setpriv --reuid=1000 --regid=1000 --clear-groups /usr/bin/env OMARCHY_POWER_SUPPLY_PATH=/tmp/power /usr/local/bin/omarchy-battery-status --shell >/evidence/battery.txt -chroot /candidate /usr/bin/python3 -c 'import sys; sys.path.insert(0,"/usr/libexec/orchard-j71b"); from permissions import verify,verify_session_access; print("Verified privileged entries:",verify("/")); verify_session_access("/")' +chroot /candidate /usr/bin/python3 -c 'import sys; sys.path.insert(0,"/usr/libexec/orchard-'"$OMA_PROFILE"'"); from permissions import verify,verify_session_access; print("Verified privileged entries:",verify("/")); verify_session_access("/")' chroot /candidate /usr/bin/setpriv --reuid=1000 --regid=1000 --clear-groups /usr/bin/env HOME=/home/tablet USER=tablet XDG_RUNTIME_DIR=/run/user/1000 XDG_SESSION_TYPE=wayland WLR_BACKENDS=headless WLR_RENDERER=pixman WLR_LIBINPUT_NO_DEVICES=1 LABWC_UPDATE_ACTIVATION_ENV=0 OMARCHY_POWER_SUPPLY_PATH=/tmp/power /usr/bin/dbus-run-session /usr/bin/labwc >/evidence/labwc.log 2>&1 & scene=$! trap 'kill "$scene" 2>/dev/null || true' EXIT @@ -43,10 +43,10 @@ python3 -c 'import json; r=json.load(open("/evidence/status.json")); assert r["r for expected in 0 731; do chroot /candidate /usr/bin/setpriv --reuid=1000 --regid=1000 --clear-groups /usr/bin/env XDG_RUNTIME_DIR=/run/user/1000 WAYLAND_DISPLAY=wayland-0 /usr/libexec/omarchy-wtype -k XF86PowerOff for attempt in $(seq 1 40); do - test "$(cat /candidate/sys/class/backlight/20e200080.backlight/brightness)" != "$expected" || break + test "$(cat /candidate/sys/class/backlight/$OMA_BACKLIGHT/brightness)" != "$expected" || break sleep .05 done - test "$(cat /candidate/sys/class/backlight/20e200080.backlight/brightness)" = "$expected" + test "$(cat /candidate/sys/class/backlight/$OMA_BACKLIGHT/brightness)" = "$expected" printf '%s\n' "$expected" >>/evidence/power-key-toggle.txt done printf 'POWER_KEY_BACKLIGHT_ROUND_TRIP_OK\n' >>/evidence/power-key-toggle.txt diff --git a/release/j71b/smoke_desktop.py b/release/j71b/smoke_desktop.py index 99c4a51..3119a5f 100644 --- a/release/j71b/smoke_desktop.py +++ b/release/j71b/smoke_desktop.py @@ -5,10 +5,11 @@ import subprocess from .audit import audit +from release.devices import device as device_spec from release.manifests import read_release -def main(): +def main(device='j71b'): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument('--manifest', required=True, type=Path) parser.add_argument('--output', required=True, type=Path, help='new evidence directory') @@ -21,7 +22,7 @@ def main(): if args.output.exists(): parser.error('Choose a new evidence directory') # Verify namespace safety and all published digests before using GNU tar. - checked = audit(manifest) + checked = audit(manifest, device) record = read_release(manifest) args.output.mkdir(parents=True) output = args.output.resolve() @@ -29,6 +30,7 @@ def main(): argv = ['podman', 'run', '--rm', '--network', 'none', '--userns', 'keep-id:uid=0,gid=0', '--cap-add', 'SYS_ADMIN', '--cap-add', 'SYS_PTRACE', '--security-opt', 'seccomp=unconfined', '-e', 'OMA_ROOT_FILENAME=' + record['assets']['root']['file'], + '-e', 'OMA_PROFILE=' + device, '-e', 'OMA_BACKLIGHT=' + device_spec(device)['backlight'], '-v', str(manifest.parent) + ':/release:ro', '-v', str(recipe) + ':/validate.sh:ro', '-v', str(output) + ':/evidence:rw', args.image, 'sh', '/validate.sh'] if args.pointer_helper: diff --git a/release/j71t/audit.py b/release/j71t/audit.py new file mode 100644 index 0000000..8d7e055 --- /dev/null +++ b/release/j71t/audit.py @@ -0,0 +1,5 @@ +"""Offline audit of finished J71t assets with the shared release auditor.""" +from release.j71b.audit import main + +if __name__ == '__main__': + main('j71t') diff --git a/release/j71t/build.py b/release/j71t/build.py new file mode 100644 index 0000000..363cd5b --- /dev/null +++ b/release/j71t/build.py @@ -0,0 +1,6 @@ +#!/usr/bin/env python3 +"""Produce one coherent public J71t release: root, recovery, Pongo and manifest.""" +from release.j71b.build import main + +if __name__ == '__main__': + main('j71t') diff --git a/release/j71t/smoke_desktop.py b/release/j71t/smoke_desktop.py new file mode 100644 index 0000000..3a35898 --- /dev/null +++ b/release/j71t/smoke_desktop.py @@ -0,0 +1,5 @@ +"""Run the public J71t userspace headlessly in Podman; never access an iPad.""" +from release.j71b.smoke_desktop import main + +if __name__ == '__main__': + main('j71t') diff --git a/release/manifests.py b/release/manifests.py index 416742b..d809bfa 100644 --- a/release/manifests.py +++ b/release/manifests.py @@ -1,5 +1,6 @@ -"""Publish a single-target TOML manifest and its pinned index.""" +"""Publish per-device TOML manifests and their pinned index.""" import json +import os from pathlib import Path try: import tomllib @@ -7,35 +8,68 @@ import tomli as tomllib from orchard_hardware.image.integrity import sha +from release.devices import asset, device as device_spec + +METHOD = 'checkm8-pongo' def receipt(path): return {'file': path.name, 'bytes': path.stat().st_size, 'sha256': sha(path)} -def write(output, version, root): +def write(output, version, root, device='j71b'): + device_spec(device) document = ('schema = 1\nkind = "orchard.install"\ndistro = "omarchy-a"\n' 'name = "Omarchy A"\nversion = ' + json.dumps(version) + '\n' - 'device = "j71b"\nboot_method = "checkm8-pongo"\n' + 'device = ' + json.dumps(device) + '\nboot_method = "' + METHOD + '"\n' 'min_orchard = "0.1.5"\nprotocol = 1\n\n' '[install]\ndefault_root = "/omarchy"\n') for role, name in (('root', 'rootfs.tar.zst'), ('boot', 'recovery.bin'), ('pongo', 'pongo.bin')): - item = receipt(output / name) + item = receipt(output / asset(device, name)) if role == 'root': item.update({key: root[key] for key in ('compression', 'expanded_bytes', 'members')}) document += '\n[' + role + ']\n' document += ''.join(key + ' = ' + json.dumps(value) + '\n' for key, value in item.items()) - manifest = output / 'j71b-checkm8-pongo.toml' + manifest = output / (device + '-' + METHOD + '.toml') with manifest.open('x') as stream: stream.write(document) - pin = receipt(manifest) - index = 'schema = 1\nkind = "orchard.index"\ndistro = "omarchy-a"\n\n[devices.j71b.checkm8-pongo]\n' - index += ''.join(key + ' = ' + json.dumps(value) + '\n' for key, value in pin.items()) - with (output / 'index.toml').open('x') as stream: - stream.write(index) + write_index(output, [manifest]) return manifest +def write_index(output, manifests, *, replace=False): + """Pin sibling per-device manifests, one boot method each, sorted by device.""" + pins = {} + for path in manifests: + path = Path(path) + value = tomllib.loads(path.read_text()) + profile = value.get('device') + if (path.parent.resolve() != Path(output).resolve() or path.is_symlink() or + value.get('kind') != 'orchard.install' or value.get('distro') != 'omarchy-a' or + value.get('boot_method') != METHOD or path.name != str(profile) + '-' + METHOD + '.toml'): + raise ValueError('Expected a sibling Omarchy installation manifest: ' + path.name) + device_spec(profile) + if profile in pins: + raise ValueError('Duplicate device in release index: ' + profile) + pins[profile] = receipt(path) + if not pins: + raise ValueError('A release index needs at least one device') + index = 'schema = 1\nkind = "orchard.index"\ndistro = "omarchy-a"\n' + for profile in sorted(pins): + index += '\n[devices.' + profile + '.' + METHOD + ']\n' + index += ''.join(key + ' = ' + json.dumps(value) + '\n' for key, value in pins[profile].items()) + path = Path(output) / 'index.toml' + if not replace: + with path.open('x') as stream: + stream.write(index) + return path + pending = path.with_name('.index.toml.pending') + with pending.open('x') as stream: + stream.write(index) + os.replace(pending, path) + return path + + def read_release(manifest): """Read installation metadata for artifact audits and smoke tests.""" value = tomllib.loads(Path(manifest).read_text()) diff --git a/release/merge.py b/release/merge.py new file mode 100644 index 0000000..dd26108 --- /dev/null +++ b/release/merge.py @@ -0,0 +1,44 @@ +#!/usr/bin/env python3 +"""Combine separately built per-device releases into one directory and index.""" +import argparse +from pathlib import Path +import shutil + +from orchard_hardware.image.integrity import sha +from release.manifests import METHOD, write_index + + +def merge(primary, others): + """Copy every file except the index; refuse differing names. Pongo is shared.""" + copies = {} + for directory in others: + if directory.is_symlink() or not directory.is_dir() or directory.resolve() == primary.resolve(): + raise ValueError('Expected a separate release directory: ' + str(directory)) + for source in sorted(directory.iterdir()): + if source.name == 'index.toml': + continue + if source.is_symlink() or not source.is_file(): + raise ValueError('Release directories contain only regular files: ' + source.name) + target = primary / source.name + existing = copies.get(target, target if target.exists() or target.is_symlink() else None) + if existing is not None: + if existing.is_symlink() or sha(existing) != sha(source): + raise ValueError('Conflicting release file: ' + source.name) + continue + copies[target] = source + # Every conflict is refused before the first copy. + for target, source in copies.items(): + shutil.copyfile(source, target) + return write_index(primary, sorted(primary.glob('*-' + METHOD + '.toml')), replace=True) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--release-dir', required=True, type=Path, help='existing release receiving the others') + parser.add_argument('--add', required=True, type=Path, action='append', help='another device release directory') + args = parser.parse_args() + print(merge(args.release_dir, args.add)) + + +if __name__ == '__main__': + main() diff --git a/system/j71b/build-rootfs b/system/j71b/build-rootfs index d72beb5..a8c8a70 100755 --- a/system/j71b/build-rootfs +++ b/system/j71b/build-rootfs @@ -1,12 +1,13 @@ #!/usr/bin/env bash set -euo pipefail -root= output= desktop= release= +root= output= desktop= release= packages= while (($#)); do case "$1" in --root) root=$2; shift 2;; --output) output=$2; shift 2;; --desktop) desktop=$2; shift 2;; --release) release=$2; shift 2;; + --packages) packages=$2; shift 2;; *) echo "Unknown argument: $1" >&2; exit 2;; esac done @@ -16,10 +17,12 @@ done [[ $(uname -m) == aarch64 && $EUID == 0 ]] || { echo 'Requires an aarch64 Arch Linux ARM build environment as root' >&2; exit 1; } for cmd in pacman useradd usermod systemctl tar python3; do command -v "$cmd" >/dev/null || { echo "Missing $cmd" >&2; exit 1; }; done project=$(cd "$(dirname "$0")/../.." && pwd) +# Devices sharing this userspace recipe supply their own package list. +packages=${packages:-$project/system/j71b/packages.txt} root=$(realpath -m -- "$root") output=$(realpath -m -- "$output") [[ "$root" != / && ! -e "$root" && ! -e "$output" ]] || { echo 'Root and archive must be new paths' >&2; exit 1; } -mapfile -t requested < <(grep -vE '^\s*(#|$)' "$project/system/j71b/packages.txt") +mapfile -t requested < <(grep -vE '^\s*(#|$)' "$packages") [[ ${#requested[@]} -gt 0 ]] || exit 1 printf '%s\n' "${requested[@]}" | grep -qx 'linux-aarch64' && { echo 'Generic kernel is not an input' >&2; exit 1; } diff --git a/system/j71b/overlay/usr/libexec/omarchy-screen-toggle b/system/j71b/overlay/usr/libexec/omarchy-screen-toggle index f8644d9..067245d 100644 --- a/system/j71b/overlay/usr/libexec/omarchy-screen-toggle +++ b/system/j71b/overlay/usr/libexec/omarchy-screen-toggle @@ -1,11 +1,21 @@ #!/usr/bin/python3 -"""J71b backlight toggle only: no suspend, session lock, or input changes.""" +"""Built-in iPad panel backlight toggle only: no suspend, session lock, or input changes.""" import fcntl import os from pathlib import Path import stat import sys +# DWI panel backlights: J71b, J71t. Exactly one exists on a supported device. +PANELS = ('20e200080.backlight', '20e200000.backlight') + + +def panel(base=Path('/sys/class/backlight')): + found = [base / name for name in PANELS if (base / name).is_dir()] + if len(found) != 1: + raise ValueError('Expected one built-in panel backlight') + return found[0] + def toggle(device, runtime): info = runtime.lstat() @@ -40,9 +50,8 @@ def toggle(device, runtime): def main(): # Keep physical output selection device-owned; never toggle unrelated displays. - device = Path('/sys/class/backlight/20e200080.backlight') runtime = Path(os.environ['XDG_RUNTIME_DIR']) - toggle(device, runtime) + toggle(panel(), runtime) if __name__ == '__main__': diff --git a/system/j71t/README.md b/system/j71t/README.md new file mode 100644 index 0000000..c62d83b --- /dev/null +++ b/system/j71t/README.md @@ -0,0 +1,14 @@ +# J71t installed system + +iPad 5 Wi-Fi (`iPad6,11`, J71t/S8003). See [docs/devices.md](../../docs/devices.md) for what works. + +- `packages.txt`: required signed Arch Linux ARM userspace packages; do not install the generic Arch kernel. +- `build-rootfs`: the J71b userspace recipe with this package list. +- `overlay/`: platform gate, framebuffer brightness permissions, USB diagnostics networking and the tablet network rule. +- `release/j71b/public_root.py --device j71t`: combine the base, public runtime/modules, the J71b layer's device-independent session files (`release/devices.py`) and this overlay. + +The hardware is prepared in recovery. `j71t-platform-ready.service` runs the hardware runtime's read-only `j71t-check-platform` before the desktop session starts; diagnostics start independently. + +Unlike J71b there is no charging service: the J71t charger policy runs in the kernel. Suspend stays disabled, as on J71b. The panel backlight is `20e200000.backlight` (the J71t kernel maps the whole DWI block); the power-key screen toggle selects it automatically. + +The tablet layer of the separate omarchy-ipad5 Hyprland image is deliberately not included: its gestures and on-screen keyboard are Hyprland-specific (Omarchy A has its own labwc keyboard), and auto-rotation needs the AOP accelerometer driver, which this port does not ship. diff --git a/system/j71t/build-rootfs b/system/j71t/build-rootfs new file mode 100755 index 0000000..26b7ede --- /dev/null +++ b/system/j71t/build-rootfs @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +# J71t shares the J71b userspace recipe; only the package list is its own. +set -euo pipefail +here=$(cd "$(dirname "$0")" && pwd) +exec "$here/../j71b/build-rootfs" --packages "$here/packages.txt" "$@" diff --git a/system/j71t/overlay/etc/polkit-1/rules.d/60-j71t-tablet-network.rules b/system/j71t/overlay/etc/polkit-1/rules.d/60-j71t-tablet-network.rules new file mode 100644 index 0000000..8232383 --- /dev/null +++ b/system/j71t/overlay/etc/polkit-1/rules.d/60-j71t-tablet-network.rules @@ -0,0 +1,7 @@ +// The local active tablet session may save Wi-Fi connections through NetworkManager. +// Do not grant remote/inactive sessions, other users, sudo or blanket device control. +polkit.addRule(function(action, subject) { + if (action.id === "org.freedesktop.NetworkManager.settings.modify.system" && + subject.user === "tablet" && subject.local && subject.active) + return polkit.Result.YES; +}); diff --git a/system/j71t/overlay/etc/systemd/system/getty@tty1.service.d/20-j71t-platform.conf b/system/j71t/overlay/etc/systemd/system/getty@tty1.service.d/20-j71t-platform.conf new file mode 100644 index 0000000..a478cac --- /dev/null +++ b/system/j71t/overlay/etc/systemd/system/getty@tty1.service.d/20-j71t-platform.conf @@ -0,0 +1,3 @@ +[Unit] +Requires=j71t-platform-ready.service +After=j71t-platform-ready.service diff --git a/system/j71t/overlay/usr/lib/NetworkManager/conf.d/30-j71t-usb.conf b/system/j71t/overlay/usr/lib/NetworkManager/conf.d/30-j71t-usb.conf new file mode 100644 index 0000000..c7be7b4 --- /dev/null +++ b/system/j71t/overlay/usr/lib/NetworkManager/conf.d/30-j71t-usb.conf @@ -0,0 +1,3 @@ +[device-j71t-usb] +match-device=interface-name:usb0 +managed=0 diff --git a/system/j71t/overlay/usr/lib/systemd/system/j71t-platform-ready.service b/system/j71t/overlay/usr/lib/systemd/system/j71t-platform-ready.service new file mode 100644 index 0000000..0890c74 --- /dev/null +++ b/system/j71t/overlay/usr/lib/systemd/system/j71t-platform-ready.service @@ -0,0 +1,11 @@ +[Unit] +Description=Verify J71t hardware payload and boot activation +After=systemd-udev-trigger.service +Before=getty@tty1.service + +[Service] +Type=oneshot +ExecStartPre=/usr/bin/rm -f /run/ipadlinux/platform-verified +ExecStart=/usr/libexec/j71t-check-platform +ExecStartPost=/usr/bin/touch /run/ipadlinux/platform-verified +RemainAfterExit=yes diff --git a/system/j71t/overlay/usr/lib/tmpfiles.d/30-j71t-battery.conf b/system/j71t/overlay/usr/lib/tmpfiles.d/30-j71t-battery.conf new file mode 100644 index 0000000..e897e65 --- /dev/null +++ b/system/j71t/overlay/usr/lib/tmpfiles.d/30-j71t-battery.conf @@ -0,0 +1,3 @@ +# Prompt battery/charge-state updates through standard power_supply uevents. +# The J71t battery gauge driver is built in, so set its parameter at runtime. +w /sys/module/bq27xxx_battery/parameters/poll_interval - - - - 10 diff --git a/system/j71t/overlay/usr/lib/udev/rules.d/70-j71t-backlight.rules b/system/j71t/overlay/usr/lib/udev/rules.d/70-j71t-backlight.rules new file mode 100644 index 0000000..54eaa9d --- /dev/null +++ b/system/j71t/overlay/usr/lib/udev/rules.d/70-j71t-backlight.rules @@ -0,0 +1,4 @@ +# Device-specific write permission, no root helper or blanket sudo. +# The Arch session contract currently includes tablet in video. The J71t +# kernel maps the whole DWI block, so the panel is 20e200000, not J71b's 20e200080. +ACTION=="add", SUBSYSTEM=="backlight", KERNEL=="20e200000.backlight", 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/j71t/packages.txt b/system/j71t/packages.txt new file mode 100644 index 0000000..33988b2 --- /dev/null +++ b/system/j71t/packages.txt @@ -0,0 +1,36 @@ +# Arch Linux ARM userspace only. NEVER install linux-aarch64 in the iPad root. +# Baseline; exact dependency snapshot comes from an empty pacman DB preview. +# Same desktop userspace as J71b: no list entry is J71b-specific. J71t audio +# (CS42L81 speakers, microphones) uses PipeWire's default ALSA profile. +base +archlinuxarm-keyring +pacman +sudo +python +labwc +quickshell +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 diff --git a/tests/test_j71t_layer.py b/tests/test_j71t_layer.py new file mode 100644 index 0000000..428e4be --- /dev/null +++ b/tests/test_j71t_layer.py @@ -0,0 +1,253 @@ +"""J71t image policy, system layer and root assembly; no device or Podman access.""" +import gzip +import hashlib +import io +import json +import os +from pathlib import Path +import runpy +import shutil +import stat +import subprocess +import sys +import tarfile +import tempfile +import unittest +from unittest.mock import patch + +import orchard_hardware +from orchard_hardware import firmware_path +from orchard_hardware.boot.initramfs.pack import record +from release.devices import SHARED_SYSTEM +from release.j71b import public_root +from release.j71b.recovery_audit import audit_runtime + +PROJECT = Path(__file__).resolve().parents[1] + + +def policy(profile): + paths = [str(PROJECT / 'image' / profile), str(Path(orchard_hardware.__file__).parent / 'runtime/j71b')] + sys.path[:0] = paths + try: + return runpy.run_path(paths[0] + '/image_policy.py') + finally: + for path in paths: + sys.path.remove(path) + + +class Policy(unittest.TestCase): + def test_root_requires_j71t_helpers_and_no_charge_lease(self): + validate = policy('j71t')['validate_handoff'] + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + for helper in ('usr/lib/systemd/systemd', 'usr/libexec/j71t-check-platform', + 'usr/libexec/omarchy-diagnostics'): + (root / helper).parent.mkdir(parents=True, exist_ok=True) + (root / helper).write_text('#!/bin/sh\n') + (root / helper).chmod(0o755) + validate(root) + (root / 'usr/libexec/j71t-check-platform').chmod(0o644) + with self.assertRaises(ValueError): + validate(root) + text = (PROJECT / 'image/j71t/image_policy.py').read_text() + self.assertNotIn('j71b', text) + self.assertNotIn('charge-lease', text) + + def test_permission_verifier_is_the_shared_j71b_policy(self): + self.assertEqual((PROJECT / 'image/j71t/permissions.py').read_bytes(), + (PROJECT / 'image/j71b/permissions.py').read_bytes()) + + +class SystemLayer(unittest.TestCase): + def test_shared_session_files_plus_device_overlay(self): + files = public_root.system_files('test-kernel', 'j71t') + own = {path.relative_to(PROJECT / 'system/j71t/overlay').as_posix() + for path in (PROJECT / 'system/j71t/overlay').rglob('*') if path.is_file()} + self.assertEqual(set(files), set(SHARED_SYSTEM) | own) + self.assertFalse([name for name in files if 'j71b' in name]) + for name in SHARED_SYSTEM: + self.assertEqual(files[name][0], (PROJECT / 'system/j71b/overlay' / name).read_bytes()) + self.assertEqual(files['etc/sudoers.d/90-tablet-passwordless'][1], 0o440) + self.assertEqual(files['usr/libexec/omarchy-screen-toggle'][1], 0o755) + self.assertIn(b'KERNEL=="20e200000.backlight"', files['usr/lib/udev/rules.d/70-j71t-backlight.rules'][0]) + self.assertIn(b'ExecStart=/usr/libexec/j71t-check-platform', + files['usr/lib/systemd/system/j71t-platform-ready.service'][0]) + self.assertEqual(files['etc/polkit-1/rules.d/60-j71t-tablet-network.rules'][0], + (PROJECT / 'system/j71b/overlay/etc/polkit-1/rules.d/60-j71b-tablet-network.rules').read_bytes()) + + def test_userspace_recipe_reuses_j71b_with_its_own_package_list(self): + wrapper = PROJECT / 'system/j71t/build-rootfs' + self.assertTrue(os.access(wrapper, os.X_OK)) + self.assertIn('--packages "$here/packages.txt"', wrapper.read_text()) + packages = [line for line in (PROJECT / 'system/j71t/packages.txt').read_text().splitlines() + if line.strip() and not line.startswith('#')] + self.assertNotIn('linux-aarch64', packages) + self.assertEqual(len(packages), len(set(packages))) + subprocess.run(['bash', '-n', str(wrapper)], check=True) + subprocess.run(['bash', '-n', str(PROJECT / 'system/j71b/build-rootfs')], check=True) + + +def tar_gz(entries): + output = io.BytesIO() + with tarfile.open(fileobj=output, mode='w:gz') as archive: + for name, kind, value in entries: + member = tarfile.TarInfo(name) + if kind == 'dir': + member.type, member.mode = tarfile.DIRTYPE, 0o755 + archive.addfile(member) + elif kind == 'symlink': + member.type, member.linkname = tarfile.SYMTYPE, value + archive.addfile(member) + else: + member.size, member.mode = len(value), 0o755 + archive.addfile(member, io.BytesIO(value)) + return output.getvalue() + + +class PublicRoot(unittest.TestCase): + def test_j71t_root_uses_profile_names_units_and_helpers(self): + replacements = public_root.desktop_replacements() + files = {name: b'old desktop content' for name in replacements} + files.update({'usr/lib/systemd/systemd': b'systemd', 'usr/bin/sudo': b'sudo', + 'etc/group': b'seat:x:1:\nvideo:x:2:\ninput:x:3:\n', + 'etc/gshadow': b'seat:!*::\nvideo:!*::\ninput:!*::\n'}) + files['arch-ipad-ui-manifest.json'] = json.dumps({ + name: hashlib.sha256(files[name]).hexdigest() for name in replacements}).encode() + parents = {str(p) for name in files for p in Path(name).parents if str(p) != '.'} + data = tar_gz([(name, 'dir', None) for name in sorted(parents)] + + [(name, 'file', value) for name, value in files.items()] + [('lib', 'symlink', 'usr/lib')]) + privileges = {'usr/bin/sudo': {'uid': 0, 'gid': 0, 'mode': 0o4755, 'type': 'file', + 'bytes': 4, 'sha256': hashlib.sha256(b'sudo').hexdigest()}} + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + base, receipt, dropbear = root / 'base.gz', root / 'base.json', root / 'dropbear' + base.write_bytes(data) + receipt.write_text(json.dumps({'bytes': len(data), 'sha256': hashlib.sha256(data).hexdigest()})) + dropbear.write_bytes(b'dropbear') + runtime = {'usr/libexec/j71t-check-platform': (b'platform', 0o755)} + with patch.object(public_root, 'privileged_entries', return_value=privileges), \ + patch.object(public_root, 'cursor_files', return_value={'usr/libexec/omarchy-wtype': (b'wtype', 0o755)}), \ + patch.object(public_root, 'bluetooth_files', return_value={}), \ + patch.object(public_root, 'labwc_files', return_value={}), \ + patch.object(public_root, 'runtime_files', return_value=('test', runtime)) as runtime_files: + result = public_root.build(base, receipt, root, dropbear, root / 'release', 'test', + root / 'cursor-tools', profile='j71t') + runtime_files.assert_called_once_with(root, 'j71t') + self.assertEqual(sorted(path.name for path in (root / 'release').iterdir()), + ['j71t-rootfs.json', 'j71t-rootfs.tar.zst']) + self.assertEqual(result['profile'], 'j71t') + self.assertEqual(json.loads((root / 'release/j71t-rootfs.json').read_text()), result) + raw = subprocess.check_output(['zstd', '-dq', '-c', str(root / 'release/j71t-rootfs.tar.zst')]) + with tarfile.open(fileobj=io.BytesIO(raw)) as bundle: + names = bundle.getnames() + release = json.load(bundle.extractfile('etc/orchard/release.json')) + wants = [name for name in names if name.startswith('etc/systemd/system/multi-user.target.wants/')] + self.assertEqual(release['profile'], 'j71t') + self.assertEqual(wants, ['etc/systemd/system/multi-user.target.wants/omarchy-diagnostics.service']) + self.assertIn('etc/polkit-1/rules.d/60-j71t-tablet-network.rules', names) + self.assertFalse([name for name in names if 'j71b' in name]) + + +class Loader(unittest.TestCase): + def test_loader_builds_the_selected_device_with_the_shared_splash(self): + for device, expected in ((None, 'j71b'), ('j71t', 'j71t')): + with self.subTest(device=device), tempfile.TemporaryDirectory() as directory: + fake = Path(directory) / 'orchard_hardware' + (fake / 'boot/m1n1').mkdir(parents=True) + (fake / '__init__.py').write_text('') + build = fake / 'boot/m1n1/build' + build.write_text('#!/bin/sh\nprintf "%s\\n" "$@" > "$(dirname "$0")/args"\n') + build.chmod(0o755) + header = Path(directory) / 'logo.h' + header.write_text('') + subprocess.run([sys.executable, str(PROJECT / 'boot/build-loader'), + *(['--device', device] if device else []), '--logo-header', str(header), + '--output', str(Path(directory) / 'm1n1.bin')], + check=True, env={**os.environ, 'PYTHONPATH': directory}) + args = (fake / 'boot/m1n1/args').read_text().splitlines() + self.assertEqual(args[0], expected) + patch = Path(args[args.index('--extra-patch') + 1]) + self.assertEqual(patch, PROJECT / 'boot/m1n1/patches/j71b/0002-omarchy-splash.patch') + self.assertTrue(patch.is_file()) + + +def gnu_sha256sum(): + if not shutil.which('sha256sum'): + return False + # Same invocation as ci/dependencies: the checklist arrives on stdin. + with tempfile.TemporaryDirectory() as directory: + (Path(directory) / 'empty').write_bytes(b'') + line = hashlib.sha256(b'').hexdigest() + ' ./empty\n' + return subprocess.run(['sha256sum', '--check', '--strict'], cwd=directory, input=line.encode(), + capture_output=True).returncode == 0 + + +class Components(unittest.TestCase): + @unittest.skipUnless(gnu_sha256sum(), 'GNU sha256sum (--strict) is required, as on CI') + def test_device_bundle_is_verified_and_extracted_beside_j71b(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + artifacts = root / 'artifacts' + (artifacts / 'components/kernel').mkdir(parents=True) + (artifacts / 'components/kernel/j71t.dtb').write_bytes(b'dtb') + bundle = artifacts / 'j71t-components.tar.gz' + with tarfile.open(bundle, 'w:gz') as archive: + archive.add(artifacts / 'components', arcname='components') + digest = hashlib.sha256(bundle.read_bytes()).hexdigest() + (artifacts / 'SHA256SUMS').write_text(digest + ' ./j71t-components.tar.gz\n') + work = root / 'work' + work.mkdir() + env = {**os.environ, 'HARDWARE_TAG': 'v0.2.0', 'CLI_TAG': 'v0.2.0', 'DEVICE': 'j71t', + 'HARDWARE_ARTIFACT_DIR': str(artifacts)} + subprocess.run(['bash', str(PROJECT / 'ci/dependencies'), '--components-only'], + cwd=work, env=env, check=True, capture_output=True) + self.assertEqual((work / 'out/j71t/components/kernel/j71t.dtb').read_bytes(), b'dtb') + self.assertFalse((work / '.venv').exists()) + (artifacts / 'SHA256SUMS').write_text('0' * 64 + ' ./j71t-components.tar.gz\n') + result = subprocess.run(['bash', str(PROJECT / 'ci/dependencies'), '--components-only'], + cwd=work, env=env, capture_output=True) + self.assertNotEqual(result.returncode, 0) + + +class Recovery(unittest.TestCase): + @staticmethod + def image(directory, profile, config_profile=None): + root = {'sha256': 'a' * 64, 'bytes': 1, 'expanded_bytes': 2, 'members': 1, 'compression': 'zstd'} + files = {name: b'executable' for name in ( + 'init', 'usr/bin/orchard-device', 'usr/bin/python3', 'sbin/capture-adt', + 'sbin/authorize-key', 'sbin/recovery-start-ssh', 'usr/sbin/blkid', 'usr/sbin/sfdisk', + 'usr/sbin/mke2fs', 'etc/mke2fs.conf')} + for name in ('device.py', 'archive.py', 'staging.py', 'handoff.py', 'image_policy.py', + 'permissions.py', 'storage.py', 'identity.py', 'adt.py'): + files['usr/libexec/orchard-' + profile + '/' + name] = b'# runtime\n' + files['etc/orchard/release.json'] = json.dumps({'root': root, 'release': 'test-1', 'protocol': 1, + 'profile': config_profile or profile}).encode() + files['etc/orchard/firmware.json'] = firmware_path(profile).read_bytes() + files['etc/orchard/public.SHA256SUMS'] = ''.join( + hashlib.sha256(data).hexdigest() + ' /' + name + '\n' for name, data in sorted(files.items())).encode() + output = io.BytesIO() + entries = sorted(files.items()) + for index, (name, data) in enumerate(entries, 1): + record(output, index, name, stat.S_IFREG | 0o755, data) + record(output, len(entries) + 1, 'TRAILER!!!', 0, b'') + output.write(b'\0' * 512) + image = Path(directory) / 'j71t-recovery.bin' + image.write_bytes(gzip.compress(output.getvalue())) + Path(str(image) + '.json').write_text(json.dumps({'components': [ + {'name': 'initramfs', 'offset': 0, 'bytes': image.stat().st_size}]})) + return image, root + + def test_recovery_is_locked_to_the_j71t_runtime_and_profile(self): + with tempfile.TemporaryDirectory() as directory: + image, root = self.image(directory, 'j71t') + self.assertGreater(audit_runtime(image, root, 'test-1', 'j71t')['members'], 0) + with self.assertRaises(ValueError): + audit_runtime(image, root, 'test-1') + with tempfile.TemporaryDirectory() as directory: + image, root = self.image(directory, 'j71t', config_profile='j71b') + with self.assertRaises(ValueError): + audit_runtime(image, root, 'test-1', 'j71t') + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_manifests_and_recovery.py b/tests/test_manifests_and_recovery.py index bed1062..3993e43 100644 --- a/tests/test_manifests_and_recovery.py +++ b/tests/test_manifests_and_recovery.py @@ -10,7 +10,8 @@ from orchard_hardware import firmware_path from orchard_hardware.boot.initramfs.pack import record -from release import manifests +from orchard_cli import manifests as manifests_schema +from release import devices, manifests, merge from release.j71b.recovery_audit import read_newc, audit_runtime @@ -43,6 +44,62 @@ def test_another_distro_is_not_accepted_by_omarchy_auditor_adapter(self): with self.assertRaises(ValueError): manifests.read_release(path) + @staticmethod + def device_release(output, device, pongo=b'pongo.bin'): + output.mkdir() + for name in ('rootfs.tar.zst', 'recovery.bin'): + (output / devices.asset(device, name)).write_bytes((device + name).encode()) + (output / 'pongo.bin').write_bytes(pongo) + return manifests.write(output, 'test-1', {'compression': 'zstd', 'expanded_bytes': 100, 'members': 3}, + device=device) + + def test_j71b_index_text_is_unchanged(self): + with tempfile.TemporaryDirectory() as directory: + path = self.device_release(Path(directory) / 'j71b', 'j71b') + pin = manifests.receipt(path) + self.assertEqual((path.parent / 'index.toml').read_text(), + 'schema = 1\nkind = "orchard.index"\ndistro = "omarchy-a"\n\n' + '[devices.j71b.checkm8-pongo]\nfile = "j71b-checkm8-pongo.toml"\n' + 'bytes = ' + str(pin['bytes']) + '\nsha256 = "' + pin['sha256'] + '"\n') + + def test_device_parameter_selects_manifest_and_distinct_asset_names(self): + with tempfile.TemporaryDirectory() as directory: + path = self.device_release(Path(directory) / 'j71t', 'j71t') + doc = manifests.tomllib.loads(path.read_text()) + self.assertEqual(path.name, 'j71t-checkm8-pongo.toml') + self.assertEqual(doc['device'], 'j71t') + self.assertEqual((doc['root']['file'], doc['boot']['file'], doc['pongo']['file']), + ('j71t-rootfs.tar.zst', 'j71t-recovery.bin', 'pongo.bin')) + self.assertEqual(manifests.read_release(path)['profile'], 'j71t') + index = manifests.tomllib.loads((path.parent / 'index.toml').read_text()) + self.assertEqual(list(index['devices']), ['j71t']) + with self.assertRaises(ValueError): + self.device_release(Path(directory) / 'other', 'j72t') + + def test_merged_index_pins_every_device_once(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + j71b = self.device_release(root / 'j71b', 'j71b') + j71t = self.device_release(root / 'j71t', 'j71t') + index = merge.merge(root / 'j71b', [root / 'j71t']) + document = manifests.tomllib.loads(index.read_text()) + manifests_schema.validate(document) + self.assertEqual(list(document['devices']), ['j71b', 'j71t']) + self.assertEqual(document['devices']['j71b']['checkm8-pongo'], manifests.receipt(j71b)) + self.assertEqual(document['devices']['j71t']['checkm8-pongo'], + manifests.receipt(root / 'j71b' / j71t.name)) + self.assertEqual(merge.merge(root / 'j71b', [root / 'j71t']).read_text(), index.read_text()) + with self.assertRaises(ValueError): + manifests.write_index(root / 'j71b', [j71b, j71b], replace=True) + with self.assertRaises(ValueError): + manifests.write_index(root / 'j71b', [j71t], replace=True) + self.device_release(root / 'conflict', 'j71t', pongo=b'different') + (root / 'conflict/extra.json').write_text('{}') + with self.assertRaises(ValueError): + merge.merge(root / 'j71b', [root / 'conflict']) + self.assertEqual((root / 'j71b/pongo.bin').read_bytes(), b'pongo.bin') + self.assertFalse((root / 'j71b/extra.json').exists()) + class RecoverySealing(unittest.TestCase): @staticmethod diff --git a/tests/test_publication.py b/tests/test_publication.py index 83faa14..8186139 100644 --- a/tests/test_publication.py +++ b/tests/test_publication.py @@ -5,6 +5,7 @@ import unittest from release import manifests +from release.merge import merge from release.publish import publish @@ -46,6 +47,27 @@ def test_corrupt_asset_cannot_be_published(self): publish(root / 'local', 'v1', root / 'public') self.assertFalse((root / 'public').exists()) + def test_merged_devices_publish_one_shared_pongo(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + self.bundle(root / 'local') + (root / 'j71t').mkdir() + for name in ('j71t-rootfs.tar.zst', 'j71t-recovery.bin', 'pongo.bin'): + (root / 'j71t' / name).write_bytes(name.encode()) + manifests.write(root / 'j71t', 'test-1', {'compression': 'zstd', 'expanded_bytes': 100, 'members': 3}, + device='j71t') + merge(root / 'local', [root / 'j71t']) + path = publish(root / 'local', 'v1.2.3', root / 'public') + index = manifests.tomllib.loads(path.read_text()) + self.assertEqual(sorted(index['devices']), ['j71b', 'j71t']) + doc = manifests.tomllib.loads((root / 'public/j71t-checkm8-pongo.toml').read_text()) + self.assertEqual(doc['device'], 'j71t') + self.assertTrue(doc['boot']['url'].endswith('/v1.2.3/j71t-recovery.bin')) + self.assertTrue(doc['pongo']['url'].endswith('/v1.2.3/pongo.bin')) + self.assertEqual(sorted(p.name for p in (root / 'public').iterdir()), [ + 'index.toml', 'j71b-checkm8-pongo.toml', 'j71t-checkm8-pongo.toml', 'j71t-recovery.bin', + 'j71t-rootfs.tar.zst', 'pongo.bin', 'recovery.bin', 'rootfs.tar.zst']) + if __name__ == '__main__': unittest.main() diff --git a/tests/test_screen_toggle.py b/tests/test_screen_toggle.py index 5c91ab5..5a8a8c5 100644 --- a/tests/test_screen_toggle.py +++ b/tests/test_screen_toggle.py @@ -9,7 +9,8 @@ PROJECT = Path(__file__).resolve().parents[1] HELPER = PROJECT / 'system/j71b/overlay/usr/libexec/omarchy-screen-toggle' -toggle = runpy.run_path(str(HELPER))['toggle'] +helper = runpy.run_path(str(HELPER)) +toggle, panel = helper['toggle'], helper['panel'] class ScreenToggleTests(unittest.TestCase): @@ -70,3 +71,21 @@ def test_binding_and_executable_are_in_public_root_inputs(self): {'name': 'Execute', 'command': '/usr/libexec/omarchy-screen-toggle'}) self.assertEqual(system_files('test')['usr/libexec/omarchy-screen-toggle'], (HELPER.read_bytes(), 0o755)) + self.assertEqual(system_files('test', 'j71t')['usr/libexec/omarchy-screen-toggle'], + (HELPER.read_bytes(), 0o755)) + + def test_only_the_single_built_in_panel_is_selected(self): + base = self.root / 'class' + base.mkdir() + with self.assertRaises(ValueError): + panel(base) + (base / 'unrelated.backlight').mkdir() + for name in ('20e200080.backlight', '20e200000.backlight'): + with self.subTest(name=name): + (base / name).mkdir() + self.assertEqual(panel(base), base / name) + (base / name).rmdir() + (base / '20e200080.backlight').mkdir() + (base / '20e200000.backlight').mkdir() + with self.assertRaises(ValueError): + panel(base)