diff --git a/BUILD.md b/BUILD.md index c6307cc..7db4af2 100644 --- a/BUILD.md +++ b/BUILD.md @@ -98,6 +98,21 @@ Choose new evidence directories. `wlrctl` is a test-only native AArch64 helper, `tools/with-source-denied --deny PATH [--allow INPUT_DIRECTORY] -- COMMAND...` optionally verifies that a command does not read a source checkout. +## N61 development + +N61 development uses the shared AArch64 desktop/base producers with its own NFS +root composition. See [the N61 build and staging instructions](system/n61/README.md). +The generated private root receipt feeds the hardware package's native NFS RAM +experiment. Its image policy preserves the USB network root and selects phone +scaling and memory defaults. + +The recorded N61 desktop build reached Arch Linux ARM and Labwc through USB/NFS +on two boots with distinct identities. Both reported zero failed system units. +The owner confirmed touch on each; the first covered the vertical edges and +on-screen keyboard entry. The exact tested artifacts and source revisions are +listed in the N61 instructions. USB/NFS tethering remains necessary, and +installation on the phone's storage and untethered startup are future work. + ## 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. diff --git a/cli b/cli index 77a9e96..2e11638 160000 --- a/cli +++ b/cli @@ -1 +1 @@ -Subproject commit 77a9e969ccb74a6081a8003d13af3b7828e67a2f +Subproject commit 2e11638b0a1921c6c6ce5bb99d4c96209932f911 diff --git a/system/j71b/overlay/usr/libexec/omarchy-desktop-ready b/desktop/configuration/desktop-ready similarity index 100% rename from system/j71b/overlay/usr/libexec/omarchy-desktop-ready rename to desktop/configuration/desktop-ready diff --git a/examples/n61-ram/README.md b/examples/n61-ram/README.md new file mode 100644 index 0000000..de6271c --- /dev/null +++ b/examples/n61-ram/README.md @@ -0,0 +1,71 @@ +# iPhone 6 RAM boot + +This experiment prepares an Orchard USB shell for `iPhone7,2` / N61AP, A8 +T7000, with CPID `0x7000` and board ID `0x06`. Its local `orchard.toml` +selects the `n61` hardware descriptor and `apple-4k` kernel platform. + +The first physical boot passed on 28 October 2026. The USB root shell +reported `7.3.0-rc1-orchard-apple-4k` with 4 KiB pages and both Typhoon CPUs +online. Linux text was visible on the 750 × 1334 LCD through simpledrm. + +That boot used PongoOS `2.6.3-e98323f8` bundled in official palera1n v2.4. +Its 238,104-byte image has SHA-256 +`5475c5f701423858b34e92176c966a9d4b12950f38acb8d1c347f14d5b272655`. +The default source-built Pongo's A8 entry remains under investigation. +Use the verified bundled image to reproduce the recorded boot. + +From the Omarchy A checkout, build before entering DFU, supplying that +Pongo image through the existing component override: + +```sh +./orchard build examples/n61-ram --pongo /path/to/Pongo-2.6.3-e98323f8.bin +``` + +The hardware and CLI sources must contain N61 support. On macOS, place Linux +source and compiler scratch on a case-sensitive filesystem. The shared +producer uses the pinned Linux revision and patch series, then assembles +BusyBox and this directory's `/init` through the usual RAM builder. + +Keep the iPhone 6 as the only connected 64-bit iOS device. The recorded +test preserved a connected 32-bit iPad mini in normal iOS. Enter a fresh +DFU session using palera1n's guided countdown, then run the upload: + +```sh +./orchard run examples/n61-ram +``` + +Inspect the displayed model and identity before confirming. Orchard verifies +the payload and uploads once through the shared Pongo transport. On an +Apple Silicon Mac using USB-C, watch `examples/n61-ram/out/palera1n.log` live and reconnect +the phone's Lightning plug immediately after `Checkmate!`. The recorded +successful attempt reached download mode three seconds after that cue. + +After upload, run `./orchard devices` and find the `Orchard n61-ram` +product. macOS IOKit can present its name as `Orchard n61_ram`. Use that +device's `serial_port` value to connect to the USB shell. + +The experiment mounts pseudo-filesystems, checks `apple,n61` and exposes an +ACM root shell. Its USB descriptor carries the current boot ID. Automatic +module loading stays disabled, and the image leaves internal storage inactive. + +At the shell, record the kernel and page size: + +```sh +uname -a +grep -m 1 KernelPageSize /proc/self/smaps +cat /sys/devices/system/cpu/online +tr '\000' '\n' /dev/console 2>&1 +echo /bin/false > /proc/sys/kernel/modprobe +if ! tr '\000' '\n' "$g/idVendor" +# The prototype PID matches the generic RAM example. +echo 0x4144 > "$g/idProduct" +mkdir -p "$g/strings/0x409" "$g/configs/c.1/strings/0x409" +echo "Orchard $name" > "$g/strings/0x409/product" +cat /proc/sys/kernel/random/boot_id > "$g/strings/0x409/serialnumber" +echo 'N61 physical USB diagnostic shell' > "$g/configs/c.1/strings/0x409/configuration" +# USB descriptor value; charger control requires a separate driver. +echo 100 > "$g/configs/c.1/MaxPower" +mkdir "$g/functions/acm.usb0" +ln -s "$g/functions/acm.usb0" "$g/configs/c.1/acm.usb0" +set -- /sys/class/udc/* +[ "$#" -eq 1 ] && [ -e "$1" ] +printf '%s\n' "${1##*/}" > "$g/UDC" +echo "$name: USB root shell for a trusted host." +while :; do + setsid sh -i /dev/ttyGS0 2>&1 || true + sleep 1 +done diff --git a/examples/n61-ram/orchard.toml b/examples/n61-ram/orchard.toml new file mode 100644 index 0000000..5e8a9d8 --- /dev/null +++ b/examples/n61-ram/orchard.toml @@ -0,0 +1 @@ +device = "n61" diff --git a/examples/n61-ram/rootfs/etc/motd b/examples/n61-ram/rootfs/etc/motd new file mode 100644 index 0000000..f06ed36 --- /dev/null +++ b/examples/n61-ram/rootfs/etc/motd @@ -0,0 +1,5 @@ +Orchard iPhone 6 RAM experiment + +This image exposes a root shell over USB ACM. Use a trusted host. +The startup script mounts RAM interfaces and leaves device storage inactive. +Display and USB operation await a physical N61 test. Touch support needs work. diff --git a/hardware b/hardware index aa69bbb..1fef11c 160000 --- a/hardware +++ b/hardware @@ -1 +1 @@ -Subproject commit aa69bbbf52aa3bf2d753357e70f11fc88e9e716f +Subproject commit 1fef11c8922b3e11e263feb75ba1d7b3d95a2022 diff --git a/image/n61/image_policy.py b/image/n61/image_policy.py new file mode 100644 index 0000000..e2dd194 --- /dev/null +++ b/image/n61/image_policy.py @@ -0,0 +1,34 @@ +"""Omarchy A's N61 desktop and NFS image contract.""" +import json +import os +from pathlib import Path + +PROFILE = 'n61' +ARCHITECTURE = 'aarch64' +KERNEL_RELEASE = '7.3.0-rc1-orchard-apple-4k' +INIT = '/usr/lib/systemd/systemd' +PANEL = (750, 1334) +SCALE = 1.5 + + +def ready(): + 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 + process = Path('/proc') / str(int(record['shell_pid'])) + socket = Path(record['wayland_socket']) + return (process.stat().st_uid == 1000 and + (process / 'exe').readlink().name in ('qs', 'quickshell') and + socket.is_socket() and socket.stat().st_uid == 1000) + except (OSError, ValueError, KeyError): + return False + + +def validate_root(root): + for helper in ('usr/lib/systemd/systemd', 'usr/libexec/omarchy-desktop-ready'): + if not os.access(root / helper, os.X_OK): + raise ValueError('N61 root lacks executable ' + helper) diff --git a/release/desktop.py b/release/desktop.py new file mode 100644 index 0000000..59a99fe --- /dev/null +++ b/release/desktop.py @@ -0,0 +1,83 @@ +"""Receipt-checked desktop components shared by AArch64 images.""" +import hashlib +import json +from pathlib import Path, PurePosixPath + + +def sha(path): + return hashlib.sha256(Path(path).read_bytes()).hexdigest() + + +def safe_name(name): + path = PurePosixPath(name) + if path.is_absolute() or ".." in path.parts or not path.parts: + raise ValueError("Unsafe desktop component path") + return str(path) + + +def cursor_files(directory, project): + record = json.loads((directory / 'manifest.json').read_text()) + pin = json.loads((project / 'desktop/cursor/wtype.json').read_text()) + if record.get('schema') != 1 or record.get('architecture') != 'aarch64' or record.get('source') != pin: + raise ValueError('Incompatible startup cursor helper receipt') + files = {} + for name, destination, mode in ( + ('wtype', 'usr/libexec/omarchy-wtype', 0o755), + ('LICENSE', 'usr/share/licenses/omarchy-wtype/LICENSE', 0o644)): + path = directory / name + if path.is_symlink() or not path.is_file(): + raise ValueError('Invalid startup cursor helper file: ' + name) + data = path.read_bytes() + if hashlib.sha256(data).hexdigest() != record['files'].get(name): + raise ValueError('Startup cursor helper differs from receipt: ' + name) + files[destination] = (data, mode) + files['etc/omarchy/cursor-helper.json'] = (json.dumps(record, sort_keys=True).encode(), 0o644) + return files + + +def bluetooth_files(directory, project): + record = json.loads((directory / 'manifest.json').read_text()) + pin = json.loads((project / 'desktop/bluetooth/dbus-next.json').read_text()) + if record.get('schema') != 1 or record.get('source') != pin: + raise ValueError('Incompatible Bluetooth dependency receipt') + files = {} + if not {'lib/dbus_next/__init__.py', 'LICENSE'} <= record['files'].keys(): + raise ValueError('Incomplete Bluetooth dependency receipt') + for name, digest in record['files'].items(): + if name != 'LICENSE' and not (name.startswith('lib/dbus_next/') and name.endswith('.py')): + raise ValueError('Unexpected Bluetooth dependency file') + if safe_name(name) != name: + raise ValueError('Unsafe Bluetooth dependency path') + path = directory / name + if path.is_symlink() or not path.is_file(): + raise ValueError('Invalid Bluetooth dependency file') + data = path.read_bytes() + if hashlib.sha256(data).hexdigest() != digest: + raise ValueError('Bluetooth dependency differs from receipt') + destination = ('usr/share/licenses/omarchy-bluetooth-dbus-next/LICENSE' if name == 'LICENSE' + else 'usr/lib/omarchy-bluetooth/' + name.removeprefix('lib/')) + files[destination] = (data, 0o644) + files['etc/omarchy/bluetooth-helper.json'] = (json.dumps(record, sort_keys=True).encode(), 0o644) + return files + + +def labwc_files(directory, project): + record = json.loads((directory / 'manifest.json').read_text()) + pin = json.loads((project / 'desktop/labwc/source.json').read_text()) + if record.get('schema') != 1 or record.get('source') != pin: + raise ValueError('Incompatible Labwc component receipt') + expected = {'labwc', 'LICENSE', 'source.tar.gz', '0001-cancel-super-tap-on-pointer.patch', + 'packages.lock', 'compiler.txt', 'version.txt'} + if record.get('files', {}).keys() != expected: + raise ValueError('Incomplete Labwc component') + files = {} + for name, digest in record['files'].items(): + path = directory / name + if path.is_symlink() or not path.is_file() or sha(path) != digest: + raise ValueError('Labwc component differs from receipt') + destination = 'usr/bin/labwc' if name == 'labwc' else 'usr/share/licenses/omarchy-labwc/' + name + files[destination] = (path.read_bytes(), 0o755 if name == 'labwc' else 0o644) + if record['files']['source.tar.gz'] != pin['sha256'] or record['files']['0001-cancel-super-tap-on-pointer.patch'] != pin['patch_sha256']: + raise ValueError('Labwc corresponding source differs from pin') + files['etc/omarchy/labwc-helper.json'] = (json.dumps(record, sort_keys=True).encode(), 0o644) + return files diff --git a/release/j71b/public_root.py b/release/j71b/public_root.py index edeac69..efe14c0 100644 --- a/release/j71b/public_root.py +++ b/release/j71b/public_root.py @@ -11,6 +11,7 @@ from .common import group_membership, SYSTEM, NETWORK_RULE from .runtime import PROJECT, files as runtime_files +from release import desktop as desktop_components from release.package_permissions import privileged_entries, apply as apply_package_permissions @@ -43,71 +44,15 @@ def desktop_replacements(): def cursor_files(directory): - record = json.loads((directory / 'manifest.json').read_text()) - pin = json.loads((PROJECT / 'desktop/cursor/wtype.json').read_text()) - if record.get('schema') != 1 or record.get('architecture') != 'aarch64' or record.get('source') != pin: - raise ValueError('Incompatible startup cursor helper receipt') - files = {} - for name, destination, mode in ( - ('wtype', 'usr/libexec/omarchy-wtype', 0o755), - ('LICENSE', 'usr/share/licenses/omarchy-wtype/LICENSE', 0o644)): - path = directory / name - if path.is_symlink() or not path.is_file(): - raise ValueError('Invalid startup cursor helper file: ' + name) - data = path.read_bytes() - if hashlib.sha256(data).hexdigest() != record['files'].get(name): - raise ValueError('Startup cursor helper differs from receipt: ' + name) - files[destination] = (data, mode) - files['etc/omarchy/cursor-helper.json'] = (json.dumps(record, sort_keys=True).encode(), 0o644) - return files + return desktop_components.cursor_files(directory, PROJECT) def bluetooth_files(directory): - record = json.loads((directory / 'manifest.json').read_text()) - pin = json.loads((PROJECT / 'desktop/bluetooth/dbus-next.json').read_text()) - if record.get('schema') != 1 or record.get('source') != pin: - raise ValueError('Incompatible Bluetooth dependency receipt') - files = {} - if not {'lib/dbus_next/__init__.py', 'LICENSE'} <= record['files'].keys(): - raise ValueError('Incomplete Bluetooth dependency receipt') - for name, digest in record['files'].items(): - if name != 'LICENSE' and not (name.startswith('lib/dbus_next/') and name.endswith('.py')): - raise ValueError('Unexpected Bluetooth dependency file') - if safe_name(name) != name: - raise ValueError('Unsafe Bluetooth dependency path') - path = directory / name - if path.is_symlink() or not path.is_file(): - raise ValueError('Invalid Bluetooth dependency file') - data = path.read_bytes() - if hashlib.sha256(data).hexdigest() != digest: - raise ValueError('Bluetooth dependency differs from receipt') - destination = ('usr/share/licenses/omarchy-bluetooth-dbus-next/LICENSE' if name == 'LICENSE' - else 'usr/lib/omarchy-bluetooth/' + name.removeprefix('lib/')) - files[destination] = (data, 0o644) - files['etc/omarchy/bluetooth-helper.json'] = (json.dumps(record, sort_keys=True).encode(), 0o644) - return files + return desktop_components.bluetooth_files(directory, PROJECT) def labwc_files(directory): - record = json.loads((directory / 'manifest.json').read_text()) - pin = json.loads((PROJECT / 'desktop/labwc/source.json').read_text()) - if record.get('schema') != 1 or record.get('source') != pin: - raise ValueError('Incompatible Labwc component receipt') - expected = {'labwc', 'LICENSE', 'source.tar.gz', '0001-cancel-super-tap-on-pointer.patch', - 'packages.lock', 'compiler.txt', 'version.txt'} - if record.get('files', {}).keys() != expected: - raise ValueError('Incomplete Labwc component') - files = {} - for name, digest in record['files'].items(): - path = directory / name - if path.is_symlink() or not path.is_file() or sha(path) != digest: - raise ValueError('Labwc component differs from receipt') - destination = 'usr/bin/labwc' if name == 'labwc' else 'usr/share/licenses/omarchy-labwc/' + name - files[destination] = (path.read_bytes(), 0o755 if name == 'labwc' else 0o644) - if record['files']['source.tar.gz'] != pin['sha256'] or record['files']['0001-cancel-super-tap-on-pointer.patch'] != pin['patch_sha256']: - raise ValueError('Labwc corresponding source differs from pin') - files['etc/omarchy/labwc-helper.json'] = (json.dumps(record, sort_keys=True).encode(), 0o644) - return files + return desktop_components.labwc_files(directory, PROJECT) def desktop_additions(): @@ -124,6 +69,7 @@ def system_files(kernel_release): mode = 0o440 if name.startswith('etc/sudoers.d/') else ( 0o755 if name.startswith('usr/libexec/') else 0o644) additions[name] = (data, mode) + additions['usr/libexec/omarchy-desktop-ready'] = ((PROJECT / 'desktop/configuration/desktop-ready').read_bytes(), 0o755) return additions diff --git a/release/n61/audit.py b/release/n61/audit.py new file mode 100644 index 0000000..42beea3 --- /dev/null +++ b/release/n61/audit.py @@ -0,0 +1,134 @@ +"""Audit an N61 NFS desktop artifact before private staging.""" +import argparse +import hashlib +import json +from pathlib import Path, PurePosixPath +import tarfile + +from .common import ARCHITECTURE, KERNEL_RELEASE, PROFILE, aarch64, member_record, name, sha + + +def audit(directory): + directory = Path(directory) + receipt = json.loads((directory / 'rootfs.json').read_text()) + if (receipt.get('schema'), receipt.get('kind'), receipt.get('profile'), receipt.get('architecture'), + receipt.get('kernel_release')) != (1, 'omarchy-a.nfs-root', PROFILE, ARCHITECTURE, KERNEL_RELEASE): + raise ValueError('Expected the N61 AArch64 NFS root contract') + if receipt['root']['file'] != 'rootfs.tar.gz' or receipt['root']['compression'] != 'gzip': + raise ValueError('Unexpected N61 root archive') + archive = directory / 'rootfs.tar.gz' + if archive.is_symlink() or archive.stat().st_size != receipt['root']['bytes'] or sha(archive) != receipt['root']['sha256']: + raise ValueError('N61 root stream differs from its receipt') + for key, digest in (('rootfs.catalog.json', receipt['catalog_sha256']), ('packages.lock', receipt['packages_lock_sha256'])): + if sha(directory / key) != digest: + raise ValueError('N61 release metadata differs: ' + key) + kernel = receipt.get('kernel', {}) + producer = receipt.get('producer', {}) + if (not isinstance(producer, dict) or not isinstance(producer.get('cli'), dict) or + not isinstance(producer.get('hardware'), dict) or + not isinstance(producer['hardware'].get('sha256'), str)): + raise ValueError('N61 producer provenance is missing') + kernel_path = directory / 'kernel.json' + if (not isinstance(kernel, dict) or kernel_path.is_symlink() or + not kernel_path.is_file() or sha(kernel_path) != kernel.get('receipt_sha256')): + raise ValueError('N61 kernel receipt differs') + kernel_receipt = json.loads(kernel_path.read_text()) + if (kernel_receipt.get('schema'), kernel_receipt.get('device'), kernel_receipt.get('platform'), + kernel_receipt.get('dtb'), kernel_receipt.get('kernel_release')) != ( + 1, PROFILE, 'apple-4k', 't7000-n61', KERNEL_RELEASE): + raise ValueError('N61 kernel receipt identity differs') + kernel_files = kernel_receipt.get('files', {}) + recipe = kernel_receipt.get('hardware_recipe', {}) + if (not isinstance(kernel_files, dict) or kernel.get('source') != kernel_receipt.get('source') or + not isinstance(recipe, dict) or not isinstance(recipe.get('files'), dict) or + kernel.get('hardware_recipe_sha256') != recipe.get('sha256') or + recipe.get('sha256') != hashlib.sha256(json.dumps( + recipe['files'], sort_keys=True, separators=(',', ':')).encode()).hexdigest() or + kernel.get('image_sha256') != kernel_files.get('Image.gz', {}).get('sha256') or + kernel.get('dtb_sha256') != kernel_files.get('n61.dtb', {}).get('sha256')): + raise ValueError('N61 kernel receipt does not match release metadata') + expected = json.loads((directory / 'rootfs.catalog.json').read_text()) + actual, contents = {}, {} + selected = {'usr/lib/systemd/systemd', 'usr/bin/sudo', 'etc/orchard/release.json', + 'usr/local/libexec/arch-ipad-login-session', 'usr/libexec/omarchy-desktop-ready', + 'usr/libexec/omarchy-screen-toggle', 'usr/lib/systemd/system/omarchy-pacman-init.service', + 'home/tablet/.local/share/omarchy-ipad/bin/omarchy-brightness-display', 'arch-ipad-ui-manifest.json', + 'home/tablet/.config/labwc/autostart', 'home/tablet/.local/share/omarchy-ipad/shell/shell.qml', + 'etc/sudoers.d/90-tablet-passwordless', 'etc/omarchy/package-privileges.json'} + with tarfile.open(archive, 'r|gz') as stream: + for member in stream: + key = name(member.name) + if key in actual: + raise ValueError('Duplicate N61 archive member') + if (key.startswith('usr/libexec/orchard-j71b/') or key == 'etc/orchard/nfs-root.json' or + key == 'etc/orchard/firmware.json' or key.endswith('/authorized_keys') or + key.startswith('etc/ssh/ssh_host_')): + raise ValueError('Unexpected board runtime or private session data: ' + key) + data = stream.extractfile(member).read() if member.isfile() else None + member.name = key + actual[key] = member_record(member, data) + if key in selected: + contents[key] = data + if actual != expected or len(actual) != receipt['root']['members']: + raise ValueError('N61 archive inventory differs from its receipt') + if not selected <= actual.keys() or not aarch64(contents['usr/lib/systemd/systemd']): + raise ValueError('Incomplete AArch64 Omarchy desktop root') + for key in actual: + for parent in PurePosixPath(key).parents: + if str(parent) in actual and actual[str(parent)]['type'] != 'directory': + raise ValueError('N61 archive traverses a guest link') + sudo = actual['usr/bin/sudo'] + if (sudo['uid'], sudo['gid'], sudo['mode']) != (0, 0, 0o4755): + raise ValueError('N61 sudo privilege contract differs') + if contents['etc/sudoers.d/90-tablet-passwordless'] != b'tablet ALL=(ALL:ALL) NOPASSWD: ALL\n': + raise ValueError('N61 tablet sudo policy differs') + policy = json.loads(contents['etc/orchard/release.json']) + if (policy['profile'], policy['architecture'], policy['kernel_release'], policy['release']) != ( + PROFILE, ARCHITECTURE, KERNEL_RELEASE, receipt['release']): + raise ValueError('N61 root policy differs from its receipt') + login = contents['usr/local/libexec/arch-ipad-login-session'] + for setting in (b'WLR_RENDERER=pixman', b'QT_QUICK_BACKEND=software', b'LIBGL_ALWAYS_SOFTWARE=1', + b'WLR_LIBINPUT_NO_DEVICES=1'): + if setting not in login: + raise ValueError('N61 software desktop policy is missing') + if 'usr/lib/modules/' + KERNEL_RELEASE + '/modules.dep' not in actual: + raise ValueError('N61 matching module tree is missing') + prefix = 'lib/modules/' + KERNEL_RELEASE + '/' + expected_modules = {'usr/' + key: item for key, item in kernel_files.items() if key.startswith(prefix)} + actual_modules = {key: item for key, item in actual.items() + if key.startswith('usr/' + prefix) and item['type'] == 'file'} + if (not expected_modules or actual_modules.keys() != expected_modules.keys() or any( + (actual_modules[key]['bytes'], actual_modules[key]['sha256']) != + (item.get('bytes'), item.get('sha256')) for key, item in expected_modules.items())): + raise ValueError('N61 modules differ from the kernel receipt') + if actual['usr/libexec/omarchy-screen-toggle']['mode'] != 0o755: + raise ValueError('N61 screen toggle is not executable') + brightness = 'home/tablet/.local/share/omarchy-ipad/bin/omarchy-brightness-display' + if json.loads(contents['arch-ipad-ui-manifest.json']).get(brightness) != hashlib.sha256(contents[brightness]).hexdigest(): + raise ValueError('N61 brightness helper differs from the desktop manifest') + key = 'etc/systemd/system/multi-user.target.wants/omarchy-pacman-init.service' + link = actual.get(key, {}) + if (link.get('type'), link.get('target')) != ( + 'symlink', '/usr/lib/systemd/system/omarchy-pacman-init.service'): + raise ValueError('N61 package keyring initialization is not enabled') + privileges = json.loads(contents['etc/omarchy/package-privileges.json']) + for key, privilege in privileges.items(): + item = actual.get(key, {}) + for field in ('type', 'mode', 'uid', 'gid'): + expected_value = 'directory' if field == 'type' and privilege[field] == 'dir' else privilege[field] + if item.get(field) != expected_value: + raise ValueError('N61 package privilege differs: ' + key) + if privilege['type'] == 'file' and item.get('sha256') != privilege['sha256']: + raise ValueError('N61 privileged file content differs: ' + key) + return receipt + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('release', type=Path) + args = parser.parse_args() + print(json.dumps(audit(args.release), indent=2)) + + +if __name__ == '__main__': + main() diff --git a/release/n61/build.py b/release/n61/build.py new file mode 100644 index 0000000..ffb3155 --- /dev/null +++ b/release/n61/build.py @@ -0,0 +1,287 @@ +"""Compose the native Omarchy A N61 desktop root for a private NFS session.""" +import argparse +import gzip +import hashlib +import io +import json +import os +from pathlib import Path, PurePosixPath +import re +import tarfile +import tempfile + +from release import desktop +from release.package_permissions import privileged_entries, apply as apply_privileges +from orchard_cli import provenance +from .common import ARCHITECTURE, KERNEL_RELEASE, PROFILE, PROJECT, encoded, member_record, name, sha + + +def kernel_component(modules_dir): + """Validate the hardware producer's complete, device-specific output.""" + modules_dir = Path(modules_dir) + kernel = modules_dir.parent.parent + if (modules_dir.is_symlink() or kernel.is_symlink() or + not modules_dir.is_dir() or not kernel.is_dir()): + raise ValueError('Expected a regular N61 kernel component') + receipt_path = kernel / 'receipt.json' + if receipt_path.is_symlink() or not receipt_path.is_file(): + raise ValueError('N61 kernel component receipt is required') + receipt = json.loads(receipt_path.read_text()) + if (receipt.get('schema'), receipt.get('device'), receipt.get('platform'), + receipt.get('dtb'), receipt.get('kernel_release')) != ( + 1, PROFILE, 'apple-4k', 't7000-n61', KERNEL_RELEASE): + raise ValueError('N61 kernel component identity differs') + source = receipt.get('source') + if (not isinstance(source, dict) or any( + not isinstance(source.get(key), str) or not re.fullmatch('[0-9a-f]{40}', source[key]) + for key in ('commit', 'tree', 'prepared_tree')) or any( + not isinstance(source.get(key), str) or not re.fullmatch('[0-9a-f]{64}', source[key]) + for key in ('patch_list_sha256', 'config_sha256'))): + raise ValueError('N61 kernel source receipt is incomplete') + recipe = receipt.get('hardware_recipe') + if not isinstance(recipe, dict) or not isinstance(recipe.get('files'), dict): + raise ValueError('N61 hardware recipe snapshot is missing') + recipe_bytes = json.dumps(recipe['files'], sort_keys=True, separators=(',', ':')).encode() + if (recipe.get('sha256') != hashlib.sha256(recipe_bytes).hexdigest() or + not {'devices/n61/device.toml', 'devices/n61/firmware.json', + 'kernel/configs/apple-4k/config'} <= recipe['files'].keys()): + raise ValueError('N61 hardware recipe snapshot differs') + releases = [p for p in modules_dir.iterdir() if p.is_dir() and not p.is_symlink()] + if (len(releases) != 1 or releases[0].name != KERNEL_RELEASE or + (kernel / 'kernel.release').read_text().strip() != KERNEL_RELEASE): + raise ValueError('Expected the matching N61 4K kernel module tree') + tree = releases[0] + records = receipt.get('files') + if not isinstance(records, dict): + raise ValueError('N61 kernel component inventory is missing') + required = {'Image.gz', 'n61.dtb', 'dtbs/t7000-n61.dtb', 'kernel.release', + 'lib/modules/' + KERNEL_RELEASE + '/modules.dep'} + if not required <= records.keys(): + raise ValueError('N61 kernel component is incomplete') + actual = set() + for path in kernel.rglob('*'): + if path.is_symlink(): + raise ValueError('Linked kernel component inputs are unsupported') + if path.is_file() and path != receipt_path: + actual.add(path.relative_to(kernel).as_posix()) + elif not path.is_file() and not path.is_dir(): + raise ValueError('Unsupported kernel component input') + if actual != records.keys(): + raise ValueError('N61 kernel component inventory differs') + for key, item in records.items(): + if (name(key) != key or not isinstance(item, dict) or + set(item) != {'bytes', 'sha256'} or type(item['bytes']) is not int or + not isinstance(item['sha256'], str) or not re.fullmatch('[0-9a-f]{64}', item['sha256'])): + raise ValueError('Invalid N61 kernel component inventory entry') + path = kernel / key + if path.stat().st_size != item['bytes'] or sha(path) != item['sha256']: + raise ValueError('N61 kernel component differs: ' + key) + if records['n61.dtb'] != records['dtbs/t7000-n61.dtb']: + raise ValueError('N61 selected device tree differs') + modules = list(tree.rglob('*.ko')) + if not modules: + raise ValueError('N61 kernel module tree is empty') + for path in modules: + data = path.read_bytes() + if (data[:6] != b'\x7fELF\x02\x01' or data[18:20] != b'\xb7\0' or + b'vermagic=' + KERNEL_RELEASE.encode() + b' ' not in data): + raise ValueError('N61 module architecture or release differs: ' + path.name) + return receipt, sha(receipt_path) + + +def hardware_evidence(receipt, hardware=None): + if hardware is None: + import orchard_hardware + hardware = Path(orchard_hardware.__file__).resolve().parent + hardware = Path(hardware) + if hardware.is_symlink() or not hardware.is_dir(): + raise ValueError('Expected a hardware package or checkout') + hardware = hardware.resolve() + for key, expected in receipt['hardware_recipe']['files'].items(): + if name(key) != key or not isinstance(expected, dict): + raise ValueError('Invalid hardware recipe snapshot entry') + path = hardware + for part in PurePosixPath(key).parts: + path = path / part + if path.is_symlink(): + raise ValueError('Linked hardware recipe input: ' + key) + if (not path.is_file() or path.stat().st_size != expected.get('bytes') or + sha(path) != expected.get('sha256')): + raise ValueError('Hardware package differs from the kernel recipe: ' + key) + return provenance.snapshot(hardware) + + +def additions(modules_dir, cursor_tools, bluetooth_tools, labwc_tools): + modules_dir = Path(modules_dir) + tree = modules_dir / KERNEL_RELEASE + files = {} + for path in sorted(tree.rglob('*')): + if path.is_symlink(): + raise ValueError('Linked kernel module inputs are unsupported') + if path.is_file(): + files['usr/lib/modules/' + KERNEL_RELEASE + '/' + path.relative_to(tree).as_posix()] = (path.read_bytes(), 0o644) + files.update(desktop.cursor_files(Path(cursor_tools), PROJECT)) + files.update(desktop.bluetooth_files(Path(bluetooth_tools), PROJECT)) + files.update(desktop.labwc_files(Path(labwc_tools), PROJECT)) + overlay = PROJECT / 'system/n61/overlay' + for path in sorted(overlay.rglob('*')): + if path.is_symlink(): + raise ValueError('N61 policy must contain regular source files') + if path.is_file(): + key = path.relative_to(overlay).as_posix() + mode = 0o440 if key.startswith('etc/sudoers.d/') else ( + 0o755 if key.startswith(('usr/libexec/', 'usr/local/libexec/')) else 0o644) + files[key] = (path.read_bytes(), mode) + # Use the same upstream helpers as the other AArch64 desktop profile. + shared = PROJECT / 'system/j71b/overlay' + for key, mode in (('usr/libexec/omarchy-screen-toggle', 0o755), + ('usr/lib/systemd/system/omarchy-pacman-init.service', 0o644)): + source = shared / key + if source.is_symlink() or not source.is_file(): + raise ValueError('Shared desktop helper is missing: ' + key) + files[key] = (source.read_bytes(), mode) + files['usr/lib/omarchy/image_policy.py'] = ((PROJECT / 'image/n61/image_policy.py').read_bytes(), 0o644) + files['usr/libexec/omarchy-desktop-ready'] = ((PROJECT / 'desktop/configuration/desktop-ready').read_bytes(), 0o755) + brightness = 'home/tablet/.local/share/omarchy-ipad/bin/omarchy-brightness-display' + files[brightness] = ((PROJECT / 'desktop/shell/bin/omarchy-brightness-display').read_bytes(), 0o755) + session = (PROJECT / 'desktop/configuration/login-session').read_bytes() + entry = b'exec /usr/bin/labwc\n' + if session.count(entry) != 1: + raise ValueError('The shared desktop session entrypoint changed') + files['usr/local/libexec/arch-ipad-login-session'] = ( + session.replace(entry, b'export WLR_LIBINPUT_NO_DEVICES=1\n' + entry), 0o755) + return files + + +def build(base, modules_dir, cursor_tools, bluetooth_tools, labwc_tools, output, *, release=None, hardware=None): + base, output = Path(base), Path(output) + if output.exists() or output.is_symlink(): + raise ValueError('Choose a new N61 release directory') + receipt = json.loads((base / 'rootfs.tar.gz.json').read_text()) + if (receipt.get('kind'), receipt.get('profile'), receipt.get('architecture')) != ('omarchy-a.base', PROFILE, ARCHITECTURE): + raise ValueError('Expected a native N61 AArch64 base receipt') + archive = base / 'rootfs.tar.gz' + if archive.is_symlink() or archive.stat().st_size != receipt['bytes'] or sha(archive) != receipt['sha256']: + raise ValueError('N61 base differs from its receipt') + if sha(base / 'packages.lock') != receipt['packages_lock_sha256']: + raise ValueError('N61 package inventory differs from its receipt') + release = receipt['release'] if release is None else release + if not isinstance(release, str) or not re.fullmatch('[A-Za-z0-9][A-Za-z0-9._+-]{0,127}', release): + raise ValueError('Invalid N61 release name') + kernel_receipt, kernel_receipt_sha = kernel_component(modules_dir) + hardware_source = hardware_evidence(kernel_receipt, hardware) + cli_source = provenance.cli() + files = additions(modules_dir, cursor_tools, bluetooth_tools, labwc_tools) + privileges = privileged_entries(archive) + if files.keys() & privileges.keys(): + raise ValueError('N61 policy cannot replace privileged package files') + files['etc/omarchy/package-privileges.json'] = (encoded(privileges), 0o644) + files['etc/orchard/release.json'] = (encoded({ + 'schema': 1, 'distro': 'omarchy-a', 'profile': PROFILE, 'architecture': ARCHITECTURE, + 'release': release, 'kernel_release': KERNEL_RELEASE}), 0o644) + catalog, privileged_digests = {}, {} + output.parent.mkdir(parents=True, exist_ok=True) + with tempfile.TemporaryDirectory(prefix='.n61-root-', dir=output.parent) as directory: + work = Path(directory) + root_archive = work / 'rootfs.tar.gz' + with root_archive.open('wb') as raw, gzip.GzipFile(fileobj=raw, mode='wb', filename='', mtime=0, compresslevel=1) as zipped: + with tarfile.open(fileobj=zipped, mode='w|') as target: + def add(member, data=None): + member.name = name(member.name) + if member.name in catalog: + raise ValueError('Duplicate N61 root member: ' + member.name) + member.pax_headers = {k: v for k, v in member.pax_headers.items() if k not in ('path', 'size')} + catalog[member.name] = member_record(member, data) + target.addfile(member, io.BytesIO(data) if data is not None else None) + + with tarfile.open(archive, 'r|gz') as source: + for member in source: + if member.name in ('.', './'): + continue + member.name = name(member.name) + if member.name in files: + continue + data = source.extractfile(member).read() if member.isfile() else None + if member.name == 'arch-ipad-ui-manifest.json': + ui = json.loads(data) + brightness = 'home/tablet/.local/share/omarchy-ipad/bin/omarchy-brightness-display' + if brightness not in ui: + raise ValueError('Desktop manifest lacks the brightness helper') + for key, (content, _) in files.items(): + if key in ui: + ui[key] = hashlib.sha256(content).hexdigest() + data = encoded(ui) + if data is not None: + member.size = len(data) + if member.name in privileges: + apply_privileges(member, data, privileges[member.name], privileged_digests) + add(member, data) + def parents(key): + for parent in reversed(PurePosixPath(key).parents): + parent = str(parent) + if parent == '.': + continue + if parent in catalog: + if catalog[parent]['type'] != 'directory': + raise ValueError('N61 policy parent is not a directory: ' + parent) + continue + member = tarfile.TarInfo(parent) + member.type, member.mode = tarfile.DIRTYPE, 0o755 + add(member) + for key, (data, mode) in sorted(files.items()): + parents(key) + member = tarfile.TarInfo(key) + member.mode, member.size = mode, len(data) + if key.startswith('home/tablet/'): + member.uid = member.gid = 1000 + add(member, data) + for target_unit, unit in (('swap.target', 'omarchy-zram.service'), + ('multi-user.target', 'omarchy-pacman-init.service')): + key = 'etc/systemd/system/' + target_unit + '.wants/' + unit + parents(key) + member = tarfile.TarInfo(key) + member.type, member.mode = tarfile.SYMTYPE, 0o777 + member.linkname = '/usr/lib/systemd/system/' + unit + add(member) + (work / 'packages.lock').write_bytes((base / 'packages.lock').read_bytes()) + kernel_receipt_file = Path(modules_dir).parent.parent / 'receipt.json' + (work / 'kernel.json').write_bytes(kernel_receipt_file.read_bytes()) + if sha(work / 'kernel.json') != kernel_receipt_sha: + raise ValueError('N61 kernel receipt changed during packaging') + (work / 'rootfs.catalog.json').write_bytes(encoded(catalog)) + record = {'schema': 1, 'kind': 'omarchy-a.nfs-root', 'distro': 'omarchy-a', + 'profile': PROFILE, 'architecture': ARCHITECTURE, 'release': release, + 'base_release': receipt['release'], 'producer': {'cli': cli_source, 'hardware': hardware_source}, + 'qualification': 'pending-physical-desktop-boot', 'kernel_release': KERNEL_RELEASE, + 'kernel': {'receipt_sha256': kernel_receipt_sha, 'source': kernel_receipt['source'], + 'hardware_recipe_sha256': kernel_receipt['hardware_recipe']['sha256'], + 'image_sha256': kernel_receipt['files']['Image.gz']['sha256'], + 'dtb_sha256': kernel_receipt['files']['n61.dtb']['sha256']}, + 'runtime': 'n61-systemd-nfs-v1', 'base_sha256': receipt['sha256'], + 'root': {'file': 'rootfs.tar.gz', 'compression': 'gzip', 'bytes': root_archive.stat().st_size, + 'sha256': sha(root_archive), 'members': len(catalog), + 'expanded_bytes': sum(row.get('bytes', 0) for row in catalog.values())}, + 'catalog_sha256': sha(work / 'rootfs.catalog.json'), + 'packages_lock_sha256': sha(work / 'packages.lock'), + 'desktop': {'compositor': 'labwc', 'renderer': 'pixman', 'qtquick': 'software', + 'panel': [750, 1334], 'scale': 1.5, 'zram_max_bytes': 536870912}} + (work / 'rootfs.json').write_bytes(encoded(record)) + from .audit import audit + audit(work) + os.replace(work, output) + return record + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + for key in ('base', 'modules-dir', 'cursor-tools', 'bluetooth-tools', 'labwc-tools', 'output'): + parser.add_argument('--' + key, type=Path, required=True) + parser.add_argument('--release', help='name for the assembled release; defaults to the base release') + parser.add_argument('--hardware', type=Path, help='matching hardware package or checkout') + args = parser.parse_args() + print(json.dumps(build(args.base, args.modules_dir, args.cursor_tools, args.bluetooth_tools, + args.labwc_tools, args.output, release=args.release, hardware=args.hardware), indent=2)) + + +if __name__ == '__main__': + main() diff --git a/release/n61/common.py b/release/n61/common.py new file mode 100644 index 0000000..7d6af14 --- /dev/null +++ b/release/n61/common.py @@ -0,0 +1,52 @@ +"""N61 userspace archive invariants, independent of device startup.""" +import hashlib +import json +from pathlib import Path, PurePosixPath +import posixpath + +from image.n61.image_policy import ARCHITECTURE, KERNEL_RELEASE, PROFILE + +PROJECT = Path(__file__).resolve().parents[2] + + +def sha(path): + digest = hashlib.sha256() + with Path(path).open('rb') as stream: + for block in iter(lambda: stream.read(1024 * 1024), b''): + digest.update(block) + return digest.hexdigest() + + +def encoded(record): + return (json.dumps(record, sort_keys=True, indent=2) + '\n').encode() + + +def name(value): + path = PurePosixPath(value) + if path.is_absolute() or '..' in path.parts or not path.parts: + raise ValueError('Unsafe N61 archive path: ' + value) + return str(path) + + +def member_record(member, data=None): + if member.isdir(): + kind = 'directory' + elif member.isfile(): + kind = 'file' + elif member.issym(): + kind = 'symlink' + target = member.linkname.lstrip('/') if member.linkname.startswith('/') else posixpath.join(posixpath.dirname(member.name), member.linkname) + if posixpath.normpath(target) == '..' or posixpath.normpath(target).startswith('../'): + raise ValueError('N61 archive link escapes the guest root') + else: + raise ValueError('Unsupported N61 archive entry: ' + member.name) + record = {'type': kind, 'mode': member.mode, 'uid': member.uid, 'gid': member.gid} + if kind == 'file': + record.update(bytes=len(data), sha256=hashlib.sha256(data).hexdigest()) + if kind == 'symlink': + record['target'] = member.linkname + return record + + +def aarch64(data): + return len(data) >= 64 and data[:6] == b'\x7fELF\x02\x01' and data[18:20] == b'\xb7\x00' diff --git a/release/n61/prepare_nfs.py b/release/n61/prepare_nfs.py new file mode 100644 index 0000000..7e90cac --- /dev/null +++ b/release/n61/prepare_nfs.py @@ -0,0 +1,241 @@ +"""Expand an audited N61 image and bind one private NFS root session.""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import posixpath +import re +import shutil +import stat +import subprocess +import tarfile +import uuid + +from orchard_cli import firmware + +from .audit import audit +from .common import ARCHITECTURE, KERNEL_RELEASE, PROFILE, encoded, name, sha + + +def extract(archive, root): + with tarfile.open(archive, 'r|gz') as stream: + for member in stream: + key = name(member.name) + path = root / key + if not path.parent.resolve().is_relative_to(root.resolve()): + raise ValueError('N61 extraction parent escapes the staging root') + path.parent.mkdir(parents=True, exist_ok=True) + if member.isdir(): + if path.is_symlink(): + raise ValueError('N61 directory would replace a link') + 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('N61 extraction link escapes the staging root') + 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) + output.flush() + os.fchown(output.fileno(), member.uid, member.gid) + os.fchmod(output.fileno(), member.mode) + else: + raise ValueError('Unsupported N61 extraction entry') + + +def require_case_sensitive(root): + probe = root / '.n61-case-probe' + probe.write_bytes(b'') + sensitive = not (root / '.N61-CASE-PROBE').exists() + probe.unlink() + if not sensitive: + raise ValueError('N61 NFS roots require a case-sensitive Linux filesystem') + + +def verify_privileges(root): + entries = json.loads((root / 'etc/omarchy/package-privileges.json').read_text()) + for key, expected in entries.items(): + path = root / name(key) + if path.is_symlink() or not path.resolve().is_relative_to(root.resolve()): + raise ValueError('Linked N61 privileged package entry') + info = path.stat() + if (info.st_uid, info.st_gid, stat.S_IMODE(info.st_mode)) != (expected['uid'], expected['gid'], expected['mode']): + raise ValueError('Staged N61 package permissions differ: ' + key) + if expected['type'] == 'file' and sha(path) != expected['sha256']: + raise ValueError('Staged N61 privileged file differs: ' + key) + + +def owner_access(root, authorized_key): + key = Path(authorized_key).read_text().strip() + if len(key) > 16384 or not re.fullmatch(r'ssh-ed25519 [A-Za-z0-9+/]+={0,2}(?: [^\r\n]+)?', key): + raise ValueError('Expected one explicit owner Ed25519 public key') + for relative, uid in (('root/.ssh', 0), ('home/tablet/.ssh', 1000)): + directory = root / relative + if directory.is_symlink() or not directory.resolve().is_relative_to(root.resolve()): + raise ValueError('Linked N61 owner SSH directory') + directory.mkdir(mode=0o700, exist_ok=True) + os.chown(directory, uid, uid) + directory.chmod(0o700) + path = directory / 'authorized_keys' + path.write_text(key + '\n') + os.chown(path, uid, uid) + path.chmod(0o600) + host_key = root / 'etc/ssh/ssh_host_ed25519_key' + subprocess.run(['ssh-keygen', '-q', '-t', 'ed25519', '-N', '', '-f', str(host_key)], check=True) + host_key.chmod(0o600) + directory = root / 'etc/ssh/sshd_config.d' + directory.mkdir(parents=True, exist_ok=True) + (directory / '10-n61-owner.conf').write_text( + 'HostKey /etc/ssh/ssh_host_ed25519_key\n' + 'PasswordAuthentication no\nKbdInteractiveAuthentication no\nPubkeyAuthentication yes\n' + 'PermitRootLogin prohibit-password\nAllowUsers root tablet\nUsePAM yes\n') + wants = root / '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') + return sha(Path(authorized_key)) + + +def read_firmware(mixin, receipt): + """Validate the shared CLI's private mixin against the installed N61 recipe.""" + mixin, receipt = Path(mixin), Path(receipt) + if mixin.is_symlink() or receipt.is_symlink() or not mixin.is_file() or not receipt.is_file(): + raise ValueError('Expected regular private firmware mixin and receipt files') + record = json.loads(receipt.read_text()) + if not isinstance(record, dict) or (record.get('schema'), record.get('kind')) != (1, 'private-mixin'): + raise ValueError('Expected the native private firmware mixin receipt') + if (not 0 < mixin.stat().st_size <= 16 * 1024 * 1024 or + mixin.stat().st_size != record.get('bytes') or sha(mixin) != record.get('sha256')): + raise ValueError('Private firmware mixin differs from its receipt') + expected = {output['destination']: output['sha256'] for output in firmware.recipe_for(PROFILE)['outputs']} + if any(name(key) != key or not key.startswith('lib/firmware/') for key in expected): + raise ValueError('N61 firmware recipe has unsafe destinations') + records, expanded = {}, 0 + with tarfile.open(mixin, 'r|gz') as stream: + for member in stream: + key = name(member.name) + if (key != member.name or key in records or not member.isfile() or + key not in expected.keys() | {'mixin.json'} or len(records) >= len(expected) + 1): + raise ValueError('Private firmware mixin contains unexpected paths, links or duplicates') + expanded += member.size + if expanded > 32 * 1024 * 1024 or (key == 'mixin.json' and member.size > 65536): + raise ValueError('Private firmware mixin exceeds its expanded limit') + records[key] = stream.extractfile(member).read() + metadata = json.loads(records.pop('mixin.json', b'{}')) + if not isinstance(metadata, dict) or (metadata.get('schema'), metadata.get('profile')) != (1, PROFILE): + raise ValueError('Private firmware mixin is not for N61') + if (not isinstance(metadata.get('files'), dict) or set(records) != set(expected) or + set(metadata['files']) != set(expected)): + raise ValueError('Private firmware mixin must contain exactly the N61 recipe outputs') + for key, data in records.items(): + digest = hashlib.sha256(data).hexdigest() + if digest != expected[key] or metadata['files'][key] != {'bytes': len(data), 'sha256': digest}: + raise ValueError('Private firmware differs from the N61 recipe: ' + key) + return records, {**record, 'files': metadata['files']} + + +def stage_firmware(root, records): + for key, data in records.items(): + # Arch's /lib points at /usr/lib. Write through real directories only. + parts = ('usr/' + key).split('/') + current = os.open(root, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW) + try: + for part in parts[:-1]: + created = False + try: + os.mkdir(part, 0o755, dir_fd=current) + created = True + except FileExistsError: + pass + following = os.open(part, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW, dir_fd=current) + os.close(current) + current = following + if created: + os.fchown(current, 0, 0) + os.fchmod(current, 0o755) + fd = os.open(parts[-1], os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600, dir_fd=current) + with os.fdopen(fd, 'wb') as output: + output.write(data) + output.flush() + os.fchown(output.fileno(), 0, 0) + os.fchmod(output.fileno(), 0o600) + finally: + os.close(current) + + +def prepare(release, destination, authorized_key=None, *, firmware_mixin=None, firmware_receipt=None): + release, destination = Path(release), Path(destination).expanduser().absolute() + receipt = audit(release) + record_path = destination.parent / (destination.name + '.private.json') + if (destination == Path('/') or destination.exists() or destination.is_symlink() or + record_path.exists() or record_path.is_symlink()): + raise ValueError('Choose a new private N61 NFS root and staging receipt') + if os.geteuid() != 0: + raise ValueError('N61 NFS staging requires root to retain guest UID/GID and package modes') + if (firmware_mixin is None) != (firmware_receipt is None): + raise ValueError('Private N61 firmware requires both --firmware-mixin and --firmware-receipt') + private_files, private_receipt = read_firmware(firmware_mixin, firmware_receipt) if firmware_mixin else ({}, None) + destination.mkdir(parents=True, mode=0o755) + try: + os.chown(destination, 0, 0) + destination.chmod(0o755) + require_case_sensitive(destination) + extract(release / 'rootfs.tar.gz', destination) + verify_privileges(destination) + stage_firmware(destination, private_files) + for directory in ('dev', 'proc', 'sys', 'run'): + path = destination / directory + if path.is_symlink() or not path.is_dir(): + raise ValueError('N61 handoff needs a real ' + directory + ' directory') + identity = {'schema': 1, 'kind': 'orchard.nfs-root', 'profile': PROFILE, + 'architecture': ARCHITECTURE, 'kernel_release': KERNEL_RELEASE, + 'root_id': str(uuid.uuid4()), 'image_sha256': receipt['root']['sha256'], + 'image_receipt_sha256': sha(release / 'rootfs.json')} + path = destination / 'etc/orchard/nfs-root.json' + path.write_bytes(encoded(identity)) + path.chmod(0o644) + owner_hash = owner_access(destination, authorized_key) if authorized_key else None + for key, value in (('hostname', 'omarchy-n61\n'), + ('hosts', '127.0.0.1 localhost\n::1 localhost\n127.0.1.1 omarchy-n61\n')): + path = destination / 'etc' / key + path.write_text(value) + path.chmod(0o644) + path = destination / 'etc/orchard/nfs-root.json' + result = {'schema': 1, 'private': True, 'publishable': False, 'profile': PROFILE, + 'release': receipt['release'], 'root_identity': identity, + 'root_identity_sha256': sha(path), 'owner_authorized_key_sha256': owner_hash, + 'firmware_mixin': private_receipt, + 'root_identity_file': str(path), + 'link_policy': 'Guest absolute links normalized to equivalent relative staging links'} + record_path.write_bytes(encoded(result)) + record_path.chmod(0o600) + except BaseException: + shutil.rmtree(destination) + raise + print('Prepared N61 NFS root:', destination) + print('RAM handoff receipt:', path) + 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('--authorized-key', type=Path) + parser.add_argument('--firmware-mixin', type=Path, help='native private N61 firmware mixin.tar.gz') + parser.add_argument('--firmware-receipt', type=Path, help='matching native mixin.json receipt') + args = parser.parse_args() + prepare(args.release, args.destination, args.authorized_key, + firmware_mixin=args.firmware_mixin, firmware_receipt=args.firmware_receipt) + + +if __name__ == '__main__': + main() diff --git a/system/aarch64/build-rootfs b/system/aarch64/build-rootfs new file mode 100755 index 0000000..5d4497b --- /dev/null +++ b/system/aarch64/build-rootfs @@ -0,0 +1,73 @@ +#!/usr/bin/env bash +set -euo pipefail +root= output= desktop= release= profile= packages= +while (($#)); do + case "$1" in + --profile) profile=$2; shift 2;; + --packages) packages=$2; shift 2;; + --root) root=$2; shift 2;; + --output) output=$2; shift 2;; + --desktop) desktop=$2; shift 2;; + --release) release=$2; shift 2;; + *) echo "Unknown argument: $1" >&2; exit 2;; + esac +done +for value in "$root" "$output" "$desktop" "$release" "$profile" "$packages"; do + [[ -n "$value" ]] || { echo 'Missing required build input' >&2; exit 2; } +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 +[[ "$profile" =~ ^[a-z0-9][a-z0-9_-]*$ && -f "$packages" ]] || exit 2 +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*(#|$)' "$packages") +[[ ${#requested[@]} -gt 0 ]] || exit 1 +printf '%s\n' "${requested[@]}" | grep -qx 'linux-aarch64' && { echo 'Generic kernel is not an input' >&2; exit 1; } + +# Install signed packages into a clean target; no mounts or VM root are needed. +# Pacman downloads and tablet queries need distribution directory modes. +umask 022 +mkdir -p "$root/var/lib/pacman" "$root/var/cache/pacman/pkg" "$root/etc" +pacman --root "$root" --gpgdir /etc/pacman.d/gnupg -Sy --noconfirm "${requested[@]}" +for package in "$desktop/packages/omafox.pkg.tar.xz" "$desktop/packages/omacalc.pkg.tar.zst"; do + pacman --root "$root" --gpgdir /etc/pacman.d/gnupg --noconfirm -U "$package" +done +useradd --root "$root" --create-home --uid 1000 --user-group --shell /bin/bash tablet +# Native Wayland login needs seatd's socket and DRM/input device access. +usermod --root "$root" --append --groups seat,video,input tablet +# Normalize only copied desktop ownership. Recursive chown of the installed +# package tree clears setuid/setgid bits and file capabilities (including sudo). +cp -a --no-preserve=ownership "$desktop/payload/." "$root/" +chown -R 1000:1000 "$root/home/tablet" +install -d "$root/etc/systemd/system" +for unit in sleep suspend hibernate hybrid-sleep suspend-then-hibernate; do + ln -s /dev/null "$root/etc/systemd/system/$unit.target" +done +systemctl --root="$root" enable getty@tty1.service NetworkManager.service bluetooth.service seatd.service +: > "$root/etc/machine-id" +printf 'omarchy-%s\n' "$profile" > "$root/etc/hostname" +chmod 644 "$root/etc/hostname" +rm -f "$root/var/lib/dbus/machine-id" "$root/etc/ssh"/ssh_host_* +rm -rf -- "$root/etc/pacman.d/gnupg" +install -d "$root/etc/ipadlinux" +printf '%s\n' "$release" > "$root/etc/ipadlinux/release" +mkdir -p "$(dirname "$output")" +pacman --root "$root" -Q > "$(dirname "$output")/packages.lock" +tar --hard-dereference --numeric-owner --one-file-system -C "$root" \ + --exclude='./dev/*' --exclude='./proc/*' --exclude='./sys/*' \ + --exclude='./run/*' --exclude='./tmp/*' --exclude='./var/cache/pacman/pkg/*' \ + -cf - . | gzip -1 > "$output" +python3 - "$output" "$release" "$profile" "$desktop/manifest.json" <<'PY' +import hashlib,json,pathlib,sys +archive,release,profile,desktop=pathlib.Path(sys.argv[1]),sys.argv[2],sys.argv[3],pathlib.Path(sys.argv[4]) +h=hashlib.sha256() +with archive.open('rb') as stream: + for block in iter(lambda:stream.read(1024*1024),b''):h.update(block) +lock=archive.parent/'packages.lock' +metadata={'kind':'omarchy-a.base','profile':profile,'architecture':'aarch64', + 'desktop_manifest_sha256':hashlib.sha256(desktop.read_bytes()).hexdigest(),'release':release,'bytes':archive.stat().st_size,'sha256':h.hexdigest(), + 'packages_lock_sha256':hashlib.sha256(lock.read_bytes()).hexdigest()} +archive.with_suffix(archive.suffix+'.json').write_text(json.dumps(metadata,indent=2)+'\n') +print('Clean rootfs:',archive,'SHA-256',h.hexdigest()) +PY diff --git a/system/n61/README.md b/system/n61/README.md new file mode 100644 index 0000000..3d452ea --- /dev/null +++ b/system/n61/README.md @@ -0,0 +1,134 @@ +# N61 Arch Linux ARM desktop + +The N61 profile composes the shared AArch64 Omarchy desktop with its matching +`7.3.0-rc1-orchard-apple-4k` module tree. Its root runs from a private NFS export. +The hardware RAM handoff owns the USB gadget and preserves its network link. +NetworkManager leaves `usb0` unmanaged in the desktop. + +On 28 October 2026, a physical iPhone 6 reached Arch Linux ARM with systemd 262.1 +and the 4 KiB kernel through this NFS flow. The `tablet` session used PAM/logind +and user D-Bus to run Labwc and Quickshell. The owner confirmed the Omarchy +wallpaper and top bar; a 750×1333 compositor frame was retained. Systemd reported +zero failed units. Omacalc opened and calculated `2+2=4` through the host's +virtual keyboard helper. + +On a later boot using the complete touch payload, the owner confirmed calculator +input and swipes. The capture recorded 50 contacts and 50 releases. On 29 October, +the reconciled build reached the desktop twice with distinct boot IDs. Touch +worked on both boots; the first covered y=26..1322 on the 0..1333 axis and +on-screen keyboard entry. The recorded payload SHA-256 was +`b2d49d60b3771947e11efcee4d1d7f91d126a333d7373bae02b4d1f341f6ec78`, and the +desktop archive SHA-256 was +`a6e0626dc02d8aff535d6c0fa8f14cb54954700bfda5f267528f07cec3d6fa57`. +The desktop requires the USB link and its NFS host for the whole session. Installing +Linux on the phone's storage and starting it after an untethered restart remain +future work. + +Labwc uses Pixman. Qt Quick and Mesa use software rendering, and the nominal 750×1334 +panel uses scale 1.5. The memory policy allocates half of `MemTotal` to zram, +capped at 512 MiB. Firefox starts with a two-process preference and a 16 MiB +memory cache. The compositor can start while physical input drivers are pending. +Memory pressure and browser use need further tests on the phone. +The image reuses the upstream package-keyring initialization and screen-toggle +helpers. N61 retains its tested zram service and 512 MiB cap; the upstream zram +generator has a separate policy that requires device testing before adoption. +The N61 backlight permission rule targets the T7000 DWI device-tree address; +physical screen-toggle and brightness checks remain open. + +The demo session logs in as `tablet` and grants passwordless sudo. The public +archive contains desktop policy and public components; private NFS staging +creates the session identity and can provision an explicit owner SSH key. + +Run the shared component producers from the development checkout with its +`.venv/bin` on `PATH`. Select the intended ARM64 Podman connection before builds. +Every output directory must be new. + +```sh +python desktop/build --output out/n61-components/desktop +python desktop/build-cursor-helper --output out/n61-components/cursor +python desktop/build-bluetooth-tools --output out/n61-components/bluetooth +python desktop/build-labwc --output out/n61-components/labwc +bash system/build-rootfs n61 \ + --desktop out/n61-components/desktop --release n61-desktop-1 \ + --output out/n61-base +python -m release.n61.build \ + --base out/n61-base --modules-dir /path/to/coherent-kernel/lib/modules \ + --release n61-desktop-1 \ + --cursor-tools out/n61-components/cursor \ + --bluetooth-tools out/n61-components/bluetooth \ + --labwc-tools out/n61-components/labwc --output out/n61-release +python -m release.n61.audit out/n61-release +``` + +The release contains `rootfs.tar.gz`, its complete catalog, the package inventory, +the hardware kernel receipt as `kernel.json`, and `rootfs.json`. The producer +checks the receipt's device, platform, image, DTBs and complete module inventory. +It verifies the installed hardware recipe against the kernel receipt and records +the CLI and hardware package fingerprints. `--hardware` selects a matching +hardware checkout when its recipe differs from the installed package. +The auditor checks those module hashes alongside the AArch64 init, +software-rendering policy and privileged package metadata. J71b's installed +runtime and firmware activation policy stay outside this profile. + +Prepare touch firmware privately on the host through the shared Orchard adapter. +The explicit profile selects N61 independently of the checkout's default device. +This first acquisition allows the pinned Apple IPSW download; later runs verify +and reuse the cached output. Choose a new mixin output directory each time. + +```sh +uv run python - <<'PY' +from pathlib import Path +from orchard_cli.devices import load +from orchard_cli.progress import Clock, Progress + +device = load('n61') +outputs = device.firmware_inputs(Clock(), Progress(), allow_download=True) +archive, receipt = device.prepare_mixin(outputs, Path('out/private/n61-firmware')) +print(archive) +PY +``` + +Set `allow_download=False` when using a prepared cache. The recipe verifies +`N61.mtprops` from iOS 12.5.1 build 16H22 and produces +`lib/firmware/apple/n61-fwstart.z2fw`. Unit calibration comes from the phone's ADT +at boot. Retain `mixin.tar.gz` and its sibling `mixin.json` together as private +inputs for NFS staging. + +Expand the public release as root on a case-sensitive Linux filesystem dedicated to the N61 +export. Staging retains numeric owners and checks the package privilege bits +after extraction. Absolute guest links become equivalent relative links so host +inspection stays inside the staged root. + +```sh +python -m release.n61.prepare_nfs \ + --release out/n61-release --destination /srv/orchard/n61/root-1 \ + --firmware-mixin out/private/n61-firmware/mixin.tar.gz \ + --firmware-receipt out/private/n61-firmware/mixin.json \ + --authorized-key /path/to/owner-ed25519.pub +``` + +Supply both firmware options to include touch firmware in a private root. +Staging verifies the archive receipt, +N61 profile and every output hash against the installed hardware recipe before +writing firmware as root-owned mode 0600 files under `/usr/lib/firmware`. +The private staging receipt records the mixin hash and firmware inventory. +Public release artifacts keep their original hashes. The N61 touch driver has +passed physical contact, motion and release checks on the tethered desktop. +Use the complete module tree from the same native kernel component when +producing the N61 image; `apple_z2` is a module and needs the verified firmware +when it loads. The recorded touch boot applies to its matching kernel, DTB, +modules and firmware. A rebuilt combination needs its own physical checks. + +Omit `--authorized-key` when the RAM rescue shell supplies diagnostics. With an +owner key, staging creates private host keys and enables key-only OpenSSH for +`root` and `tablet`; this option requires `ssh-keygen` on the staging host. +The keys remain in this private NFS root. + +`/srv/orchard/n61/root-1/etc/orchard/nfs-root.json` binds a fresh UUID to the public +image and receipt hashes. Pass that exact file to +`python -m orchard_hardware.boot.initramfs.nfs.stage --receipt ...` when building +the native RAM experiment. Hardware verifies those bytes before handing off to +systemd. Keep the separate `root-1.private.json` staging receipt private. + +The physical sessions qualify the recorded artifacts and inputs above. Later +kernel, firmware or desktop combinations need their own device checks. diff --git a/system/n61/build-rootfs b/system/n61/build-rootfs new file mode 100755 index 0000000..5c77f20 --- /dev/null +++ b/system/n61/build-rootfs @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +set -euo pipefail +here=$(cd "$(dirname "$0")" && pwd) +exec "$here/../aarch64/build-rootfs" --profile n61 --packages "$here/packages.txt" "$@" diff --git a/system/n61/overlay/etc/sudoers.d/90-tablet-passwordless b/system/n61/overlay/etc/sudoers.d/90-tablet-passwordless new file mode 100644 index 0000000..027b8dd --- /dev/null +++ b/system/n61/overlay/etc/sudoers.d/90-tablet-passwordless @@ -0,0 +1 @@ +tablet ALL=(ALL:ALL) NOPASSWD: ALL diff --git a/system/n61/overlay/etc/systemd/logind.conf.d/60-omarchy-a-power.conf b/system/n61/overlay/etc/systemd/logind.conf.d/60-omarchy-a-power.conf new file mode 100644 index 0000000..4c3908a --- /dev/null +++ b/system/n61/overlay/etc/systemd/logind.conf.d/60-omarchy-a-power.conf @@ -0,0 +1,5 @@ +[Login] +HandlePowerKey=ignore +HandleSuspendKey=ignore +HandleHibernateKey=ignore +IdleAction=ignore diff --git a/system/n61/overlay/usr/lib/NetworkManager/conf.d/30-n61-usb.conf b/system/n61/overlay/usr/lib/NetworkManager/conf.d/30-n61-usb.conf new file mode 100644 index 0000000..dac29c5 --- /dev/null +++ b/system/n61/overlay/usr/lib/NetworkManager/conf.d/30-n61-usb.conf @@ -0,0 +1,2 @@ +[keyfile] +unmanaged-devices=interface-name:usb0 diff --git a/system/n61/overlay/usr/lib/firefox/distribution/policies.json b/system/n61/overlay/usr/lib/firefox/distribution/policies.json new file mode 100644 index 0000000..29dbe0a --- /dev/null +++ b/system/n61/overlay/usr/lib/firefox/distribution/policies.json @@ -0,0 +1,15 @@ +{ + "policies": { + "ExtensionSettings": { + "@omafox": { + "installation_mode": "normal_installed", + "install_url": "file:///usr/lib/firefox/distribution/extensions/@omafox.xpi" + } + }, + "Preferences": { + "dom.ipc.processCount": {"Value": 2, "Status": "default"}, + "browser.cache.memory.capacity": {"Value": 16384, "Status": "default"}, + "browser.tabs.unloadOnLowMemory": {"Value": true, "Status": "default"} + } + } +} diff --git a/system/n61/overlay/usr/lib/systemd/system/omarchy-zram.service b/system/n61/overlay/usr/lib/systemd/system/omarchy-zram.service new file mode 100644 index 0000000..b48718d --- /dev/null +++ b/system/n61/overlay/usr/lib/systemd/system/omarchy-zram.service @@ -0,0 +1,14 @@ +[Unit] +Description=Omarchy N61 compressed RAM swap +DefaultDependencies=no +After=systemd-udevd.service +Before=swap.target +ConditionPathExists=/sys/block/zram0/disksize + +[Service] +Type=oneshot +RemainAfterExit=yes +ExecStart=/usr/libexec/omarchy-zram + +[Install] +WantedBy=swap.target diff --git a/system/n61/overlay/usr/lib/udev/rules.d/70-n61-backlight.rules b/system/n61/overlay/usr/lib/udev/rules.d/70-n61-backlight.rules new file mode 100644 index 0000000..a8b5da3 --- /dev/null +++ b/system/n61/overlay/usr/lib/udev/rules.d/70-n61-backlight.rules @@ -0,0 +1,2 @@ +# N61's T7000 DWI backlight; the tablet user belongs to the video group. +ACTION=="add", SUBSYSTEM=="backlight", KERNEL=="20e200010.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/n61/overlay/usr/libexec/omarchy-zram b/system/n61/overlay/usr/libexec/omarchy-zram new file mode 100644 index 0000000..11fe0b1 --- /dev/null +++ b/system/n61/overlay/usr/libexec/omarchy-zram @@ -0,0 +1,12 @@ +#!/bin/sh +# Half the available RAM, capped at 512 MiB, with no storage-backed swap. +set -eu +g=/sys/block/zram0 +if grep -q '^/dev/zram0[[:space:]]' /proc/swaps; then exit 0; fi +[ "$(cat "$g/disksize")" = 0 ] +kb=$(awk '/^MemTotal:/ {print $2}' /proc/meminfo) +size=$((kb * 1024 / 2 / 4096 * 4096)) +[ "$size" -le 536870912 ] || size=536870912 +printf '%s\n' "$size" > "$g/disksize" +mkswap /dev/zram0 +swapon --priority 100 /dev/zram0 diff --git a/system/n61/overlay/usr/local/libexec/arch-ipad-scale-output b/system/n61/overlay/usr/local/libexec/arch-ipad-scale-output new file mode 100644 index 0000000..9159821 --- /dev/null +++ b/system/n61/overlay/usr/local/libexec/arch-ipad-scale-output @@ -0,0 +1,11 @@ +#!/bin/bash +# Preserve the kernel's timing and scale the N61 panel to 500 logical pixels. +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:]]+(750x1334|1334x750) px, .*current' <<< "$state"; then + echo 'N61 single-panel mode is pending; preserving the current scale' >&2 + exit 0 +fi +wlr-randr --output "${outputs[0]}" --scale 1.5 diff --git a/system/n61/packages.txt b/system/n61/packages.txt new file mode 100644 index 0000000..0bdd189 --- /dev/null +++ b/system/n61/packages.txt @@ -0,0 +1,37 @@ +# Arch Linux ARM userspace only. NEVER install linux-aarch64 in a device root. +# Baseline; exact dependency snapshot comes from an empty pacman DB preview. +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 + +# Owner keys are provisioned during private NFS staging. +openssh diff --git a/tests/test_n61_nfs_image.py b/tests/test_n61_nfs_image.py new file mode 100644 index 0000000..1a72cfd --- /dev/null +++ b/tests/test_n61_nfs_image.py @@ -0,0 +1,333 @@ +"""N61 artifact/ABI binding and private NFS staging, using tiny local fixtures.""" +from contextlib import ExitStack, redirect_stdout +import gzip +import hashlib +import io +import json +import os +from pathlib import Path +import stat +import tarfile +import tempfile +import unittest +from unittest.mock import patch +import uuid + +from orchard_cli import firmware + +from release.n61 import audit, build, prepare_nfs +from release.n61.common import KERNEL_RELEASE, PROJECT, encoded, member_record, sha + + +def elf(): + data = bytearray(64) + data[:6] = b'\x7fELF\x02\x01' + data[18:20] = b'\xb7\x00' + return bytes(data) + + +class N61ImageTests(unittest.TestCase): + def setUp(self): + self.temporary = tempfile.TemporaryDirectory() + self.addCleanup(self.temporary.cleanup) + self.root = Path(self.temporary.name) + self.base = self.root / 'base' + self.base.mkdir() + sudo = b'sudo fixture\n' + brightness = 'home/tablet/.local/share/omarchy-ipad/bin/omarchy-brightness-display' + old_brightness = b'old brightness helper\n' + mtree = ('./usr/bin/sudo type=file uid=0 gid=0 mode=4755 size=%d sha256digest=%s\n' + './var/log/journal type=dir uid=0 gid=190 mode=2755\n') % (len(sudo), hashlib.sha256(sudo).hexdigest()) + files = { + 'usr/lib/systemd/systemd': (elf(), 0o755), + 'usr/bin/sudo': (sudo, 0o4755), + 'usr/local/libexec/arch-ipad-login-session': ((PROJECT / 'desktop/configuration/login-session').read_bytes(), 0o755), + 'home/tablet/.config/labwc/autostart': ((PROJECT / 'desktop/configuration/labwc-autostart').read_bytes(), 0o755), + 'home/tablet/.local/share/omarchy-ipad/shell/shell.qml': (b'// shell fixture\n', 0o644), + brightness: (old_brightness, 0o755), + 'arch-ipad-ui-manifest.json': (encoded({brightness: hashlib.sha256(old_brightness).hexdigest()}), 0o644), + 'var/lib/pacman/local/sudo-1/mtree': (mtree.encode(), 0o644), + } + directories = {'dev', 'proc', 'sys', 'run', 'etc', 'var/log/journal'} + for key in files: + directories.update(str(p) for p in Path(key).parents if str(p) != '.') + archive = self.base / 'rootfs.tar.gz' + with tarfile.open(archive, 'w:gz') as stream: + for key in sorted(directories): + member = tarfile.TarInfo(key) + member.type, member.mode = tarfile.DIRTYPE, 0o755 + if key == 'var/log/journal': + member.mode, member.gid = 0o2755, 190 + stream.addfile(member) + for key, (data, mode) in files.items(): + member = tarfile.TarInfo(key) + member.mode, member.size = mode, len(data) + stream.addfile(member, io.BytesIO(data)) + member = tarfile.TarInfo('lib') + member.type, member.linkname = tarfile.SYMTYPE, '/usr/lib' + stream.addfile(member) + (self.base / 'packages.lock').write_text('sudo 1\nlabwc 0.20\n') + self.receipt = {'kind': 'omarchy-a.base', 'profile': 'n61', 'architecture': 'aarch64', + 'release': 'n61-fixture', 'bytes': archive.stat().st_size, 'sha256': sha(archive), + 'packages_lock_sha256': sha(self.base / 'packages.lock')} + (self.base / 'rootfs.tar.gz.json').write_bytes(encoded(self.receipt)) + self.kernel = self.root / 'kernel' + modules = self.kernel / 'lib/modules' / KERNEL_RELEASE + modules.mkdir(parents=True) + (self.kernel / 'kernel.release').write_text(KERNEL_RELEASE + '\n') + (modules / 'modules.dep').write_text('fixture.ko:\n') + (modules / 'fixture.ko').write_bytes(elf() + b'vermagic=' + KERNEL_RELEASE.encode() + b' SMP\n') + (self.kernel / 'Image.gz').write_bytes(b'kernel fixture') + (self.kernel / 'dtbs').mkdir() + (self.kernel / 'n61.dtb').write_bytes(b'n61 device tree fixture') + (self.kernel / 'dtbs/t7000-n61.dtb').write_bytes(b'n61 device tree fixture') + kernel_files = {} + for path in self.kernel.rglob('*'): + if path.is_file(): + kernel_files[path.relative_to(self.kernel).as_posix()] = {'bytes': path.stat().st_size, 'sha256': sha(path)} + self.hardware = self.root / 'hardware' + self.hardware.mkdir() + (self.hardware / '__init__.py').write_text('') + recipe_files = {} + for key in ('devices/n61/device.toml', 'devices/n61/firmware.json', 'kernel/configs/apple-4k/config'): + path = self.hardware / key + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(b'fixture recipe') + recipe_files[key] = {'bytes': path.stat().st_size, 'sha256': sha(path)} + recipe_sha = hashlib.sha256(json.dumps(recipe_files, sort_keys=True, separators=(',', ':')).encode()).hexdigest() + self.kernel_receipt = {'schema': 1, 'device': 'n61', 'platform': 'apple-4k', 'dtb': 't7000-n61', + 'kernel_release': KERNEL_RELEASE, 'source': { + 'commit': '1' * 40, 'tree': '2' * 40, 'prepared_tree': '3' * 40, + 'patch_list_sha256': '4' * 64, 'config_sha256': '5' * 64}, + 'hardware_recipe': {'sha256': recipe_sha, 'files': recipe_files}, + 'files': kernel_files} + (self.kernel / 'receipt.json').write_bytes(encoded(self.kernel_receipt)) + self.modules = modules.parent + self.components = ExitStack() + self.addCleanup(self.components.close) + self.components.enter_context(patch.object(build.desktop, 'cursor_files', return_value={'usr/libexec/omarchy-wtype': (elf(), 0o755)})) + self.components.enter_context(patch.object(build.desktop, 'bluetooth_files', return_value={})) + self.components.enter_context(patch.object(build.desktop, 'labwc_files', return_value={'usr/bin/labwc': (elf(), 0o755)})) + + def compose(self): + output = self.root / 'release' + result = build.build(self.base, self.modules, Path('/cursor'), Path('/bluetooth'), Path('/labwc'), output, + hardware=self.hardware) + return output, result + + def firmware_mixin(self, *, profile='n61', data=b'N61 firmware fixture\n'): + destination = 'lib/firmware/apple/n61-fwstart.z2fw' + prepared = self.root / 'mixin-source' + path = prepared / 'firmware' / destination + path.parent.mkdir(parents=True) + path.write_bytes(data) + output = self.root / 'private-mixin' + output.mkdir() + metadata = {'schema': 1, 'profile': profile, + 'files': {destination: {'bytes': len(data), 'sha256': sha(path)}}} + mixin, _ = firmware.pack_mixin(prepared, metadata, output) + expected = {'outputs': [{'destination': destination, 'sha256': sha(path)}]} + return mixin, output / 'mixin.json', expected + + def test_native_root_keeps_matching_modules_and_software_desktop_policy(self): + output, result = self.compose() + self.assertEqual(audit.audit(output), result) + self.assertEqual(result['kernel']['receipt_sha256'], sha(self.kernel / 'receipt.json')) + self.assertEqual(json.loads((output / 'kernel.json').read_text()), self.kernel_receipt) + self.assertEqual(result['desktop']['scale'], 1.5) + catalog = json.loads((output / 'rootfs.catalog.json').read_text()) + self.assertIn('usr/lib/modules/' + KERNEL_RELEASE + '/fixture.ko', catalog) + self.assertEqual(catalog['usr/bin/sudo']['mode'], 0o4755) + self.assertEqual(catalog['var/log/journal']['mode'], 0o2755) + self.assertEqual(catalog['etc/sudoers.d/90-tablet-passwordless']['mode'], 0o440) + self.assertNotIn('etc/orchard/nfs-root.json', catalog) + self.assertFalse(any('orchard-j71b' in key for key in catalog)) + self.assertIn('usr/lib/systemd/system/omarchy-zram.service', catalog) + for key in ('usr/libexec/omarchy-screen-toggle', + 'usr/lib/systemd/system/omarchy-pacman-init.service'): + source = PROJECT / 'system/j71b/overlay' / key + self.assertEqual(catalog[key]['sha256'], sha(source)) + self.assertEqual(catalog['usr/libexec/omarchy-screen-toggle']['mode'], 0o755) + backlight_rule = 'usr/lib/udev/rules.d/70-n61-backlight.rules' + self.assertEqual(catalog[backlight_rule]['sha256'], sha(PROJECT / 'system/n61/overlay' / backlight_rule)) + self.assertIn('KERNEL=="20e200010.backlight"', + (PROJECT / 'system/n61/overlay' / backlight_rule).read_text()) + brightness = 'home/tablet/.local/share/omarchy-ipad/bin/omarchy-brightness-display' + self.assertEqual(catalog[brightness]['sha256'], sha(PROJECT / 'desktop/shell/bin/omarchy-brightness-display')) + self.assertEqual((catalog[brightness]['uid'], catalog[brightness]['gid'], catalog[brightness]['mode']), + (1000, 1000, 0o755)) + self.assertEqual( + catalog['etc/systemd/system/multi-user.target.wants/omarchy-pacman-init.service']['target'], + '/usr/lib/systemd/system/omarchy-pacman-init.service') + + def test_wrong_base_architecture_and_module_release_stop_before_output(self): + receipt = dict(self.receipt, architecture='armv7h') + (self.base / 'rootfs.tar.gz.json').write_bytes(encoded(receipt)) + with self.assertRaisesRegex(ValueError, 'AArch64 base'): + self.compose() + (self.base / 'rootfs.tar.gz.json').write_bytes(encoded(self.receipt)) + (self.kernel / 'kernel.release').write_text('wrong-kernel\n') + with self.assertRaisesRegex(ValueError, '4K kernel module'): + self.compose() + self.assertFalse((self.root / 'release').exists()) + + def test_changed_image_or_receipt_is_rejected(self): + output, _ = self.compose() + path = output / 'rootfs.json' + receipt = json.loads(path.read_text()) + receipt['kernel_release'] = 'wrong-kernel' + path.write_bytes(encoded(receipt)) + with self.assertRaisesRegex(ValueError, 'NFS root contract'): + audit.audit(output) + receipt['kernel_release'] = KERNEL_RELEASE + path.write_bytes(encoded(receipt)) + with (output / 'rootfs.tar.gz').open('ab') as stream: + stream.write(b'changed') + with self.assertRaisesRegex(ValueError, 'root stream differs'): + audit.audit(output) + + def test_kernel_component_rejects_mismatched_image_module_and_identity(self): + for relative in ('Image.gz', 'lib/modules/' + KERNEL_RELEASE + '/fixture.ko'): + path = self.kernel / relative + original = path.read_bytes() + path.write_bytes(original + b'changed') + with self.subTest(relative=relative), self.assertRaisesRegex(ValueError, 'kernel component differs'): + self.compose() + self.assertFalse((self.root / 'release').exists()) + path.write_bytes(original) + changed = dict(self.kernel_receipt, device='j71b') + (self.kernel / 'receipt.json').write_bytes(encoded(changed)) + with self.assertRaisesRegex(ValueError, 'component identity'): + self.compose() + self.assertFalse((self.root / 'release').exists()) + + def test_hardware_snapshot_and_release_name_are_bound_to_output(self): + changed = self.hardware / 'devices/n61/firmware.json' + changed.write_bytes(b'changed') + with self.assertRaisesRegex(ValueError, 'Hardware package differs'): + self.compose() + changed.write_bytes(b'fixture recipe') + output = self.root / 'named-release' + result = build.build(self.base, self.modules, Path('/cursor'), Path('/bluetooth'), Path('/labwc'), output, + release='n61-20261029.1', hardware=self.hardware) + self.assertEqual(result['release'], 'n61-20261029.1') + self.assertEqual(result['base_release'], 'n61-fixture') + self.assertEqual(result['producer']['hardware']['kind'], 'installed') + self.assertEqual(audit.audit(output), result) + + def test_private_staging_mints_exact_root_binding_and_preserves_guest_links(self): + output, receipt = self.compose() + destination = self.root / 'private-root' + with patch.object(prepare_nfs.os, 'geteuid', return_value=0), \ + patch.object(prepare_nfs.os, 'chown'), patch.object(prepare_nfs.os, 'lchown'), \ + patch.object(prepare_nfs.os, 'fchown'), \ + patch.object(prepare_nfs.os, 'fchmod', wraps=os.fchmod) as chmod, \ + patch.object(prepare_nfs, 'verify_privileges'), \ + patch.object(prepare_nfs, 'require_case_sensitive'), redirect_stdout(io.StringIO()): + staged = prepare_nfs.prepare(output, destination) + marker = destination / 'etc/orchard/nfs-root.json' + identity = json.loads(marker.read_text()) + self.assertEqual(identity['kind'], 'orchard.nfs-root') + self.assertEqual(identity['architecture'], 'aarch64') + self.assertEqual(identity['root_id'], str(uuid.UUID(identity['root_id']))) + self.assertEqual(identity['image_sha256'], receipt['root']['sha256']) + self.assertEqual(identity['image_receipt_sha256'], sha(output / 'rootfs.json')) + self.assertEqual((destination / 'etc/hostname').read_text(), 'omarchy-n61\n') + self.assertIn('127.0.1.1 omarchy-n61\n', (destination / 'etc/hosts').read_text()) + self.assertEqual(staged['root_identity_sha256'], sha(marker)) + self.assertEqual(os.readlink(destination / 'lib'), 'usr/lib') + self.assertIn(0o4755, [call.args[1] for call in chmod.call_args_list]) + self.assertEqual(stat.S_IMODE(destination.stat().st_mode), 0o755) + self.assertEqual(stat.S_IMODE((destination.parent / 'private-root.private.json').stat().st_mode), 0o600) + + def test_archive_link_escape_is_rejected(self): + member = tarfile.TarInfo('usr/link') + member.type, member.linkname = tarfile.SYMTYPE, '../../outside' + with self.assertRaisesRegex(ValueError, 'escapes'): + member_record(member) + + def test_native_private_firmware_is_root_owned_and_bound_to_staging_receipt(self): + output, public = self.compose() + mixin, receipt, expected = self.firmware_mixin() + destination = self.root / 'firmware-root' + with patch.object(prepare_nfs.os, 'geteuid', return_value=0), \ + patch.object(prepare_nfs.os, 'chown'), patch.object(prepare_nfs.os, 'lchown'), \ + patch.object(prepare_nfs.os, 'fchown') as chown, \ + patch.object(prepare_nfs, 'verify_privileges'), \ + patch.object(prepare_nfs, 'require_case_sensitive'), \ + patch.object(prepare_nfs.firmware, 'recipe_for', return_value=expected) as recipe, \ + patch.object(prepare_nfs.firmware, 'ensure', side_effect=AssertionError('firmware acquisition')), \ + redirect_stdout(io.StringIO()): + result = prepare_nfs.prepare(output, destination, firmware_mixin=mixin, firmware_receipt=receipt) + recipe.assert_called_once_with('n61') + key = expected['outputs'][0]['destination'] + staged = destination / ('usr/' + key) + self.assertEqual(sha(staged), expected['outputs'][0]['sha256']) + self.assertEqual((destination / key).read_bytes(), staged.read_bytes()) + self.assertEqual(stat.S_IMODE(staged.stat().st_mode), 0o600) + self.assertEqual(chown.call_args.args[1:], (0, 0)) + self.assertEqual(result['firmware_mixin']['sha256'], sha(mixin)) + self.assertEqual(result['firmware_mixin']['files'][key]['sha256'], sha(staged)) + self.assertEqual(audit.audit(output), public) + self.assertNotIn(key, json.loads((output / 'rootfs.catalog.json').read_text())) + + def test_private_firmware_requires_both_inputs_before_creating_a_root(self): + output, _ = self.compose() + destination = self.root / 'incomplete-root' + for inputs in ({'firmware_mixin': self.root / 'mixin.tar.gz'}, + {'firmware_receipt': self.root / 'mixin.json'}): + with self.subTest(inputs=inputs), patch.object(prepare_nfs.os, 'geteuid', return_value=0), \ + self.assertRaisesRegex(ValueError, 'requires both'): + prepare_nfs.prepare(output, destination, **inputs) + self.assertFalse(destination.exists()) + + def test_private_firmware_rejects_profile_receipt_and_pinned_content_mismatches(self): + mixin, receipt, expected = self.firmware_mixin(profile='j71b') + original = receipt.read_bytes() + with patch.object(prepare_nfs.firmware, 'recipe_for', return_value=expected): + with self.assertRaisesRegex(ValueError, 'not for N61'): + prepare_nfs.read_firmware(mixin, receipt) + record = json.loads(original) + record['sha256'] = '0' * 64 + receipt.write_bytes(encoded(record)) + with self.assertRaisesRegex(ValueError, 'differs from its receipt'): + prepare_nfs.read_firmware(mixin, receipt) + receipt.write_bytes(original) + with tarfile.open(mixin, 'w:gz') as stream: + key = expected['outputs'][0]['destination'] + files = {key: b'changed', 'mixin.json': encoded({'schema': 1, 'profile': 'n61', 'files': { + key: {'bytes': 7, 'sha256': hashlib.sha256(b'changed').hexdigest()}}})} + for key, data in files.items(): + member = tarfile.TarInfo(key) + member.size = len(data) + stream.addfile(member, io.BytesIO(data)) + record.update(bytes=mixin.stat().st_size, sha256=sha(mixin)) + receipt.write_bytes(encoded(record)) + with self.assertRaisesRegex(ValueError, 'differs from the N61 recipe'): + prepare_nfs.read_firmware(mixin, receipt) + + def test_private_firmware_rejects_unsafe_members_and_linked_root_parents(self): + mixin, receipt, expected = self.firmware_mixin() + for unsafe, kind in (('../escape', tarfile.REGTYPE), + ('/absolute', tarfile.REGTYPE), + (expected['outputs'][0]['destination'], tarfile.SYMTYPE)): + with self.subTest(member=unsafe, kind=kind): + with tarfile.open(mixin, 'w:gz') as stream: + member = tarfile.TarInfo(unsafe) + member.type, member.linkname = kind, '/outside' + stream.addfile(member) + receipt.write_bytes(encoded({'schema': 1, 'kind': 'private-mixin', + 'bytes': mixin.stat().st_size, 'sha256': sha(mixin)})) + with patch.object(prepare_nfs.firmware, 'recipe_for', return_value=expected), self.assertRaises(ValueError): + prepare_nfs.read_firmware(mixin, receipt) + root, outside = self.root / 'target', self.root / 'outside' + root.mkdir() + outside.mkdir() + (root / 'usr').symlink_to(outside) + with self.assertRaises(OSError): + prepare_nfs.stage_firmware(root, {expected['outputs'][0]['destination']: b'data'}) + self.assertEqual(list(outside.iterdir()), []) + + +if __name__ == '__main__': + unittest.main() diff --git a/tools/nfs/Containerfile b/tools/nfs/Containerfile new file mode 100644 index 0000000..3b38d82 --- /dev/null +++ b/tools/nfs/Containerfile @@ -0,0 +1,9 @@ +# Development NFS host. Device images and private roots are supplied separately. +FROM docker.io/library/debian@sha256:a6db089d0e655c0b29cf0104d17ec02f391ff3039ee929a725c41c07fb405ead +RUN apt-get update && apt-get install -y --no-install-recommends \ + nfs-ganesha nfs-ganesha-vfs rpcbind dbus ca-certificates \ + && rm -rf /var/lib/apt/lists/* \ + && mkdir -p /run/ganesha /var/lib/nfs/ganesha /srv/root +COPY entrypoint /usr/local/sbin/orchard-nfs-server +RUN chmod 755 /usr/local/sbin/orchard-nfs-server +ENTRYPOINT ["/usr/local/sbin/orchard-nfs-server"] diff --git a/tools/nfs/README.md b/tools/nfs/README.md new file mode 100644 index 0000000..4c3662e --- /dev/null +++ b/tools/nfs/README.md @@ -0,0 +1,31 @@ +# N61 development NFS host + +This container serves a private root produced by `release.n61.prepare_nfs`. +Its source must live on a Linux filesystem that preserves numeric ownership +and package modes. Use a dedicated container and bind its published TCP port +to the NFS host's loopback address. The N61 USB host forwards port 32061 onto +the phone's dedicated link at 10.61.0.1. + +`n61.conf` selects NFSv4 over TCP and sends numeric UID/GID attributes so the +RAM client can preserve guest ownership without a name-mapping daemon. It is +intended for the trusted USB development link. Keep the root and its keys +private. The VFS backend requires a container with permission to open Linux +filesystem handles; a rootless container cannot supply that host capability. + +Build with the project's separate N61 Podman endpoint selected: + +```sh +podman build -f tools/nfs/Containerfile -t localhost/orchard-n61-nfs:bookworm tools/nfs +``` + +Bind the prepared root at `/srv/root` and this configuration read-only at +`/etc/ganesha/ganesha.conf`. Publish `127.0.0.1:32061:2049/tcp`. Record the +container image ID, root identity and owned forwarding process with the boot +evidence. Host exports and containers used by other devices stay in place. + +Supply `--tmpfs /run:rw,nosuid,nodev,mode=0755` when creating the container. +Its RPC and D-Bus processes keep PID files there; a fresh runtime directory +allows the same container to restart after its processes have exited. + +The hardware runtime owns root validation and PID 1 handoff. This server +supplies the file service and has no device boot or storage operation. diff --git a/tools/nfs/entrypoint b/tools/nfs/entrypoint new file mode 100644 index 0000000..87c21cf --- /dev/null +++ b/tools/nfs/entrypoint @@ -0,0 +1,7 @@ +#!/bin/sh +set -eu +mkdir -p /run/rpcbind /run/dbus /run/ganesha /var/lib/dbus +dbus-uuidgen --ensure=/var/lib/dbus/machine-id +rpcbind -w +dbus-daemon --system --fork +exec /usr/bin/ganesha.nfsd -F -L STDOUT -f /etc/ganesha/ganesha.conf diff --git a/tools/nfs/n61.conf b/tools/nfs/n61.conf new file mode 100644 index 0000000..4b3a60f --- /dev/null +++ b/tools/nfs/n61.conf @@ -0,0 +1,29 @@ +# Use a dedicated container and Linux filesystem for this export. +NFS_CORE_PARAM { + Protocols = 4; + NFS_Port = 2049; + Enable_UDP = false; + Enable_NLM = false; +} + +NFS_KRB5 { Active_krb5 = false; } + +# The RAM client uses AUTH_SYS numeric IDs and has no name-mapping daemon. +NFSV4 { + Allow_Numeric_Owners = true; + Only_Numeric_Owners = true; +} + +EXPORT { + Export_Id = 61; + Path = /srv/root; + Pseudo = /n61-root; + Access_Type = RW; + Squash = No_Root_Squash; + SecType = sys; + Protocols = 4; + Transports = TCP; + FSAL { Name = VFS; } +} + +LOG { Default_Log_Level = EVENT; }