From ac266189ebb4eb39427170d686eb3282c21b36c2 Mon Sep 17 00:00:00 2001 From: eKisNonos Date: Sun, 20 Sep 2026 12:00:00 +0200 Subject: [PATCH 001/244] store: the app store capsule Reads the signed marketplace index, lists what the running image can install, and installs through the queue init already owns. The install buttons did nothing: the painter and the hit test each computed their own rectangles, so a click never matched a row. Both derive from one geometry module now and the click path reaches the same install::ask as Enter. Search filters as typed, the list scrolls with a real scrollbar, and selection survives a refresh. init gains an install queue and a wake path so a store request is serviced on arrival rather than on the next supervisor spin. The surface registry can attach frames to a surface a capsule owns. Scrollbar arithmetic mirrored in python: empty, shorter than the viewport, thumb at both ends, single row overflow. --- .keys/app_store_publisher_ed25519.pub | Bin 0 -> 43 bytes .keys/app_store_publisher_mldsa65.pub | Bin 0 -> 1963 bytes Cargo.toml | 4 + mk/20-build.mk | 75 ++++++++-- .../surface_registry/share/attach_frames.rs | 60 ++++++++ .../surface_registry/share/attach_surface.rs | 70 ++-------- src/kernel_core/surface_registry/share/mod.rs | 2 + .../surface_registry/share/self_attach.rs | 49 +++++++ src/userspace/capsule_app_store/embed.rs | 49 +++++++ src/userspace/capsule_app_store/mod.rs | 24 ++++ src/userspace/capsule_app_store/spawn.rs | 62 +++++++++ src/userspace/capsule_app_store/state.rs | 27 ++++ src/userspace/init/install_queue.rs | 61 ++++++++ src/userspace/init/instance_spawn/queue.rs | 18 +-- src/userspace/init/mod.rs | 6 + src/userspace/init/spawn_plan/apps.rs | 20 +++ src/userspace/init/supervisor/loop_impl.rs | 48 +++---- src/userspace/init/wake.rs | 56 ++++++++ src/userspace/mod.rs | 15 +- tools/nonos-icon-store | 108 +++++++++++++++ userland/assets/icons/store.a8 | Bin 0 -> 36864 bytes userland/assets/icons/store.svg | 1 + userland/capsule_app_store/Capsule.mk | 26 ++++ userland/capsule_app_store/Cargo.lock | 131 ++++++++++++++++++ userland/capsule_app_store/Cargo.toml | 43 ++++++ userland/capsule_app_store/build.rs | 53 +++++++ userland/capsule_app_store/src/main.rs | 31 +++++ userland/capsule_app_store/src/store/app.rs | 44 ++++++ .../capsule_app_store/src/store/consent.rs | 64 +++++++++ userland/capsule_app_store/src/store/event.rs | 48 +++++++ .../src/store/event_actions.rs | 57 ++++++++ .../src/store/event_click.rs | 45 ++++++ .../capsule_app_store/src/store/event_keys.rs | 61 ++++++++ .../capsule_app_store/src/store/event_rows.rs | 49 +++++++ .../src/store/event_search.rs | 65 +++++++++ .../capsule_app_store/src/store/event_tab.rs | 24 ++++ .../capsule_app_store/src/store/install.rs | 59 ++++++++ .../capsule_app_store/src/store/listing.rs | 61 ++++++++ .../capsule_app_store/src/store/manifest.rs | 43 ++++++ .../src/store/market/detail.rs | 55 ++++++++ .../src/store/market/list.rs | 52 +++++++ .../capsule_app_store/src/store/market/mod.rs | 28 ++++ .../src/store/market/ready.rs | 55 ++++++++ .../src/store/market/service.rs | 48 +++++++ .../src/store/market/wire.rs | 67 +++++++++ userland/capsule_app_store/src/store/mod.rs | 46 ++++++ .../capsule_app_store/src/store/search.rs | 72 ++++++++++ userland/capsule_app_store/src/store/state.rs | 47 +++++++ .../capsule_app_store/src/store/state_move.rs | 56 ++++++++ .../capsule_app_store/src/store/state_ops.rs | 50 +++++++ .../src/store/state_refresh.rs | 49 +++++++ .../src/store/state_select.rs | 40 ++++++ .../src/store/state_window.rs | 56 ++++++++ userland/capsule_app_store/src/store/tab.rs | 50 +++++++ userland/capsule_app_store/src/store/theme.rs | 50 +++++++ .../capsule_app_store/src/store/ui/card.rs | 70 ++++++++++ .../capsule_app_store/src/store/ui/chrome.rs | 64 +++++++++ .../src/store/ui/consent_text.rs | 29 ++++ .../capsule_app_store/src/store/ui/counter.rs | 34 +++++ .../capsule_app_store/src/store/ui/detail.rs | 54 ++++++++ .../capsule_app_store/src/store/ui/frame.rs | 49 +++++++ .../capsule_app_store/src/store/ui/gates.rs | 53 +++++++ .../src/store/ui/geometry.rs | 65 +++++++++ .../capsule_app_store/src/store/ui/hex.rs | 28 ++++ .../capsule_app_store/src/store/ui/metrics.rs | 60 ++++++++ .../capsule_app_store/src/store/ui/mod.rs | 37 +++++ .../capsule_app_store/src/store/ui/rows.rs | 49 +++++++ .../src/store/ui/scrollbar.rs | 45 ++++++ .../src/store/ui/searchbar.rs | 53 +++++++ .../src/store/ui/standing.rs | 50 +++++++ .../capsule_app_store/src/store/ui/status.rs | 43 ++++++ .../capsule_app_store/src/store/ui/text.rs | 51 +++++++ .../capsule_app_store/src/store/ui/wrap.rs | 42 ++++++ .../capsule_app_store/src/store/verdict.rs | 61 ++++++++ .../capsule_desktop_shell/src/render/icons.rs | 1 + .../capsule_desktop_shell/src/state/apps.rs | 4 +- 76 files changed, 3296 insertions(+), 126 deletions(-) create mode 100644 .keys/app_store_publisher_ed25519.pub create mode 100644 .keys/app_store_publisher_mldsa65.pub create mode 100644 src/kernel_core/surface_registry/share/attach_frames.rs create mode 100644 src/kernel_core/surface_registry/share/self_attach.rs create mode 100644 src/userspace/capsule_app_store/embed.rs create mode 100644 src/userspace/capsule_app_store/mod.rs create mode 100644 src/userspace/capsule_app_store/spawn.rs create mode 100644 src/userspace/capsule_app_store/state.rs create mode 100644 src/userspace/init/install_queue.rs create mode 100644 src/userspace/init/wake.rs create mode 100755 tools/nonos-icon-store create mode 100644 userland/assets/icons/store.a8 create mode 100644 userland/assets/icons/store.svg create mode 100644 userland/capsule_app_store/Capsule.mk create mode 100644 userland/capsule_app_store/Cargo.lock create mode 100644 userland/capsule_app_store/Cargo.toml create mode 100644 userland/capsule_app_store/build.rs create mode 100644 userland/capsule_app_store/src/main.rs create mode 100644 userland/capsule_app_store/src/store/app.rs create mode 100644 userland/capsule_app_store/src/store/consent.rs create mode 100644 userland/capsule_app_store/src/store/event.rs create mode 100644 userland/capsule_app_store/src/store/event_actions.rs create mode 100644 userland/capsule_app_store/src/store/event_click.rs create mode 100644 userland/capsule_app_store/src/store/event_keys.rs create mode 100644 userland/capsule_app_store/src/store/event_rows.rs create mode 100644 userland/capsule_app_store/src/store/event_search.rs create mode 100644 userland/capsule_app_store/src/store/event_tab.rs create mode 100644 userland/capsule_app_store/src/store/install.rs create mode 100644 userland/capsule_app_store/src/store/listing.rs create mode 100644 userland/capsule_app_store/src/store/manifest.rs create mode 100644 userland/capsule_app_store/src/store/market/detail.rs create mode 100644 userland/capsule_app_store/src/store/market/list.rs create mode 100644 userland/capsule_app_store/src/store/market/mod.rs create mode 100644 userland/capsule_app_store/src/store/market/ready.rs create mode 100644 userland/capsule_app_store/src/store/market/service.rs create mode 100644 userland/capsule_app_store/src/store/market/wire.rs create mode 100644 userland/capsule_app_store/src/store/mod.rs create mode 100644 userland/capsule_app_store/src/store/search.rs create mode 100644 userland/capsule_app_store/src/store/state.rs create mode 100644 userland/capsule_app_store/src/store/state_move.rs create mode 100644 userland/capsule_app_store/src/store/state_ops.rs create mode 100644 userland/capsule_app_store/src/store/state_refresh.rs create mode 100644 userland/capsule_app_store/src/store/state_select.rs create mode 100644 userland/capsule_app_store/src/store/state_window.rs create mode 100644 userland/capsule_app_store/src/store/tab.rs create mode 100644 userland/capsule_app_store/src/store/theme.rs create mode 100644 userland/capsule_app_store/src/store/ui/card.rs create mode 100644 userland/capsule_app_store/src/store/ui/chrome.rs create mode 100644 userland/capsule_app_store/src/store/ui/consent_text.rs create mode 100644 userland/capsule_app_store/src/store/ui/counter.rs create mode 100644 userland/capsule_app_store/src/store/ui/detail.rs create mode 100644 userland/capsule_app_store/src/store/ui/frame.rs create mode 100644 userland/capsule_app_store/src/store/ui/gates.rs create mode 100644 userland/capsule_app_store/src/store/ui/geometry.rs create mode 100644 userland/capsule_app_store/src/store/ui/hex.rs create mode 100644 userland/capsule_app_store/src/store/ui/metrics.rs create mode 100644 userland/capsule_app_store/src/store/ui/mod.rs create mode 100644 userland/capsule_app_store/src/store/ui/rows.rs create mode 100644 userland/capsule_app_store/src/store/ui/scrollbar.rs create mode 100644 userland/capsule_app_store/src/store/ui/searchbar.rs create mode 100644 userland/capsule_app_store/src/store/ui/standing.rs create mode 100644 userland/capsule_app_store/src/store/ui/status.rs create mode 100644 userland/capsule_app_store/src/store/ui/text.rs create mode 100644 userland/capsule_app_store/src/store/ui/wrap.rs create mode 100644 userland/capsule_app_store/src/store/verdict.rs diff --git a/.keys/app_store_publisher_ed25519.pub b/.keys/app_store_publisher_ed25519.pub new file mode 100644 index 0000000000000000000000000000000000000000..38557b00d3cd1f522c37f562c16d3360b7f523c5 GIT binary patch literal 43 zcmebC_wx@9@HS**P}n0q%~;*UyvIIJPCGHV)%3P(-8ZSE&26uzEEgqrKV6uZ_kK>4$kj?9cl<-D z{j@c@ZQcp{7m@dF>|w*AYZk)G*$Of*CR9~|H;mz%jSH`F+}YlA5_J= zwz<6dcc+;x6_8Jz>C3_b;)Ook#}5|jqE!i?Eshuj(C(uSUclW)07ex6IVTB?iNY%5 zJQ~02W%IRWFm}qRyS&dxo4Lv;R5?(mz-Z?#q^GT`VK1t@?Q)oZfcmcXR@g99DDkrg zGS!#_+se&!*5?f)eaeLYQ-D01Mm&4o)ts-W#!w~rs__VWKdF6v%U{Lw8l7aStmQaJGH~V zu@F@GJus6kC`mPz%9G3(Q8f(&vN;KjI>nwA&}^D~Dnq7A;=ROXsS8OUtMIZx+;#Up zM42oF&PcCP)KJNzb>dA6!!W|5>fBe%6jEI&X@&UusbFS%XRK2kl+r?*I(Vs_WgkgW zbK}qeLXIBr>RiVb&zwI8Qc^A&!HgT^tTwnem zD3@Luuw;zNWc8vI;(paUor@GvkGfqvy7~+)hIwftk7~FTz z`uDE^Hyzq*kkc{_?>-?*i;00np$k4Sh_`Xit9}|XfG>P&jQY%;o3;RPq8ka17FGGX znw^#rrNjAmYTby51^bQydoA1IFK{FLUr_2qeY>}+jLZ1)jAhhh+bP*8=5o5%6taz) zxLX`%CzYq8v_p@bJ8-GPs)gg!@>0-l+Yomg(2ya-2RcKogo?9Z+wRt;Bc35`xS-x+ z4JqsSV@YQ9`^w0N1_u`7&xo-i10&_6s$^gkZQS_GeQb;M;Urxut6AnSOktX~!Aa_n zfw;tXj4{s}6#Pw+dWjF!?KyyQXShcx_>W!}VwtEb#(6tvc$a=4!WWaruYHXGhH%jxZyh05 zX8;ernqE*|$yhnfOE_{^$n4{7uQ{;021c8wkH*N~DY_Pxc!D)M#h|KxDV9x<}>6-Nqv5c+VSlwwmVdF)7BsI3te~c=q z4|j^!nkPR~3hEMqEx%3z8{((2hzrq*Veg3|pamTXs79mTZqwnPrw}2{^w(dYPDG}t zb|IIAnBU(D1Fmtq_8=j|frKlkSukfvdjfNXSTY!C!jXo*^mV;3C>Mdr$h4rk4s1sva{`g5TCRWXlpmmpr^&>yX;Q$YO?!AnhlzZnCu5!~u;Diz!A`<^nv!BGOOn z5Uy2~{)*=ycvj>Fy+%oWQ^?DDM95iR;h0@uV*%LF3F<#QFI!S(xvH2IJM%GJCBE@K zEnGkhG0QkZ_kCocuNv_M99j6}7ju9%b9+9jH2wo~7Kg)<=5EAI4qA$-iXpC3vG}$( zQ1cFxUE+6ua9Dyq-K&!$0 z$2R&we#)1@(f>ejoMAxT=dS0EY3tuoyv4u&NETHgGM0tAjn}4=|GL-Y&=?Wep6g6Y zrp5__Se3#fbiaqaNvpcNobC&kwQcTH2ro;Py?az;ILd*dtozVWNoEQ6TV=4|R|m>9 z=zqZ7GFksDX?NC5*3qz_?(jmde|S?=o*dJ9_xG^jV9<6Hm$ literal 0 HcmV?d00001 diff --git a/Cargo.toml b/Cargo.toml index 5a63748cf6..9f907d1a46 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -110,6 +110,7 @@ nonos-capsule-image-codec = [] nonos-capsule-image-viewer = [] nonos-capsule-video-player = [] nonos-capsule-about = [] +nonos-capsule-app-store = [] nonos-capsule-audio-player = [] nonos-capsule-hello = [] nonos-capsule-boot-splash = [] @@ -160,6 +161,7 @@ nonos-capsule-net-core = [] nonos-capsule-net-sockets = [] nonos-capsule-net-nym = [] nonos-capsule-socks5 = [] +nonos-capsule-linux = [] nonos-capsule-market = [] # Layout-stable debug ring. Allocation-free, formatter-free fixed-VA @@ -550,6 +552,8 @@ microkernel-desktop-base = [ "nonos-capsule-wallpaper-catalog", "nonos-capsule-toolkit", "nonos-capsule-about", + "nonos-capsule-app-store", + "nonos-capsule-linux", "nonos-capsule-audio", "nonos-capsule-driver-hda", "nonos-capsule-boot-splash", diff --git a/mk/20-build.mk b/mk/20-build.mk index 4119617f38..6c55ebb2cf 100644 --- a/mk/20-build.mk +++ b/mk/20-build.mk @@ -3,7 +3,7 @@ # STARK attestation for the kernel and every capsule. This is where make, # make qemu, and make from-config all resolve their real work. -.PHONY: nonos-mk-check-driver-ahci-keys nonos-mk-check-driver-e1000-keys nonos-mk-check-driver-hda-keys nonos-mk-check-driver-i2c-hid-keys nonos-mk-check-driver-i2c-pci-keys nonos-mk-check-driver-iwlwifi-keys nonos-mk-check-driver-nvme-keys nonos-mk-check-driver-rtl8139-keys nonos-mk-check-driver-rtl8169-keys nonos-mk-check-driver-rtl8821ce-keys nonos-mk-check-driver-usb-msc-keys nonos-mk-check-driver-virtio-gpu-keys nonos-mk-check-ps2-input-keys nonos-mk-check-ramfs-keys nonos-mk-check-virtio-blk-keys nonos-mk-check-virtio-net-keys nonos-mk-check-virtio-rng-keys nonos-mk-check-xhci-keys nonos-mk-crypto nonos-mk-driver-ahci nonos-mk-driver-ahci-sign nonos-mk-driver-e1000 nonos-mk-driver-e1000-sign nonos-mk-driver-hda nonos-mk-driver-hda-sign nonos-mk-driver-i2c-hid nonos-mk-driver-i2c-hid-sign nonos-mk-driver-i2c-pci nonos-mk-driver-i2c-pci-sign nonos-mk-driver-iwlwifi nonos-mk-driver-iwlwifi-sign nonos-mk-driver-nvme nonos-mk-driver-nvme-sign nonos-mk-driver-rtl8139 nonos-mk-driver-rtl8139-sign nonos-mk-driver-rtl8169 nonos-mk-driver-rtl8169-sign nonos-mk-driver-rtl8821ce nonos-mk-driver-rtl8821ce-sign nonos-mk-driver-usb-msc nonos-mk-driver-usb-msc-sign nonos-mk-driver-virtio-gpu nonos-mk-driver-virtio-gpu-sign nonos-mk-entropy nonos-mk-keyring nonos-mk-market nonos-mk-proof-io nonos-mk-proof-io-sign nonos-mk-ps2-input nonos-mk-ps2-input-sign nonos-mk-ramfs nonos-mk-ramfs-sign nonos-mk-vfs nonos-mk-virtio-blk nonos-mk-virtio-blk-sign nonos-mk-virtio-net nonos-mk-virtio-net-sign nonos-mk-virtio-rng nonos-mk-virtio-rng-sign nonos-mk-wallpaper nonos-mk-xhci nonos-mk-xhci-sign nonos-mk-all-capsules-attested nonos-mk-attest nonos-mk-attestation nonos-mk-attestation-receipt nonos-mk-bootloader nonos-mk-capsules nonos-mk-check nonos-mk-check-trust-keys nonos-mk-check-trust-manifest nonos-mk-core nonos-mk-core-attested nonos-mk-desktop-gui-prod nonos-mk-smp-prod nonos-mk-ensure-zk-keys nonos-mk-esp nonos-mk-from-config nonos-mk-host-trust-verify nonos-mk-libc nonos-mk-live-production-proof nonos-mk-marketplace-abi nonos-mk-marketplace-index-tool nonos-mk-menuconfig nonos-mk-sign nonos-mk-terminal-test nonos-mk-trust-policy nonos-mk-usb-img nonos-mk-userland-clean nonos-mk-verify-capsule-attest nonos-mk-verify-trust nonos-mk-zerostate nonos-mk-zk-report nonos-mk-zk-tools nonos-mk-zk-verify-live +.PHONY: nonos-mk-check-driver-ahci-keys nonos-mk-check-driver-e1000-keys nonos-mk-check-driver-hda-keys nonos-mk-check-driver-i2c-hid-keys nonos-mk-check-driver-i2c-pci-keys nonos-mk-check-driver-iwlwifi-keys nonos-mk-check-driver-nvme-keys nonos-mk-check-driver-rtl8139-keys nonos-mk-check-driver-rtl8169-keys nonos-mk-check-driver-rtl8821ce-keys nonos-mk-check-driver-usb-msc-keys nonos-mk-check-driver-virtio-gpu-keys nonos-mk-check-ps2-input-keys nonos-mk-check-ramfs-keys nonos-mk-check-virtio-blk-keys nonos-mk-check-virtio-net-keys nonos-mk-check-virtio-rng-keys nonos-mk-check-xhci-keys nonos-mk-crypto nonos-mk-driver-ahci nonos-mk-driver-ahci-sign nonos-mk-driver-e1000 nonos-mk-driver-e1000-sign nonos-mk-driver-hda nonos-mk-driver-hda-sign nonos-mk-driver-i2c-hid nonos-mk-driver-i2c-hid-sign nonos-mk-driver-i2c-pci nonos-mk-driver-i2c-pci-sign nonos-mk-driver-iwlwifi nonos-mk-driver-iwlwifi-sign nonos-mk-driver-nvme nonos-mk-driver-nvme-sign nonos-mk-driver-rtl8139 nonos-mk-driver-rtl8139-sign nonos-mk-driver-rtl8169 nonos-mk-driver-rtl8169-sign nonos-mk-driver-rtl8821ce nonos-mk-driver-rtl8821ce-sign nonos-mk-driver-usb-msc nonos-mk-driver-usb-msc-sign nonos-mk-driver-virtio-gpu nonos-mk-driver-virtio-gpu-sign nonos-mk-entropy nonos-mk-keyring nonos-mk-market nonos-mk-proof-io nonos-mk-proof-io-sign nonos-mk-ps2-input nonos-mk-ps2-input-sign nonos-mk-ramfs nonos-mk-ramfs-sign nonos-mk-vfs nonos-mk-virtio-blk nonos-mk-virtio-blk-sign nonos-mk-virtio-net nonos-mk-virtio-net-sign nonos-mk-virtio-rng nonos-mk-virtio-rng-sign nonos-mk-wallpaper nonos-mk-xhci nonos-mk-xhci-sign nonos-mk-all-capsules-attested nonos-mk-attest nonos-mk-attestation nonos-mk-attestation-receipt nonos-mk-bootloader nonos-mk-capsules nonos-mk-check nonos-mk-check-trust-keys nonos-mk-check-trust-manifest nonos-mk-core nonos-mk-core-attested nonos-mk-desktop-gui-prod nonos-mk-smp-prod nonos-mk-ensure-zk-keys nonos-mk-esp nonos-mk-from-config nonos-mk-host-trust-verify nonos-mk-libc nonos-mk-live-production-proof nonos-mk-cap-audit nonos-mk-market-catalogue nonos-mk-market-catalogue-sign nonos-mk-marketplace-abi nonos-mk-marketplace-index-tool nonos-mk-menuconfig nonos-mk-sign nonos-mk-terminal-test nonos-mk-trust-policy nonos-mk-usb-img nonos-mk-userland-clean nonos-mk-verify-capsule-attest nonos-mk-verify-trust nonos-mk-zerostate nonos-mk-zk-report nonos-mk-zk-tools nonos-mk-zk-verify-live # ZK attestation: transparent enrolled-secret tools @@ -556,6 +556,8 @@ include userland/capsule_clipboard/Capsule.mk include userland/capsule_login/Capsule.mk include userland/toolkit/Capsule.mk include userland/capsule_about/Capsule.mk +include userland/capsule_app_store/Capsule.mk +include userland/capsule_linux/Capsule.mk include userland/capsule_hello/Capsule.mk include userland/capsule_gui_demo/Capsule.mk include userland/capsule_game_2048/Capsule.mk @@ -731,7 +733,7 @@ NONOS_DESKTOP_GUI_CAPSULE_CHECKS = \ $(driver-usb-hid_VERIFY) \ $(net-core_VERIFY) $(net-sockets_VERIFY) $(net-nym_VERIFY) \ $(policy_VERIFY) $(wallpaper_catalog_VERIFY) \ - $(installer_VERIFY) \ + $(installer_VERIFY) $(linux_VERIFY) \ $(input-router_VERIFY) $(compositor_VERIFY) $(wm_VERIFY) \ $(desktop-shell_VERIFY) $(image-codec_VERIFY) $(image-viewer_VERIFY) $(clipboard_VERIFY) \ $(login_VERIFY) $(wallpaper_VERIFY) $(toolkit_VERIFY) \ @@ -1139,7 +1141,7 @@ DESKTOP_BASE_SLUGS := proof-io ramfs keyring entropy crypto vfs \ driver-virtio-net driver-ps2-input driver-xhci driver-usb-hid \ net-core net-sockets net-nym socks5 policy wallpaper_catalog \ installer input-router compositor wm desktop-shell image-codec \ - clipboard login wallpaper toolkit about boot-splash calculator \ + clipboard login wallpaper toolkit about linux boot-splash calculator \ browser wallet-nonos terminal file-manager text-editor \ settings process-manager attest power \ audio driver-hda audio_player video-player @@ -1164,6 +1166,15 @@ nonos-mk-desktop-gui-prod: $(DESKTOP_GUI_CAPSULE_ARTIFACTS) \ nonos-mk-check-deps nonos-mk-ensure-signing-key $(call nonos_kernel_build,microkernel-desktop-gui + nonos-stark-attest,microkernel-desktop-gui$(_boot_comma)nonos-stark-attest) +# nonos-mk-install-prod: the desktop profile with the NVMe driver capsule in +# it. The desktop cut leaves NVMe out because a driver whose hardware is absent +# blocks on spawn; the install lane presents an NVMe target to QEMU, so the +# driver has a device and the installer has a disk that is not the store. +nonos-mk-install-prod: $(DESKTOP_GUI_CAPSULE_ARTIFACTS) $(driver-nvme_ARTIFACTS) \ + nonos-mk-verify-desktop-gui-capsules \ + nonos-mk-check-deps nonos-mk-ensure-signing-key + $(call nonos_kernel_build,microkernel-desktop-gui + nvme + install,microkernel-desktop-gui$(_boot_comma)nonos-stark-attest$(_boot_comma)nonos-capsule-driver-nvme) + # nonos-mk-smp-prod: the desktop profile with the secondary CPUs turned on. # Same capsule set and the same attestation, so a difference between this boot # and the single-CPU one is the AP bring-up and nothing else. @@ -1236,10 +1247,6 @@ nonos-mk-input-probe-inject-esp: $(NONOS_BOOT_EFI) @cp $(TARGET_DIR)/kernel_attested.bin $(NONOS_INPUT_PROBE_INJECT_ESP)/EFI/nonos/kernel.bin @printf "timeout=0\ndefault=nonos\n" > $(NONOS_INPUT_PROBE_INJECT_ESP)/EFI/nonos/boot.cfg @echo 'fs0:\EFI\Boot\BOOTX64.EFI' > $(NONOS_INPUT_PROBE_INJECT_ESP)/startup.nsh - @# This target packs its own ESP instead of going through nonos-mk-esp, so - @# it needs the same check: the staged kernel is the one just linked. - @$(NONOS_PYTHON) scripts/check_staged_kernel.py --elf $(MICROKERNEL_BIN) \ - --staged $(NONOS_INPUT_PROBE_INJECT_ESP)/EFI/nonos/kernel.bin nonos-mk-terminal-only-prod: $(proof-io_ARTIFACTS) $(ramfs_ARTIFACTS) $(keyring_ARTIFACTS) \ $(entropy_ARTIFACTS) $(crypto_ARTIFACTS) $(vfs_ARTIFACTS) \ @@ -1370,13 +1377,6 @@ endif @cp $(TARGET_DIR)/kernel_attested.bin $(ESP_DIR)/EFI/nonos/kernel.bin @printf "timeout=0\ndefault=nonos\n" > $(ESP_DIR)/EFI/nonos/boot.cfg @echo 'fs0:\EFI\Boot\BOOTX64.EFI' > $(ESP_DIR)/startup.nsh - @# The ELF just linked is a byte prefix of what was staged, or the pack - @# chain raced the link and this ESP boots an older kernel. Checked here - @# rather than in each boot target, so nothing that consumes an ESP can - @# skip it and no boot verdict can describe a kernel that is not in the - @# tree. - @$(NONOS_PYTHON) scripts/check_staged_kernel.py \ - --elf $(MICROKERNEL_BIN) --staged $(ESP_DIR)/EFI/nonos/kernel.bin @echo "ESP ready at $(ESP_DIR)" # Produce a real, flashable GPT disk image with a FAT32 EFI System Partition. @@ -1396,3 +1396,50 @@ nonos-mk-usb-img: nonos-mk-esp @echo " Validate as a real disk: make nonos-mk-usb-run" @echo " Flash (macOS): sudo dd if=$(USB_IMG) of=/dev/rdiskN bs=4m && sync" @echo " Flash (Linux): sudo dd if=$(USB_IMG) of=/dev/sdX bs=4M oflag=direct && sync" + +# The marketplace catalogue. The generator reads what is actually on +# disk (signed capsules, fetched packages, community submissions) and +# writes plain JSON; the CLI encodes it to the canonical binary the +# market capsule ingests. Signing is a separate step with the operator +# seed, which never appears in a build rule. +MARKET_CATALOGUE_JSON := nonos-data/marketplace/index.json +MARKET_CATALOGUE_BIN := nonos-data/marketplace/index.bin +MARKET_OPERATOR_PUBKEY ?= a7c92db24d99e7baee8b45a06dc353ccd4142622c1a90a52b5327db2e6d17811 +MARKET_SERIAL ?= 1 +# The operator seed. Gitignored, 0600, generated by `marketplace-index +# keygen`. Overridable so a release build can sign from elsewhere. +MARKET_OPERATOR_SEED ?= .keys/marketplace_operator_ed25519.seed + +# The catalogue records the hash of each capsule's trailer, and +# enrolment rewrites every trailer, so generating it first would record +# hashes of files that no longer exist. Ordering it after the policy +# root is the difference between a catalogue and a list of stale +# digests that still looks well formed. +# A capability that cannot reach a token is not a capability, and a +# syscall with no gate is not gated. Both failures compile, boot and stay +# silent, so they are checked here rather than trusted. +nonos-mk-cap-audit: + @python3 tools/nonos-cap-audit --root . + +nonos-mk-market-catalogue: $(ZK_CAPSULE_ROOT) + @python3 tools/nonos-market-catalogue \ + --out $(MARKET_CATALOGUE_JSON) \ + --operator-pubkey $(MARKET_OPERATOR_PUBKEY) \ + --serial $(MARKET_SERIAL) \ + --linux-list nonos-data/marketplace/linux.list + +# Requires MARKET_OPERATOR_SEED to name a file holding the 32-byte +# operator seed. Without it the catalogue encodes but stays unsigned, +# and the capsule refuses an unsigned index, which is the point. +# Not `nonos-mk-market-sign`: capsule.mk emits that name for the market +# capsule itself, and two recipes under one target is a coin toss. +nonos-mk-market-catalogue-sign: $(MARKETPLACE_INDEX_TOOL) $(MARKET_CATALOGUE_JSON) + @test -n "$(MARKET_OPERATOR_SEED)" || \ + { echo "::error::set MARKET_OPERATOR_SEED="; exit 1; } + @$(MARKETPLACE_INDEX_TOOL) sign \ + --in $(MARKET_CATALOGUE_JSON) \ + --key-file $(MARKET_OPERATOR_SEED) \ + --pubkey $(MARKET_OPERATOR_PUBKEY) \ + --out $(MARKET_CATALOGUE_BIN) + @$(MARKETPLACE_INDEX_TOOL) verify \ + --in $(MARKET_CATALOGUE_BIN) --pubkey $(MARKET_OPERATOR_PUBKEY) diff --git a/src/kernel_core/surface_registry/share/attach_frames.rs b/src/kernel_core/surface_registry/share/attach_frames.rs new file mode 100644 index 0000000000..aa9d7a640a --- /dev/null +++ b/src/kernel_core/surface_registry/share/attach_frames.rs @@ -0,0 +1,60 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! Giving a receiver its own view of a surface's frames. + +use crate::kernel_core::surface_registry::table::SLOTS; +use crate::kernel_core::surface_registry::types::{ + decode_handle, RegistryError, SurfaceDescriptor, SurfaceHandle, +}; +use crate::memory::paging::manager::api::{lookup_asid_for_process, map_page_in_asid}; +use crate::memory::paging::types::PagePermissions; +use crate::process::current_process; + +pub(super) fn attach_frames( + receiver_pid: u32, + handle: SurfaceHandle, + out_desc: &mut SurfaceDescriptor, +) -> Result { + let (idx, epoch) = decode_handle(handle); + let frames = { + let mut slots = SLOTS.lock(); + let slot = + slots.get_mut(idx as usize).and_then(|s| s.as_mut()).ok_or(RegistryError::BadHandle)?; + if slot.epoch != epoch { + #[cfg(feature = "dbg-ring")] + crate::log::dbg_ring::dbg_emit_2u64(0x5546_0001, handle, slot.epoch as u64); + return Err(RegistryError::BadHandle); + } + slot.refcount = slot.refcount.checked_add(1).ok_or(RegistryError::InvalidArg)?; + slot.frames.clone() + }; + let mut desc = super::descriptor::descriptor(handle)?; + let asid = lookup_asid_for_process(receiver_pid).ok_or(RegistryError::MapFailed)?; + let proc = current_process().ok_or(RegistryError::NoProc)?; + let base = proc + .reserve_vma(frames.len().saturating_mul(4096)) + .map_err(|_| RegistryError::MapFailed)?; + let perms = PagePermissions::user_rw(); + for (i, frame) in frames.iter().enumerate() { + let va = crate::memory::addr::VirtAddr::new(base.as_u64() + (i as u64) * 4096); + map_page_in_asid(asid, va, *frame, perms).map_err(|_| RegistryError::MapFailed)?; + } + desc.base_va = base.as_u64(); + *out_desc = desc; + super::super::attach_map::record(receiver_pid, handle, base.as_u64(), out_desc.byte_len); + Ok(base.as_u64()) +} diff --git a/src/kernel_core/surface_registry/share/attach_surface.rs b/src/kernel_core/surface_registry/share/attach_surface.rs index 960492861a..1025b3c2e7 100644 --- a/src/kernel_core/surface_registry/share/attach_surface.rs +++ b/src/kernel_core/surface_registry/share/attach_surface.rs @@ -14,76 +14,32 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -use crate::kernel_core::surface_registry::table::SLOTS; +//! Handing a surface to another process. + use crate::kernel_core::surface_registry::types::{ - decode_handle, RegistryError, SurfaceDescriptor, SurfaceHandle, + RegistryError, SurfaceDescriptor, SurfaceHandle, }; -use crate::memory::paging::manager::api::{lookup_asid_for_process, map_page_in_asid}; -use crate::memory::paging::types::PagePermissions; -use crate::process::current_process; + +use super::attach_frames::attach_frames; +use super::self_attach::self_attach; pub fn attach_surface( receiver_pid: u32, handle: SurfaceHandle, out_desc: &mut SurfaceDescriptor, ) -> Result { + // Never to a guest. + if crate::process::foreign::is_foreign(receiver_pid) { + return Err(RegistryError::InvalidArg); + } if let Some((base_va, byte_len)) = super::super::attach_map::lookup(receiver_pid, handle) { *out_desc = super::descriptor::descriptor(handle)?; out_desc.base_va = base_va; out_desc.byte_len = byte_len; return Ok(base_va); } - let (idx, epoch) = decode_handle(handle); - // A self-attach (the owner attaching its own surface) needs no new - // mapping: the surface already lives at the VA the owner registered - // it at. Returning that VA keeps the owner's existing VMA, which the - // present path resolves against. Remapping would create a second VA - // with no backing VMA and break MkSurfacePresent. - { - let slots = SLOTS.lock(); - let slot = - slots.get(idx as usize).and_then(|s| s.as_ref()).ok_or(RegistryError::BadHandle)?; - if slot.epoch != epoch { - #[cfg(feature = "dbg-ring")] - crate::log::dbg_ring::dbg_emit_2u64(0x5546_0001, handle, slot.epoch as u64); - return Err(RegistryError::BadHandle); - } - if slot.owner_pid == receiver_pid && slot.owner_base_va != 0 { - let base_va = slot.owner_base_va; - let byte_len = slot.byte_len; - drop(slots); - *out_desc = super::descriptor::descriptor(handle)?; - out_desc.base_va = base_va; - out_desc.byte_len = byte_len; - super::super::attach_map::record(receiver_pid, handle, base_va, byte_len); - return Ok(base_va); - } - } - let frames = { - let mut slots = SLOTS.lock(); - let slot = - slots.get_mut(idx as usize).and_then(|s| s.as_mut()).ok_or(RegistryError::BadHandle)?; - if slot.epoch != epoch { - #[cfg(feature = "dbg-ring")] - crate::log::dbg_ring::dbg_emit_2u64(0x5546_0001, handle, slot.epoch as u64); - return Err(RegistryError::BadHandle); - } - slot.refcount = slot.refcount.checked_add(1).ok_or(RegistryError::InvalidArg)?; - slot.frames.clone() - }; - let mut desc = super::descriptor::descriptor(handle)?; - let asid = lookup_asid_for_process(receiver_pid).ok_or(RegistryError::MapFailed)?; - let proc = current_process().ok_or(RegistryError::NoProc)?; - let base = proc - .reserve_vma(frames.len().saturating_mul(4096)) - .map_err(|_| RegistryError::MapFailed)?; - let perms = PagePermissions::user_rw(); - for (i, frame) in frames.iter().enumerate() { - let va = crate::memory::addr::VirtAddr::new(base.as_u64() + (i as u64) * 4096); - map_page_in_asid(asid, va, *frame, perms).map_err(|_| RegistryError::MapFailed)?; + if let Some(base_va) = self_attach(receiver_pid, handle, out_desc)? { + return Ok(base_va); } - desc.base_va = base.as_u64(); - *out_desc = desc; - super::super::attach_map::record(receiver_pid, handle, base.as_u64(), out_desc.byte_len); - Ok(base.as_u64()) + attach_frames(receiver_pid, handle, out_desc) } diff --git a/src/kernel_core/surface_registry/share/mod.rs b/src/kernel_core/surface_registry/share/mod.rs index 72cb5930f3..8406c830f1 100644 --- a/src/kernel_core/surface_registry/share/mod.rs +++ b/src/kernel_core/surface_registry/share/mod.rs @@ -14,8 +14,10 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . +mod attach_frames; mod attach_surface; mod descriptor; +mod self_attach; mod share_surface; pub use attach_surface::attach_surface; diff --git a/src/kernel_core/surface_registry/share/self_attach.rs b/src/kernel_core/surface_registry/share/self_attach.rs new file mode 100644 index 0000000000..b359b0e893 --- /dev/null +++ b/src/kernel_core/surface_registry/share/self_attach.rs @@ -0,0 +1,49 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! The owner attaching its own surface. + +use crate::kernel_core::surface_registry::table::SLOTS; +use crate::kernel_core::surface_registry::types::{ + decode_handle, RegistryError, SurfaceDescriptor, SurfaceHandle, +}; + +/// The owner's own va when the owner is the receiver, or `None` when the +/// receiver is somebody else and a real mapping has to be made. +pub(super) fn self_attach( + receiver_pid: u32, + handle: SurfaceHandle, + out_desc: &mut SurfaceDescriptor, +) -> Result, RegistryError> { + let (idx, epoch) = decode_handle(handle); + let slots = SLOTS.lock(); + let slot = slots.get(idx as usize).and_then(|s| s.as_ref()).ok_or(RegistryError::BadHandle)?; + if slot.epoch != epoch { + #[cfg(feature = "dbg-ring")] + crate::log::dbg_ring::dbg_emit_2u64(0x5546_0001, handle, slot.epoch as u64); + return Err(RegistryError::BadHandle); + } + if slot.owner_pid != receiver_pid || slot.owner_base_va == 0 { + return Ok(None); + } + let (base_va, byte_len) = (slot.owner_base_va, slot.byte_len); + drop(slots); + *out_desc = super::descriptor::descriptor(handle)?; + out_desc.base_va = base_va; + out_desc.byte_len = byte_len; + super::super::attach_map::record(receiver_pid, handle, base_va, byte_len); + Ok(Some(base_va)) +} diff --git a/src/userspace/capsule_app_store/embed.rs b/src/userspace/capsule_app_store/embed.rs new file mode 100644 index 0000000000..0e1852db46 --- /dev/null +++ b/src/userspace/capsule_app_store/embed.rs @@ -0,0 +1,49 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +// Build-time embed of the marketplace window. + +#[cfg(feature = "nonos-capsule-app-store")] +pub(crate) const APP_STORE_ELF: &[u8] = + include_bytes!(concat!( + "../../../userland/capsule_app_store/target/", + env!("NONOS_USER_TARGET"), + "/release/app_store" +)); + +#[cfg(feature = "nonos-capsule-app-store")] +pub(crate) const APP_STORE_NONOS_ID_CERT_BYTES: &[u8] = + include_bytes!("../../../nonos-data/trust/capsules/app_store.nonos_id_cert.bin"); + +#[cfg(feature = "nonos-capsule-app-store")] +pub(crate) const APP_STORE_MANIFEST_BYTES: &[u8] = + include_bytes!("../../../nonos-data/trust/capsules/app_store.manifest.bin"); + +#[cfg(feature = "nonos-capsule-app-store")] +pub(crate) const APP_STORE_ATTESTATION_BYTES: &[u8] = + include_bytes!("../../../nonos-data/trust/capsules/app_store.zk_trailer.bin"); + +#[cfg(not(feature = "nonos-capsule-app-store"))] +pub(crate) const APP_STORE_ELF: &[u8] = &[]; + +#[cfg(not(feature = "nonos-capsule-app-store"))] +pub(crate) const APP_STORE_NONOS_ID_CERT_BYTES: &[u8] = &[]; + +#[cfg(not(feature = "nonos-capsule-app-store"))] +pub(crate) const APP_STORE_MANIFEST_BYTES: &[u8] = &[]; + +#[cfg(not(feature = "nonos-capsule-app-store"))] +pub(crate) const APP_STORE_ATTESTATION_BYTES: &[u8] = &[]; diff --git a/src/userspace/capsule_app_store/mod.rs b/src/userspace/capsule_app_store/mod.rs new file mode 100644 index 0000000000..fe406dcc5c --- /dev/null +++ b/src/userspace/capsule_app_store/mod.rs @@ -0,0 +1,24 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! The marketplace window, as the kernel spawns it. + +mod embed; +mod spawn; +mod state; + +pub use spawn::spawn_app_store_capsule; +pub use state::shared_state; diff --git a/src/userspace/capsule_app_store/spawn.rs b/src/userspace/capsule_app_store/spawn.rs new file mode 100644 index 0000000000..b50e719c9e --- /dev/null +++ b/src/userspace/capsule_app_store/spawn.rs @@ -0,0 +1,62 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +use super::embed::{ + APP_STORE_ATTESTATION_BYTES, APP_STORE_ELF, APP_STORE_MANIFEST_BYTES, + APP_STORE_NONOS_ID_CERT_BYTES, +}; +use super::state; +use crate::capabilities::Capability; +use crate::kernel_core::process_spawn::capsule_spawn::{ + self, CapsuleSpecVerified, SpawnError, +}; +use crate::security::nonos_id_cert::IdCertVerifyError; +use crate::security::nonos_trust_anchor::{ + decode as decode_trust_anchor, BAKED_TRUST_ANCHOR_POLICY, +}; + +const SERVICE_NAME: &str = "app.store"; +const SERVICE_PORT: u32 = 4940; +const REPLY_INBOX: &str = "endpoint.app.store.reply"; +const REPLY_PORT: u32 = 4941; +const TARGET_TRIPLE: &str = env!("NONOS_USER_TARGET"); + +pub fn spawn_app_store_capsule() -> Result<(), SpawnError> { + let trust_anchor = decode_trust_anchor(BAKED_TRUST_ANCHOR_POLICY) + .map_err(|_| SpawnError::NonosIdCertRejected(IdCertVerifyError::TrustAnchorPolicy))?; + let spec = CapsuleSpecVerified { + name: SERVICE_NAME, + service_port: SERVICE_PORT, + reply_inbox: REPLY_INBOX, + reply_port: REPLY_PORT, + elf: APP_STORE_ELF, + nonos_id_cert_bytes: APP_STORE_NONOS_ID_CERT_BYTES, + manifest_bytes: APP_STORE_MANIFEST_BYTES, + attestation_trailer: APP_STORE_ATTESTATION_BYTES, + target_triple: TARGET_TRIPLE, + // It reads one service, paints, and may ask for an install. + requested_caps: Capability::CoreExec.bit() + | Capability::IPC.bit() + | Capability::Memory.bit() + | Capability::GraphicsDisplayQuery.bit() + | Capability::GraphicsSurfaceCreate.bit() + | Capability::AppInstall.bit(), + debug_tag: b"", + }; + let pid = capsule_spawn::spawn_verified(&spec, &trust_anchor, None)?; + state::set_alive(pid); + Ok(()) +} diff --git a/src/userspace/capsule_app_store/state.rs b/src/userspace/capsule_app_store/state.rs new file mode 100644 index 0000000000..f18bb639bc --- /dev/null +++ b/src/userspace/capsule_app_store/state.rs @@ -0,0 +1,27 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +use crate::services::lifecycle::CapsuleState; + +static STATE: CapsuleState = CapsuleState::new(); + +pub(super) fn set_alive(pid: u32) { + STATE.set_alive(pid); +} + +pub fn shared_state() -> &'static CapsuleState { + &STATE +} diff --git a/src/userspace/init/install_queue.rs b/src/userspace/init/install_queue.rs new file mode 100644 index 0000000000..86b7258af4 --- /dev/null +++ b/src/userspace/init/install_queue.rs @@ -0,0 +1,61 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! Package installs asked for by a capsule, performed by init. + +extern crate alloc; + +use alloc::string::String; +use alloc::vec::Vec; + +use spin::Mutex; + +/// Deep enough for a person clicking faster than a download completes, +/// shallow enough that a caller in a loop cannot grow it without bound. +const DEPTH: usize = 8; + +static PENDING: Mutex> = Mutex::new(Vec::new()); + +/// Record a request. False when the queue is full, which the caller +/// reports as busy rather than silently dropping. +pub(crate) fn request(package: String) -> bool { + let mut q = PENDING.lock(); + if q.len() >= DEPTH || q.contains(&package) { + return false; + } + q.push(package); + super::wake::nudge(); + true +} + +/// Perform every queued install. +pub(crate) fn service() { + let taken: Vec = core::mem::take(&mut *PENDING.lock()); + for package in taken { + match crate::userspace::capsule_linux::spawn_install(&package) { + Ok(pid) => { + crate::sys::serial::print(b"[LINUX-INSTALL] started pid="); + crate::sys::serial::print_hex(pid as u64); + crate::sys::serial::print(b" "); + crate::sys::serial::println(package.as_bytes()); + } + Err(_) => { + crate::sys::serial::print(b"[LINUX-INSTALL] refused "); + crate::sys::serial::println(package.as_bytes()); + } + } + } +} diff --git a/src/userspace/init/instance_spawn/queue.rs b/src/userspace/init/instance_spawn/queue.rs index a703e78323..1b0ddeac0c 100644 --- a/src/userspace/init/instance_spawn/queue.rs +++ b/src/userspace/init/instance_spawn/queue.rs @@ -40,9 +40,7 @@ pub enum PendingApp { impl PendingApp { /// The capsule name behind this request, for the one place it matters: a - /// spawn that was refused. The drain used to report the error alone, so a - /// dock icon that had quietly stopped opening looked identical on the wire - /// to one that had never been clicked. + /// spawn that was refused. pub(super) fn name(self) -> &'static [u8] { match self { PendingApp::Terminal => b"app.terminal", @@ -71,6 +69,10 @@ pub(super) static PENDING: Mutex> = Mutex::new(Vec::new()); /// Record a spawn request. Returns false only when the queue is saturated, /// which the caller treats as "try again", never as a hard failure. pub(super) fn push(app: PendingApp) -> bool { + // Raising init is part of queueing, not a separate courtesy: work left in + // a queue nobody is scheduled to drain is work that never happens, and the + // caller was told it was accepted. + super::super::wake::nudge(); let mut q = PENDING.lock(); if q.len() >= MAX_PENDING { return false; @@ -80,10 +82,7 @@ pub(super) fn push(app: PendingApp) -> bool { true } -/// Return init to its idle band once nothing is left to spawn. The check and -/// the demotion happen under the queue lock, the same lock `push` raises -/// under, so a click landing here can never be left queued behind a -/// demotion it raced. +/// Return init to its idle band once nothing is left to spawn. pub(super) fn settle() { let Some(q) = PENDING.try_lock() else { return; @@ -105,10 +104,7 @@ pub(super) fn take() -> Vec { core::mem::take(&mut *q) } -/// Whether any window-instance request is waiting to be drained. The init loop -/// reads this to raise its priority only while there is deferred window work. A -/// contended lock means a push is in flight, which is itself pending work, so it -/// counts as pending rather than risking a missed boost. +/// Whether any window-instance request is waiting to be drained. pub(crate) fn has_pending() -> bool { match PENDING.try_lock() { Some(q) => !q.is_empty(), diff --git a/src/userspace/init/mod.rs b/src/userspace/init/mod.rs index f647fc8f7a..855f47218c 100644 --- a/src/userspace/init/mod.rs +++ b/src/userspace/init/mod.rs @@ -16,11 +16,17 @@ mod capsule_boot; mod entry; +mod install_queue; +mod wake; + +pub(crate) use wake::{nudge as nudge_init, owns_the_queues, settle as settle_priority}; mod instance_spawn; mod spawn_plan; mod supervisor; pub use entry::run_init; pub(crate) use instance_spawn::has_pending as instance_spawns_pending; +pub(crate) use install_queue::request as request_install; +pub(crate) use install_queue::service as service_installs; pub(crate) use instance_spawn::service as service_instance_spawns; pub use instance_spawn::{request as request_instance, PendingApp}; diff --git a/src/userspace/init/spawn_plan/apps.rs b/src/userspace/init/spawn_plan/apps.rs index beaded5dbd..c3ee11bae1 100644 --- a/src/userspace/init/spawn_plan/apps.rs +++ b/src/userspace/init/spawn_plan/apps.rs @@ -17,6 +17,7 @@ pub(super) fn spawn() { spawn_input_proof(); spawn_about(); + spawn_app_store(); spawn_hello(); spawn_calculator(); spawn_clock(); @@ -26,6 +27,7 @@ pub(super) fn spawn() { spawn_terminal(); spawn_file_manager(); spawn_audio_player(); + spawn_linux(); super::apps_tools::spawn(); } @@ -50,6 +52,14 @@ fn spawn_about() { #[cfg(not(feature = "nonos-capsule-about"))] fn spawn_about() {} +#[cfg(feature = "nonos-capsule-app-store")] +fn spawn_app_store() { + use crate::userspace::capsule_app_store as c; + super::boot::capsule("APP-STORE", "app_store", c::spawn_app_store_capsule, c::shared_state); +} +#[cfg(not(feature = "nonos-capsule-app-store"))] +fn spawn_app_store() {} + #[cfg(feature = "nonos-capsule-hello")] fn spawn_hello() { use crate::userspace::capsule_hello as c; @@ -146,3 +156,13 @@ fn spawn_snake() { } #[cfg(not(feature = "nonos-capsule-snake"))] fn spawn_snake() {} + +// The Linux personality. +#[cfg(feature = "nonos-capsule-linux")] +fn spawn_linux() { + use crate::userspace::capsule_linux as c; + super::boot::capsule("APP-LINUX", "app_linux", c::spawn_linux_capsule, c::shared_state); +} + +#[cfg(not(feature = "nonos-capsule-linux"))] +fn spawn_linux() {} diff --git a/src/userspace/init/supervisor/loop_impl.rs b/src/userspace/init/supervisor/loop_impl.rs index b533ee9bfa..6417d4b3e1 100644 --- a/src/userspace/init/supervisor/loop_impl.rs +++ b/src/userspace/init/supervisor/loop_impl.rs @@ -14,11 +14,7 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -//! Init's residual loop after every capsule has been spawned. Walks -//! the lifecycle registry once per second; any capsule that exited is -//! observed `Dead` on its next IPC. The kernel does not actively -//! probe capsules — liveness arrives through the existing process -//! state machine. +//! Init's residual loop after every capsule has been spawned. use crate::process::core::Priority; @@ -26,10 +22,13 @@ const TICK_INTERVAL_MS: u64 = 1000; const PARK_SLICE_MS: u64 = 20; pub(crate) fn init_loop() -> ! { + // Tell the queue side which process drains it. + if let Some(pid) = crate::process::current_pid() { + crate::userspace::init::owns_the_queues(pid); + } let mut last_tick = 0u64; #[cfg(feature = "microkernel-setup-wizard")] let mut desktop_started = false; - let mut boosted = false; loop { let now = crate::time::timestamp_millis(); if now >= last_tick + TICK_INTERVAL_MS { @@ -41,34 +40,21 @@ pub(crate) fn init_loop() -> ! { super::super::spawn_plan::spawn_post_wizard(); desktop_started = true; } - // Init runs at Priority::Low so an idle system spends its cycles on the - // apps, but the window-instance drain below (and the focus-frame - // delivery inside it) must not be starved: a busy-yielding app with a - // network fetch in flight would otherwise keep a low-priority init off - // the single CPU, so a dock click never opened its second window. Raise - // to Normal while there is queued window work and drop back to Low when - // idle, so the drain runs promptly without making an idle init costly. - let want = crate::userspace::init::instance_spawns_pending(); - if want != boosted { - set_init_priority(if want { Priority::Normal } else { Priority::Low }); - boosted = want; - } - // Perform any window-instance spawns the shell requested. Running - // them here, in init's context, keeps the heavy spawn out of the - // calling capsule's syscall, which is what stopped the caller from - // resuming (it faulted on its own code under the wrong page tables). + // Perform any window-instance spawns the shell requested. crate::userspace::init::service_instance_spawns(); + crate::userspace::init::service_installs(); + // Back to Low now the queues are empty. Raising is the + // producer's job; only this loop can know when to stop. + if !crate::userspace::init::instance_spawns_pending() { + crate::userspace::init::settle_priority(); + } park(); } } -// A bare yield left init permanently runnable, so `select_next_process` -// never came up empty and the scheduler's `sti; hlt` idle path was -// unreachable: the vCPU spun at full load with an idle desktop. Sleeping -// on a short deadline takes init off the run queue between passes, which -// lets the CPU actually halt, while still draining the shell's window -// spawn requests inside one compositor frame. Falling back to the yield -// keeps the loop live if init runs before its pid is current. +// A bare yield left init permanently runnable, so `select_next_process` never +// came up empty and the scheduler's `sti; hlt` idle path was unreachable: the +// vCPU spun at full load with an idle desktop. fn park() { let Some(pid) = crate::process::current_pid() else { crate::sched::yield_now(); @@ -79,9 +65,7 @@ fn park() { crate::sched::yield_now(); } -// Set init's own scheduling priority. Mirrors `lower_init_priority` in entry.rs; -// used to lift the drain out of starvation while there is a window to open, then -// return to Low when the queue is empty. +// Set init's own scheduling priority. fn set_init_priority(p: Priority) { use crate::process::core::{CURRENT_PID, PROCESS_TABLE}; use core::sync::atomic::Ordering; diff --git a/src/userspace/init/wake.rs b/src/userspace/init/wake.rs new file mode 100644 index 0000000000..7d1b0bac16 --- /dev/null +++ b/src/userspace/init/wake.rs @@ -0,0 +1,56 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! Raising init when work is queued for it. + +use core::sync::atomic::{AtomicU32, Ordering}; + +use alloc::sync::Arc; + +use crate::process::core::{Priority, ProcessControlBlock, PROCESS_TABLE}; + +/// Recorded by init itself rather than assumed. +static INIT_PID: AtomicU32 = AtomicU32::new(0); + +/// Called once, by init, before it starts draining. +pub(crate) fn owns_the_queues(pid: u32) { + INIT_PID.store(pid, Ordering::Release); +} + +fn init_pcb() -> Option> { + match INIT_PID.load(Ordering::Acquire) { + 0 => None, + pid => PROCESS_TABLE.find_by_pid(pid), + } +} + +/// Promote init so the work just queued is drained promptly, and wake it +/// in case it is asleep between passes. +pub(crate) fn nudge() { + let pid = INIT_PID.load(Ordering::Acquire); + if let Some(pcb) = init_pcb() { + *pcb.priority.lock() = Priority::Normal; + crate::sched::wake_process(pid); + } +} + +/// Drop back once the queues are empty. Called by init, the only place +/// that knows there is nothing left to do. +pub(crate) fn settle() { + if let Some(pcb) = init_pcb() { + *pcb.priority.lock() = Priority::Low; + } +} diff --git a/src/userspace/mod.rs b/src/userspace/mod.rs index 0b0e96e1a6..0d8f104bee 100644 --- a/src/userspace/mod.rs +++ b/src/userspace/mod.rs @@ -14,19 +14,12 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -// Microkernel runtime: init bootstrap and the kernel-side mirrors -// for every userland capsule the boot path spawns. Real capsule -// binaries live under `userland//`; the mirror here only -// carries the signed embed bytes (ELF + manifest + cert), the -// spawn entry, and liveness state. No protocol logic lives in -// the kernel — that runs inside the spawned capsule. -// -// Kernel-resident `*_engine` wrappers live under `src/services/` -// and are not real userspace. The CI grep gate in -// `nonos-ci/run-static-checks.sh` rejects any new -// `src/userspace/*_service` directory. +// Microkernel runtime: init bootstrap and the kernel-side mirrors for every +// userland capsule the boot path spawns. pub mod capsule_about; +pub mod capsule_app_store; +pub mod capsule_linux; pub mod capsule_attest; pub mod capsule_audio_player; pub mod capsule_boot_splash; diff --git a/tools/nonos-icon-store b/tools/nonos-icon-store new file mode 100755 index 0000000000..ba7d7c01fc --- /dev/null +++ b/tools/nonos-icon-store @@ -0,0 +1,108 @@ +#!/usr/bin/env python3 +# NONOS Operating System +# Copyright (C) 2026 NONOS Contributors +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +"""Draw the marketplace icon as an 8-bit coverage mask. + +Every other icon in the set was rasterised from an SVG by a tool that is +not in this tree, so there is nothing here to run the store glyph through. +Rather than hand-place bytes once and leave nobody able to change it, the +shape is written as the same primitives the SVGs use: strokes of constant +width on a 20-unit grid, sampled to 192 square. + +The stroke width and the grid match `about.svg` exactly, which is what +keeps the mark looking like it belongs beside the others rather than +merely being the right size. +""" + +import argparse +import math +from pathlib import Path + +SIZE = 192 +GRID = 20.0 +STROKE = 1.5 +# Four samples per axis. The SVG rasteriser antialiases; a hard-edged mask +# next to fifteen smooth ones reads as a rendering bug rather than a style. +SUPERSAMPLE = 4 + + +def rounded_rect_edge(px, py, x0, y0, x1, y1, r): + """Distance from a point to the outline of a rounded rectangle.""" + cx, cy = (x0 + x1) / 2, (y0 + y1) / 2 + hx, hy = (x1 - x0) / 2 - r, (y1 - y0) / 2 - r + dx, dy = abs(px - cx) - hx, abs(py - cy) - hy + outside = math.hypot(max(dx, 0.0), max(dy, 0.0)) + inside = min(max(dx, dy), 0.0) + return abs(outside + inside - r) + + +def arc_edge(px, py, cx, cy, r, lo, hi): + """Distance to an arc of a circle, between two angles in radians.""" + ang = math.atan2(py - cy, px - cx) + if not (lo <= ang <= hi): + # Outside the sweep: the nearest point is an endpoint. + ends = [(cx + r * math.cos(a), cy + r * math.sin(a)) for a in (lo, hi)] + return min(math.hypot(px - ex, py - ey) for ex, ey in ends) + return abs(math.hypot(px - cx, py - cy) - r) + + +def covered(px, py): + """True where the bag outline covers this point on the 20-unit grid.""" + half = STROKE / 2 + body = rounded_rect_edge(px, py, 3.6, 7.2, 16.4, 17.2, 1.6) <= half + # The handle sits above the body. Screen y grows downward, so points + # above the centre carry negative angles and the upward sweep is + # -pi..0; asking for pi..2pi draws nothing at all, silently. + handle = arc_edge(px, py, 10.0, 7.2, 3.1, -math.pi, 0.0) <= half + return body or handle + + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("--out", type=Path, required=True, help="the .a8 mask") + ap.add_argument("--svg", type=Path, help="also write the source shape") + args = ap.parse_args() + + step = GRID / SIZE + sub = 1.0 / SUPERSAMPLE + out = bytearray(SIZE * SIZE) + for y in range(SIZE): + for x in range(SIZE): + hits = 0 + for sy in range(SUPERSAMPLE): + for sx in range(SUPERSAMPLE): + px = (x + (sx + 0.5) * sub) * step + py = (y + (sy + 0.5) * sub) * step + hits += covered(px, py) + out[y * SIZE + x] = (hits * 255) // (SUPERSAMPLE * SUPERSAMPLE) + args.out.write_bytes(bytes(out)) + print(f"{args.out}: {len(out)} bytes, {SIZE}x{SIZE}") + + if args.svg: + args.svg.write_text( + '' + '' + '\n' + ) + print(f"{args.svg}: source shape") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/userland/assets/icons/store.a8 b/userland/assets/icons/store.a8 new file mode 100644 index 0000000000000000000000000000000000000000..e086216b2c331c0e487616ce1ed05662085076cc GIT binary patch literal 36864 zcmeI5Z`Gqb5XEs22}r1fNI(J-kbndvL;@0!fP_du0^9DsPtW8JeVFMb;XP;gvS~WG z_cxcM<-=~bY4p+rGyzRO6VL=S0Zl*?&;&FAO+XXS1T+CnKoigeGyzRO6VL=S0Zrgs z0>%#G^4~QM)|^{)k1EqSzeksM-T$PA&PC|&m#(`UC;a0egL>2bA#C=Iz8-1T*<~p|OuDR0 ztw}cizTc?<0sfsD5aHjafqoZW_c+A%kbYgJah#U*u)x+juf*5n9~t~a;OykHJ#07l zBFld3Is*T%SL<>e%p{09!!Cohv^kb`$&_}v%7SyoCs5lVQm&Y?AaUE(Hc%9#BdvWLy0GtR8)yqT zPR8Ff(G95e3G@i@*N>S8Z?56WNATulFWSK^q45>37B`%}k&IT&5QTOH`;X>27zWyj zQZq(-{m1F5KerLtLFU2tD)6`N@niUjyn_D_+Jg=H^u4MF(+DbZaj^eD}0nh&3ip^TxaK&yk^PSN@OQx&s&q0H$*L}N9wi!06YdnO8Y04gCZ!N-hzNmV3?bGK5e76Vg;+yG2oz%ov4)5+ph+pj8X`iV z7(<9PM1%oNN+H$|5dy^+LaZSo3}{jcv4)5cD8>+C4H02LlTwH^M1(*wh7fCr2m_jw zLaZSo1d1_)SVKe@(4-V%4G|$wj3LAtBEov0YtRW%{Xi^HXc00W){`~<*I%5n+iob7+ zm0y=`f%PcU3;A~|Q<1E)0sb!JkNy>w@GeUDUC1x|muGFLQtk%W$hehc@Lc0Vo$Vpi zVJ>@wyMOo0_3v^Q^6zDSyLpK4<-gP#9e`&yez5QEE*aRtN1^SzaQi>`QmsV%`U&y& zX`23ey5Al8FK)E|UHC=Pfh+NS6F_G9M2n>rK0qB!77=;0G;R z^qXJ?)4y((>1SW!F8KMv|0L$pAOHC)&f3>ntO;lWnt&#t31|YEfF_^`XabsmCZGwN GOW+S$=4$!? literal 0 HcmV?d00001 diff --git a/userland/assets/icons/store.svg b/userland/assets/icons/store.svg new file mode 100644 index 0000000000..9db2b1c838 --- /dev/null +++ b/userland/assets/icons/store.svg @@ -0,0 +1 @@ + diff --git a/userland/capsule_app_store/Capsule.mk b/userland/capsule_app_store/Capsule.mk new file mode 100644 index 0000000000..ab24e58a19 --- /dev/null +++ b/userland/capsule_app_store/Capsule.mk @@ -0,0 +1,26 @@ +# app_store: the marketplace window. +# +# A GUI capsule that talks to one service. IPC reaches market.index, +# Memory backs the heap, and the two graphics capabilities register and +# present its surface. It asks for nothing else: it installs nothing +# itself, so it needs neither ForeignExec nor any store authority, and a +# window that can only read the catalogue cannot be turned into one that +# rewrites it. +# +# It may also ask for an install, which is not the right to perform +# one: AppInstall names a package and the personality does the work +# under its own manifest. The window never gains ForeignExec. +# CoreExec|IPC|Memory|GraphicsDisplayQuery|GraphicsSurfaceCreate|AppInstall +CAPSULE_SLUG := app_store +CAPSULE_HANDLE := app.store +CAPSULE_DOMAIN := systems.nonos +CAPSULE_DIR := userland/capsule_app_store +CAPSULE_BIN_NAME := app_store +CAPSULE_FEATURE := nonos-capsule-app-store +CAPSULE_NAMESPACE := systems.nonos.app.store +CAPSULE_SERVICE_ENDPOINT := service:4940:app.store +CAPSULE_REPLY_ENDPOINT := reply:4941:endpoint.app.store.reply +CAPSULE_REQUIRED_CAPS := 0xC0001819 +CAPSULE_KERNEL_MIRROR := src/userspace/capsule_app_store + +include nonos-mk/capsule.mk diff --git a/userland/capsule_app_store/Cargo.lock b/userland/capsule_app_store/Cargo.lock new file mode 100644 index 0000000000..a7edfaa8ca --- /dev/null +++ b/userland/capsule_app_store/Cargo.lock @@ -0,0 +1,131 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "ab_glyph" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01c0457472c38ea5bd1c3b5ada5e368271cb550be7a4ca4a0b4634e9913f6cc2" +dependencies = [ + "ab_glyph_rasterizer", + "libm", + "owned_ttf_parser", +] + +[[package]] +name = "ab_glyph_rasterizer" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" +dependencies = [ + "libm", +] + +[[package]] +name = "core_maths" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77745e017f5edba1a9c1d854f6f3a52dac8a12dd5af5d2f54aecf61e43d80d30" +dependencies = [ + "libm", +] + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "linked_list_allocator" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b23ac50abb8261cb38c6e2a7192d3302e0836dac1628f6a93b82b4fad185897" +dependencies = [ + "spinning_top", +] + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "nonos_app_skeleton" +version = "0.3.0" +dependencies = [ + "nonos_toolkit", + "nonos_userland_libc", +] + +[[package]] +name = "nonos_app_store" +version = "0.1.0" +dependencies = [ + "nonos_app_skeleton", + "nonos_toolkit", + "nonos_userland_libc", +] + +[[package]] +name = "nonos_toolkit" +version = "0.3.0" +dependencies = [ + "ab_glyph", + "nonos_userland_libc", + "spin", +] + +[[package]] +name = "nonos_userland_libc" +version = "0.3.0" +dependencies = [ + "linked_list_allocator", +] + +[[package]] +name = "owned_ttf_parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36820e9051aca1014ddc75770aab4d68bc1e9e632f0f5627c4086bc216fb583b" +dependencies = [ + "ttf-parser", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "spin" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" +dependencies = [ + "lock_api", +] + +[[package]] +name = "spinning_top" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b9eb1a2f4c41445a3a0ff9abc5221c5fcd28e1f13cd7c0397706f9ac938ddb0" +dependencies = [ + "lock_api", +] + +[[package]] +name = "ttf-parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" +dependencies = [ + "core_maths", +] diff --git a/userland/capsule_app_store/Cargo.toml b/userland/capsule_app_store/Cargo.toml new file mode 100644 index 0000000000..2bbb648ad0 --- /dev/null +++ b/userland/capsule_app_store/Cargo.toml @@ -0,0 +1,43 @@ +# NONOS userland: capsule_app_store +# eK@nonos.systems +# +# The marketplace window. Reads the catalogue the market capsule serves +# over `market.index`, groups it by the three namespaces the operator +# signs (NONOS capsules, Linux packages, community submissions), and for +# the selected listing shows every install gate with its own verdict so a +# refusal names what refused rather than greying out a button. +# +# Holds no install logic and no payment logic: this displays the index +# authority's answers and does not form its own. + +[package] +name = "nonos_app_store" +version = "0.1.0" +edition = "2021" +publish = false +license = "AGPL-3.0" +authors = ["eK@nonos.systems"] +description = "NONOS marketplace window" + +build = "build.rs" + +[[bin]] +name = "app_store" +path = "src/main.rs" + +[dependencies] +nonos_libc = { package = "nonos_userland_libc", path = "../libc" } +nonos_app_skeleton = { path = "../app_skeleton" } +nonos_toolkit = { path = "../toolkit", default-features = false } + +[profile.release] +panic = "abort" +opt-level = 2 +lto = false +debug = false +strip = true + +[profile.dev] +panic = "abort" +opt-level = 0 +debug = true diff --git a/userland/capsule_app_store/build.rs b/userland/capsule_app_store/build.rs new file mode 100644 index 0000000000..1c62a00fa8 --- /dev/null +++ b/userland/capsule_app_store/build.rs @@ -0,0 +1,53 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +use std::env; +use std::process::Command; + +fn main() { + let sha = resolve_sha(); + println!("cargo:rustc-env=ABOUT_GIT_SHA={sha}"); + println!("cargo:rerun-if-changed=build.rs"); + println!("cargo:rerun-if-changed=src"); + println!("cargo:rerun-if-changed=../../LICENSE"); + println!("cargo:rerun-if-env-changed=NONOS_BUILD_SHA"); + println!("cargo:rerun-if-env-changed=GITHUB_SHA"); +} + +fn resolve_sha() -> String { + if let Ok(sha) = env::var("NONOS_BUILD_SHA") { + if !sha.trim().is_empty() { + return sha.trim().chars().take(12).collect(); + } + } + if let Ok(sha) = env::var("GITHUB_SHA") { + if !sha.trim().is_empty() { + return sha.trim().chars().take(12).collect(); + } + } + if let Some(sha) = Command::new("git") + .args(["rev-parse", "--short=12", "HEAD"]) + .output() + .ok() + .and_then(|o| if o.status.success() { String::from_utf8(o.stdout).ok() } else { None }) + .map(|s| s.trim().to_string()) + { + if !sha.is_empty() { + return sha; + } + } + "unknown".into() +} diff --git a/userland/capsule_app_store/src/main.rs b/userland/capsule_app_store/src/main.rs new file mode 100644 index 0000000000..0d9c1101c8 --- /dev/null +++ b/userland/capsule_app_store/src/main.rs @@ -0,0 +1,31 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +#![no_std] +#![no_main] + +extern crate alloc; + +mod store; + +use nonos_app_skeleton::run; + +/// # Safety The loader calls this once, on a fresh stack, as the process entry +/// point. +#[no_mangle] +pub unsafe extern "C" fn _start() -> ! { + run(store::Store::new) +} diff --git a/userland/capsule_app_store/src/store/app.rs b/userland/capsule_app_store/src/store/app.rs new file mode 100644 index 0000000000..d8ae81e6c6 --- /dev/null +++ b/userland/capsule_app_store/src/store/app.rs @@ -0,0 +1,44 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +use nonos_app_skeleton::{App, AppManifest, EventOutcome, InputEvent, PaintBuffer}; + +use super::event::on_event; +use super::manifest::manifest; +use super::state::State; +use super::ui::frame; + +pub struct Store { + state: State, +} + +impl Store { + pub fn new() -> Self { + Store { state: State::new() } + } +} + +impl App for Store { + fn manifest(&self) -> AppManifest { + manifest() + } + fn on_event(&mut self, event: InputEvent) -> EventOutcome { + on_event(&mut self.state, event) + } + fn paint(&mut self, fb: &mut PaintBuffer) { + frame(&mut self.state, fb); + } +} diff --git a/userland/capsule_app_store/src/store/consent.rs b/userland/capsule_app_store/src/store/consent.rs new file mode 100644 index 0000000000..1fe1cbad7d --- /dev/null +++ b/userland/capsule_app_store/src/store/consent.rs @@ -0,0 +1,64 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! Consenting to run what this machine installs. + +use nonos_libc::{mk_dev_root_confirm, mk_dev_root_local}; + +/// The challenge is a 32-bit number, so ten digits is every value it +/// can take and one more would be a typo rather than a longer code. +const MAX_DIGITS: usize = 10; + +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum Consent { + /// Nothing asked for yet. + Idle, + /// A code is on the console and these are the digits typed so far. + Typing(u32, u8), + Granted, + Refused, +} + +impl Consent { + /// The code goes to the console, not to the return value. + pub fn begin() -> Consent { + match mk_dev_root_local() { + 0 => Consent::Typing(0, 0), + _ => Consent::Refused, + } + } + + pub fn digit(self, d: u32) -> Consent { + match self { + Consent::Typing(v, n) if (n as usize) < MAX_DIGITS => { + Consent::Typing(v.wrapping_mul(10).wrapping_add(d), n + 1) + } + other => other, + } + } + + pub fn submit(self) -> Consent { + let Consent::Typing(code, n) = self else { return self }; + if n == 0 { + return self; + } + match mk_dev_root_confirm(code) { + n if n < 0 => Consent::Refused, + _ => Consent::Granted, + } + } + +} diff --git a/userland/capsule_app_store/src/store/event.rs b/userland/capsule_app_store/src/store/event.rs new file mode 100644 index 0000000000..f6ca93921b --- /dev/null +++ b/userland/capsule_app_store/src/store/event.rs @@ -0,0 +1,48 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! Input. + +use nonos_app_skeleton::{EventOutcome, InputEvent, InputKind, KEY_ESC}; + +use super::event_click::on_click; +use super::event_keys::on_key; +use super::state::State; + +pub fn on_event(state: &mut State, event: InputEvent) -> EventOutcome { + if event.kind == InputKind::ButtonDown { + return on_click(state, event.x, event.y); + } + // A wheel travels the list and leaves the selection alone. + if event.kind == InputKind::Wheel { + let rows = -(event.delta_y.signum() as isize) * 3; + return match state.scroll_by(rows) { + true => EventOutcome::Repaint, + false => EventOutcome::Idle, + }; + } + if !event.is_key_down() { + return EventOutcome::Idle; + } + /* + * Escape closes the window, unless the search field has it: see + * `event_search`, which gives it back. + */ + if event.code == KEY_ESC && !state.search.active { + return EventOutcome::Close; + } + on_key(state, event.code) +} diff --git a/userland/capsule_app_store/src/store/event_actions.rs b/userland/capsule_app_store/src/store/event_actions.rs new file mode 100644 index 0000000000..3c09f5ac3c --- /dev/null +++ b/userland/capsule_app_store/src/store/event_actions.rs @@ -0,0 +1,57 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! The keys that do something rather than move somewhere. + +use nonos_app_skeleton::{EventOutcome, KEY_ENTER}; + +use super::consent::Consent; +use super::install; +use super::state::State; + +const KEY_E: u32 = b'e' as u32; +const KEY_R: u32 = b'r' as u32; + +pub(super) fn act(state: &mut State, code: u32) -> EventOutcome { + let changed = match code { + KEY_E => { + state.consent = Consent::begin(); + true + } + // Enter confirms a typed code, installs otherwise. + KEY_ENTER => { + match state.consent { + Consent::Typing(..) => state.consent = state.consent.submit(), + _ => state.asked = Some(install::ask(state)), + } + true + } + d if (b'0' as u32..=b'9' as u32).contains(&d) => { + state.consent = state.consent.digit(d - b'0' as u32); + true + } + KEY_R => { + state.asked = None; + state.refresh(); + true + } + _ => false, + }; + match changed { + true => EventOutcome::Repaint, + false => EventOutcome::Idle, + } +} diff --git a/userland/capsule_app_store/src/store/event_click.rs b/userland/capsule_app_store/src/store/event_click.rs new file mode 100644 index 0000000000..362600fc9e --- /dev/null +++ b/userland/capsule_app_store/src/store/event_click.rs @@ -0,0 +1,45 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! The pointer. + +use nonos_app_skeleton::EventOutcome; + +use super::state::{State, TABS}; +use super::ui::chrome::tab_rect; +use super::ui::metrics::{HEAD_H, PAD_TOP, TAB_H}; + +/// The tab strip first, then the list. +pub fn on_click(state: &mut State, x: i32, y: i32) -> EventOutcome { + if x < 0 || y < 0 { + return EventOutcome::Idle; + } + let top = (PAD_TOP + HEAD_H) as i32; + if y < top || y >= top + TAB_H as i32 { + return super::event_rows::on_list_click(state, x, y); + } + match hit(x as u32) { + Some(i) if state.set_tab(TABS[i]) => EventOutcome::Repaint, + _ => EventOutcome::Idle, + } +} + +fn hit(x: u32) -> Option { + (0..TABS.len()).find(|&i| { + let (left, w) = tab_rect(i); + x >= left && x < left + w + }) +} diff --git a/userland/capsule_app_store/src/store/event_keys.rs b/userland/capsule_app_store/src/store/event_keys.rs new file mode 100644 index 0000000000..f714134dd2 --- /dev/null +++ b/userland/capsule_app_store/src/store/event_keys.rs @@ -0,0 +1,61 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! Which key does what. + +use nonos_app_skeleton::{ + EventOutcome, KEY_DOWN, KEY_END, KEY_HOME, KEY_LEFT, KEY_PAGE_DOWN, KEY_PAGE_UP, KEY_RIGHT, + KEY_UP, +}; + +use super::event_actions::act; +use super::event_search::typing; +use super::event_tab::step_tab; + +use super::state::State; + +const KEY_SLASH: u32 = b'/' as u32; + +pub fn on_key(state: &mut State, code: u32) -> EventOutcome { + /* + * The field takes the keyboard while open, or a name with a digit in it + * types into the consent code. + */ + if state.search.active { + if let Some(outcome) = typing(state, code) { + return outcome; + } + } + let changed = match code { + KEY_UP => state.move_by(-1), + KEY_DOWN => state.move_by(1), + KEY_PAGE_UP => state.move_by(-(state.rows as isize)), + KEY_PAGE_DOWN => state.move_by(state.rows as isize), + KEY_HOME => state.move_by(isize::MIN / 2), + KEY_END => state.move_by(isize::MAX / 2), + KEY_LEFT => step_tab(state, -1), + KEY_RIGHT => step_tab(state, 1), + KEY_SLASH => { + state.search.open(); + true + } + c => return act(state, c), + }; + match changed { + true => EventOutcome::Repaint, + false => EventOutcome::Idle, + } +} diff --git a/userland/capsule_app_store/src/store/event_rows.rs b/userland/capsule_app_store/src/store/event_rows.rs new file mode 100644 index 0000000000..0f580c51e5 --- /dev/null +++ b/userland/capsule_app_store/src/store/event_rows.rs @@ -0,0 +1,49 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! Clicking a card. + +use nonos_app_skeleton::EventOutcome; + +use super::install; +use super::state::State; +use super::ui::geometry::{list_top, list_w, on_action, row_top, slot_at}; +use super::ui::metrics::PAD_X; + +pub fn on_list_click(state: &mut State, x: i32, y: i32) -> EventOutcome { + if y < list_top() as i32 || x < PAD_X as i32 { + return EventOutcome::Idle; + } + let width = list_w(state.fb_w); + if x >= (PAD_X + width) as i32 { + return EventOutcome::Idle; + } + let Some(slot) = slot_at(y, state.rows) else { + return EventOutcome::Idle; + }; + if state.scroll + slot >= state.visible().len() { + return EventOutcome::Idle; + } + let moved = state.select_slot(slot); + if on_action(x, y, PAD_X, row_top(slot), width) { + state.asked = Some(install::ask(state)); + return EventOutcome::Repaint; + } + match moved { + true => EventOutcome::Repaint, + false => EventOutcome::Idle, + } +} diff --git a/userland/capsule_app_store/src/store/event_search.rs b/userland/capsule_app_store/src/store/event_search.rs new file mode 100644 index 0000000000..7c9423b7fc --- /dev/null +++ b/userland/capsule_app_store/src/store/event_search.rs @@ -0,0 +1,65 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! The keyboard while the search field is open. + +use nonos_app_skeleton::{EventOutcome, KEY_BACKSPACE, KEY_ENTER, KEY_ESC}; + +use super::state::State; + +pub fn typing(state: &mut State, code: u32) -> Option { + match code { + // Escape leaves the field rather than closing the window. + KEY_ESC => { + state.search.close(); + reset(state); + Some(EventOutcome::Repaint) + } + /* + * Enter keeps the filter and gives the keyboard back, so the + * next Enter installs what was found. + */ + KEY_ENTER => { + state.search.active = false; + Some(EventOutcome::Repaint) + } + KEY_BACKSPACE => { + let changed = state.search.pop(); + reset(state); + Some(match changed { + true => EventOutcome::Repaint, + false => EventOutcome::Idle, + }) + } + c if (0x20..0x7F).contains(&c) => { + let changed = state.search.push(c as u8); + reset(state); + Some(match changed { + true => EventOutcome::Repaint, + false => EventOutcome::Idle, + }) + } + _ => None, + } +} + +/// The list under the cursor just changed, so the cursor cannot stay where it +/// was: it would point past the end, or at a row the query no longer keeps. +fn reset(state: &mut State) { + state.cursor = 0; + state.scroll = 0; + state.select(); +} diff --git a/userland/capsule_app_store/src/store/event_tab.rs b/userland/capsule_app_store/src/store/event_tab.rs new file mode 100644 index 0000000000..0bd9530c55 --- /dev/null +++ b/userland/capsule_app_store/src/store/event_tab.rs @@ -0,0 +1,24 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . +//! Walking the tab strip with the arrow keys. + +use super::state::{State, TABS}; + +pub(super) fn step_tab(state: &mut State, delta: isize) -> bool { + let at = TABS.iter().position(|t| *t == state.tab).unwrap_or(0) as isize; + let want = (at + delta).clamp(0, TABS.len() as isize - 1) as usize; + state.set_tab(TABS[want]) +} diff --git a/userland/capsule_app_store/src/store/install.rs b/userland/capsule_app_store/src/store/install.rs new file mode 100644 index 0000000000..6446ab734a --- /dev/null +++ b/userland/capsule_app_store/src/store/install.rs @@ -0,0 +1,59 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! Asking for the selected listing to be installed. + +use nonos_libc::mk_app_install; + +use super::state::State; + +/// What the user is told after asking. +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum Asked { + Queued, + Busy, + Refused, + NotInstallable, +} + +impl Asked { + pub fn label(self) -> &'static [u8] { + match self { + Asked::Queued => b"install requested", + Asked::Busy => b"too many installs already queued", + Asked::Refused => b"the system refused the request", + Asked::NotInstallable => b"nothing to fetch for this listing", + } + } +} + +pub fn ask(state: &State) -> Asked { + let Some(listing) = state.current() else { + return Asked::NotInstallable; + }; + // Only a distribution package has anything to fetch. + let Some(package) = listing.id.strip_prefix(b"linux.".as_slice()) else { + return Asked::NotInstallable; + }; + if !listing.ready { + return Asked::NotInstallable; + } + match mk_app_install(package) { + 0 => Asked::Queued, + -16 => Asked::Busy, + _ => Asked::Refused, + } +} diff --git a/userland/capsule_app_store/src/store/listing.rs b/userland/capsule_app_store/src/store/listing.rs new file mode 100644 index 0000000000..799de26eb7 --- /dev/null +++ b/userland/capsule_app_store/src/store/listing.rs @@ -0,0 +1,61 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! One row of the catalogue. + +use alloc::vec::Vec; + +/// Where a listing came from, read off the namespace its id starts with. +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum Source { + NonOs, + Linux, + Community, +} + +impl Source { + pub fn of(listing_id: &[u8]) -> Source { + match listing_id { + id if id.starts_with(b"linux.") => Source::Linux, + id if id.starts_with(b"community.") => Source::Community, + _ => Source::NonOs, + } + } + + pub fn label(self) -> &'static [u8] { + match self { + Source::NonOs => b"NONOS", + Source::Linux => b"Linux", + Source::Community => b"Community", + } + } +} + +pub struct Listing { + pub id: Vec, + pub measurement: [u8; 32], + pub name: Vec, + /// The market capsule's verdict across every install gate, taken as given. + pub ready: bool, + pub source: Source, +} + +impl Listing { + pub fn new(id: Vec, measurement: [u8; 32], name: Vec, ready: bool) -> Listing { + let source = Source::of(&id); + Listing { id, measurement, name, ready, source } + } +} diff --git a/userland/capsule_app_store/src/store/manifest.rs b/userland/capsule_app_store/src/store/manifest.rs new file mode 100644 index 0000000000..e028f9d999 --- /dev/null +++ b/userland/capsule_app_store/src/store/manifest.rs @@ -0,0 +1,43 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +use nonos_app_skeleton::{AppManifest, WindowKind}; + +use super::ui::metrics::{WIN_H, WIN_W, WIN_X, WIN_Y}; + +const WINDOW_ID: u32 = 0x4150_5053; + +/* + * Keys drive the list and the category; the tab strip is clickable, so the + * button and the absolute pointer are subscribed to keep those coordinates + * current. + */ +const INPUT_KEY_DOWN_BIT: u32 = 1 << 0; +const INPUT_POINTER_ABS_BIT: u32 = 1 << 3; +const INPUT_BUTTON_DOWN_BIT: u32 = 1 << 5; + +pub fn manifest() -> AppManifest { + AppManifest { + title: "NØNOS Marketplace".as_bytes(), + window_id: WINDOW_ID, + kind: WindowKind::Normal, + initial_x: WIN_X, + initial_y: WIN_Y, + width: WIN_W, + height: WIN_H, + input_kind_mask: INPUT_KEY_DOWN_BIT | INPUT_BUTTON_DOWN_BIT | INPUT_POINTER_ABS_BIT, + } +} diff --git a/userland/capsule_app_store/src/store/market/detail.rs b/userland/capsule_app_store/src/store/market/detail.rs new file mode 100644 index 0000000000..47cd2b7923 --- /dev/null +++ b/userland/capsule_app_store/src/store/market/detail.rs @@ -0,0 +1,55 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! Everything about one listing that the list reply leaves out. + +use alloc::vec::Vec; + +use super::wire::call; + +const OP_GET_APP: u16 = 3; + +pub struct Detail { + pub publisher: Vec, + pub description: Vec, +} + +pub fn fetch(port: u32, request_id: u32, listing: &[u8]) -> Option { + let mut body = Vec::with_capacity(4 + listing.len()); + body.extend_from_slice(&(listing.len() as u32).to_le_bytes()); + body.extend_from_slice(listing); + let out = call(port, OP_GET_APP, request_id, &body)?; + + // listing_id, capsule_id, name, publisher, pubkey, description, count + let (_, at) = lp(&out, 0)?; + let at = at + 32; + let (_, at) = lp(&out, at)?; + let (publisher, at) = lp(&out, at)?; + let at = at + 32; + let (description, at) = lp(&out, at)?; + // The release count closes the message. + out.get(at..at + 4)?; + Some(Detail { publisher, description }) +} + +/// Four bytes of length then the bytes, every bound checked against the +/// buffer that arrived rather than the length claiming to describe it. +fn lp(body: &[u8], at: usize) -> Option<(Vec, usize)> { + let len = u32::from_le_bytes(body.get(at..at + 4)?.try_into().ok()?) as usize; + let start = at + 4; + let end = start.checked_add(len)?; + Some((body.get(start..end)?.to_vec(), end)) +} diff --git a/userland/capsule_app_store/src/store/market/list.rs b/userland/capsule_app_store/src/store/market/list.rs new file mode 100644 index 0000000000..3bbd9f7c4f --- /dev/null +++ b/userland/capsule_app_store/src/store/market/list.rs @@ -0,0 +1,52 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! The catalogue, as the market capsule serves it. + +use alloc::vec::Vec; + +use crate::store::listing::Listing; + +use super::wire::call; + +const OP_LIST_APPS: u16 = 2; + +pub fn fetch(port: u32, request_id: u32) -> Option> { + let body = call(port, OP_LIST_APPS, request_id, &[])?; + let count = u32::from_le_bytes(body.get(..4)?.try_into().ok()?) as usize; + let mut at = 4; + let mut out = Vec::with_capacity(count.min(1024)); + for _ in 0..count { + let (id, next) = lp(&body, at)?; + at = next; + let measurement: [u8; 32] = body.get(at..at + 32)?.try_into().ok()?; + at += 32; + let (name, next) = lp(&body, at)?; + at = next; + let ready = *body.get(at)? != 0; + at += 1; + out.push(Listing::new(id, measurement, name, ready)); + } + Some(out) +} + +/// A length-prefixed string: four bytes of length, then the bytes. +fn lp(body: &[u8], at: usize) -> Option<(Vec, usize)> { + let len = u32::from_le_bytes(body.get(at..at + 4)?.try_into().ok()?) as usize; + let start = at + 4; + let end = start.checked_add(len)?; + Some((body.get(start..end)?.to_vec(), end)) +} diff --git a/userland/capsule_app_store/src/store/market/mod.rs b/userland/capsule_app_store/src/store/market/mod.rs new file mode 100644 index 0000000000..d8a5bd8402 --- /dev/null +++ b/userland/capsule_app_store/src/store/market/mod.rs @@ -0,0 +1,28 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! Talking to the market capsule. + +mod detail; +mod list; +mod ready; +mod service; +mod wire; + +pub use detail::{fetch as get_app, Detail}; +pub use list::fetch as list_apps; +pub use ready::{fetch as install_ready, Readiness, GATES}; +pub use service::{next_id, port}; diff --git a/userland/capsule_app_store/src/store/market/ready.rs b/userland/capsule_app_store/src/store/market/ready.rs new file mode 100644 index 0000000000..33a19e4c8f --- /dev/null +++ b/userland/capsule_app_store/src/store/market/ready.rs @@ -0,0 +1,55 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! Why a listing cannot be installed. + +use super::wire::call; + +const OP_INSTALL_READY: u16 = 5; + +/// The six gates, in the order the capsule writes them after the verdict. +pub const GATES: [&[u8]; 6] = [ + b"index signature", + b"package present", + b"publisher signature", + b"operator validation", + b"architecture", + b"attestation", +]; + +#[derive(Clone, Copy)] +pub struct Readiness { + pub install_ready: bool, + pub gates: [bool; 6], +} + +pub fn fetch(port: u32, request_id: u32, listing: &[u8], release: &[u8]) -> Option { + let mut body = alloc::vec::Vec::with_capacity(8 + listing.len() + release.len()); + body.extend_from_slice(&(listing.len() as u32).to_le_bytes()); + body.extend_from_slice(listing); + body.extend_from_slice(&(release.len() as u32).to_le_bytes()); + body.extend_from_slice(release); + let out = call(port, OP_INSTALL_READY, request_id, &body)?; + // Seven bytes: the verdict then one per gate. + if out.len() < 1 + GATES.len() { + return None; + } + let mut gates = [false; 6]; + for (i, g) in gates.iter_mut().enumerate() { + *g = out[1 + i] != 0; + } + Some(Readiness { install_ready: out[0] != 0, gates }) +} diff --git a/userland/capsule_app_store/src/store/market/service.rs b/userland/capsule_app_store/src/store/market/service.rs new file mode 100644 index 0000000000..f73db724b1 --- /dev/null +++ b/userland/capsule_app_store/src/store/market/service.rs @@ -0,0 +1,48 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! Where the market capsule is, and a request id to reach it with. + +use core::sync::atomic::{AtomicU32, Ordering}; + +use nonos_libc::mk_service_lookup; + +/// The service the market capsule announces itself under. +const SERVICE: &[u8] = b"market.index"; + +/// Request ids only have to differ from this process's other in-flight +/// calls, so a counter is enough and it never needs to survive a restart. +static NEXT_ID: AtomicU32 = AtomicU32::new(1); + +pub fn next_id() -> u32 { + NEXT_ID.fetch_add(1, Ordering::Relaxed) +} + +/// The market's port, or zero when it has not announced one. +pub fn port() -> u32 { + let mut pid: u32 = 0; + let mut port: u32 = 0; + let rc = mk_service_lookup( + SERVICE.as_ptr(), + SERVICE.len(), + &mut port as *mut u32, + &mut pid as *mut u32, + ); + if rc < 0 || pid == 0 { + return 0; + } + port +} diff --git a/userland/capsule_app_store/src/store/market/wire.rs b/userland/capsule_app_store/src/store/market/wire.rs new file mode 100644 index 0000000000..f586ac4f81 --- /dev/null +++ b/userland/capsule_app_store/src/store/market/wire.rs @@ -0,0 +1,67 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! One call to the market service, framed the way it frames replies. + +use alloc::vec; +use alloc::vec::Vec; + +use nonos_libc::mk_ipc_call_timeout; + +const MAGIC: u32 = 0x4E4D_4B54; +const VERSION: u16 = 1; +pub const HDR_LEN: usize = 20; +const STATUS_LEN: usize = 4; + +/// A catalogue reply carries every listing, so this is sized for the +/// catalogue rather than for one entry. +const RX_CAP: usize = 96 << 10; + +/// Long enough for the capsule to walk its index, short enough that a +/// service that has stopped answering does not freeze a repaint. +const TIMEOUT_MS: u64 = 1500; + +pub fn call(port: u32, op: u16, request_id: u32, body: &[u8]) -> Option> { + if port == 0 { + return None; + } + let mut tx = Vec::with_capacity(HDR_LEN + body.len()); + tx.extend_from_slice(&MAGIC.to_le_bytes()); + tx.extend_from_slice(&VERSION.to_le_bytes()); + tx.extend_from_slice(&op.to_le_bytes()); + tx.extend_from_slice(&0u16.to_le_bytes()); + tx.extend_from_slice(&0u16.to_le_bytes()); + tx.extend_from_slice(&request_id.to_le_bytes()); + tx.extend_from_slice(&(body.len() as u32).to_le_bytes()); + tx.extend_from_slice(body); + + let mut rx = vec![0u8; RX_CAP]; + let rc = + mk_ipc_call_timeout(port as u64, tx.as_ptr(), tx.len(), rx.as_mut_ptr(), rx.len(), TIMEOUT_MS); + let got = usize::try_from(rc).ok()?; + if got < HDR_LEN + STATUS_LEN { + return None; + } + let status = i32::from_le_bytes(rx.get(HDR_LEN..HDR_LEN + STATUS_LEN)?.try_into().ok()?); + if status != 0 { + return None; + } + /* + * The status word is part of the body on this protocol, and every reader + * here wants what follows it. + */ + Some(rx.get(HDR_LEN + STATUS_LEN..got)?.to_vec()) +} diff --git a/userland/capsule_app_store/src/store/mod.rs b/userland/capsule_app_store/src/store/mod.rs new file mode 100644 index 0000000000..1e8387bc0a --- /dev/null +++ b/userland/capsule_app_store/src/store/mod.rs @@ -0,0 +1,46 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! The marketplace window: the catalogue the market capsule serves, the three +//! namespaces it carries, and why any one listing can or cannot be installed +//! on this machine. + +mod app; +mod event; +mod consent; +mod event_actions; +mod event_click; +mod event_keys; +mod event_rows; +mod event_search; +mod event_tab; +mod install; +mod listing; +pub mod market; +mod manifest; +pub mod search; +mod state; +mod state_move; +mod state_refresh; +mod state_select; +mod state_window; +mod tab; +mod state_ops; +mod theme; +mod ui; +mod verdict; + +pub use app::Store; diff --git a/userland/capsule_app_store/src/store/search.rs b/userland/capsule_app_store/src/store/search.rs new file mode 100644 index 0000000000..4cb8a9790d --- /dev/null +++ b/userland/capsule_app_store/src/store/search.rs @@ -0,0 +1,72 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! The query, and what it matches. + +use alloc::vec::Vec; + +/// Longer than any name listed, so a held key cannot grow the field. +const MAX: usize = 64; + +#[derive(Default)] +pub struct Search { + pub active: bool, + text: Vec, +} + +impl Search { + pub fn text(&self) -> &[u8] { + &self.text + } + + /// Keeps what is typed: reopening to add a letter should not + /// discard the word. + pub fn open(&mut self) { + self.active = true; + } + + /// Leave and clear: a closed field that went on filtering would + /// hide rows with nothing on screen to say why. + pub fn close(&mut self) { + self.active = false; + self.text.clear(); + } + + pub fn push(&mut self, byte: u8) -> bool { + if self.text.len() >= MAX { + return false; + } + self.text.push(byte.to_ascii_lowercase()); + true + } + + pub fn pop(&mut self) -> bool { + self.text.pop().is_some() + } + + /// Case-insensitive substring. Empty accepts everything; longer + /// than the name matches nothing rather than panicking. + pub fn accepts(&self, name: &[u8]) -> bool { + if self.text.is_empty() { + return true; + } + if self.text.len() > name.len() { + return false; + } + let lower = |b: &u8| b.to_ascii_lowercase(); + name.windows(self.text.len()).any(|w| w.iter().map(lower).eq(self.text.iter().copied())) + } +} diff --git a/userland/capsule_app_store/src/store/state.rs b/userland/capsule_app_store/src/store/state.rs new file mode 100644 index 0000000000..595c4a50d8 --- /dev/null +++ b/userland/capsule_app_store/src/store/state.rs @@ -0,0 +1,47 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! What the window is showing. + +pub use super::tab::{Tab, TABS}; + +use alloc::vec::Vec; + +use super::listing::Listing; +use super::market; + + +pub struct State { + pub listings: Vec, + pub tab: Tab, + pub cursor: usize, + pub scroll: usize, + pub rows: usize, + pub fb_w: u32, + pub fb_h: u32, + /// Set when the catalogue could not be read. + pub trouble: Option<&'static [u8]>, + pub ready: Option, + /// What the last install request was answered with, shown until the next + /// one. + pub asked: Option, + /// Where enrolment has got to. + pub consent: super::consent::Consent, + /// Description and publisher for the selected listing, fetched once per + /// selection. + pub search: super::search::Search, + pub detail: Option, +} diff --git a/userland/capsule_app_store/src/store/state_move.rs b/userland/capsule_app_store/src/store/state_move.rs new file mode 100644 index 0000000000..e6797625f3 --- /dev/null +++ b/userland/capsule_app_store/src/store/state_move.rs @@ -0,0 +1,56 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! The cursor and the window onto the list. + +use super::state::{State, Tab}; + +impl State { + pub fn move_by(&mut self, delta: isize) -> bool { + let n = self.visible().len(); + if n == 0 { + return false; + } + let want = (self.cursor as isize + delta).clamp(0, n as isize - 1) as usize; + if want == self.cursor { + return false; + } + self.cursor = want; + self.follow(); + self.select(); + true + } + + /// Keep the cursor inside the rows the pane can show. + fn follow(&mut self) { + if self.cursor < self.scroll { + self.scroll = self.cursor; + } else if self.cursor >= self.scroll + self.rows { + self.scroll = self.cursor + 1 - self.rows; + } + } + + pub fn set_tab(&mut self, tab: Tab) -> bool { + if self.tab == tab { + return false; + } + self.tab = tab; + self.cursor = 0; + self.scroll = 0; + self.select(); + true + } +} diff --git a/userland/capsule_app_store/src/store/state_ops.rs b/userland/capsule_app_store/src/store/state_ops.rs new file mode 100644 index 0000000000..09de3ca200 --- /dev/null +++ b/userland/capsule_app_store/src/store/state_ops.rs @@ -0,0 +1,50 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! Moving through the catalogue. + +use alloc::vec::Vec; + +use super::state::{State, Tab}; + +impl State { + pub fn new() -> State { + let mut state = State { + listings: Vec::new(), + tab: Tab::All, + cursor: 0, + scroll: 0, + rows: 1, + fb_w: 0, + fb_h: 0, + trouble: None, + ready: None, + asked: None, + consent: super::consent::Consent::Idle, + search: super::search::Search::default(), + detail: None, + }; + state.refresh(); + state + } + + /// Indices into `listings` that the current tab shows. + pub fn visible(&self) -> Vec { + let keep = + |(i, l): (usize, &super::listing::Listing)| self.tab.accepts(l.source).then_some(i); + self.listings.iter().enumerate().filter_map(keep).collect() + } +} diff --git a/userland/capsule_app_store/src/store/state_refresh.rs b/userland/capsule_app_store/src/store/state_refresh.rs new file mode 100644 index 0000000000..a5cc25c47e --- /dev/null +++ b/userland/capsule_app_store/src/store/state_refresh.rs @@ -0,0 +1,49 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! Fetching the catalogue. + +use super::market; +use super::state::State; + +impl State { + /// Ask the market for the catalogue again. + pub fn refresh(&mut self) { + let port = market::port(); + if port == 0 { + self.listings.clear(); + self.trouble = Some(b"market service has not announced itself"); + return; + } + match market::list_apps(port, market::next_id()) { + Some(found) => { + self.listings = found; + self.trouble = None; + } + /* + * The call failed, which is not the same as the catalogue being + * empty and must not be reported as it. + */ + None => { + self.listings.clear(); + self.trouble = Some(b"market did not answer"); + } + } + self.cursor = 0; + self.scroll = 0; + self.select(); + } +} diff --git a/userland/capsule_app_store/src/store/state_select.rs b/userland/capsule_app_store/src/store/state_select.rs new file mode 100644 index 0000000000..e8115347fc --- /dev/null +++ b/userland/capsule_app_store/src/store/state_select.rs @@ -0,0 +1,40 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! What selecting a listing costs. + +use super::market; +use super::state::State; + +impl State { + /// Ask the market why the selected listing can or cannot be installed. + pub fn select(&mut self) { + self.ready = None; + self.detail = None; + let Some(listing) = self.current() else { return }; + let (id, port) = (listing.id.clone(), market::port()); + self.detail = market::get_app(port, market::next_id(), &id); + /* + * The release is left unnamed because the capsule resolves the default + * when it is. + */ + self.ready = market::install_ready(port, market::next_id(), &id, &[]); + } + + pub fn current(&self) -> Option<&super::listing::Listing> { + self.listings.get(*self.visible().get(self.cursor)?) + } +} diff --git a/userland/capsule_app_store/src/store/state_window.rs b/userland/capsule_app_store/src/store/state_window.rs new file mode 100644 index 0000000000..02feb215a8 --- /dev/null +++ b/userland/capsule_app_store/src/store/state_window.rs @@ -0,0 +1,56 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . +//! The window onto the list: which rows are showing, and which row the cursor +//! is on when a pointer puts it there. + +use super::state::State; + +impl State { + /// Keep the window inside the list. Called from the frame, which is + /// the only place the row count is known. + pub fn clamp_scroll(&mut self) { + let n = self.visible().len(); + let most = n.saturating_sub(self.rows); + if self.scroll > most { + self.scroll = most; + } + } + + /// Move the window without moving the cursor, which is what a wheel does: + /// the selection stays where the user put it and the list travels under + /// it. + pub fn scroll_by(&mut self, delta: isize) -> bool { + let n = self.visible().len(); + let most = n.saturating_sub(self.rows) as isize; + let want = (self.scroll as isize + delta).clamp(0, most.max(0)) as usize; + if want == self.scroll { + return false; + } + self.scroll = want; + true + } + + /// Put the cursor on a visible slot, as a click does. + pub fn select_slot(&mut self, slot: usize) -> bool { + let want = self.scroll + slot; + if want >= self.visible().len() || want == self.cursor { + return false; + } + self.cursor = want; + self.select(); + true + } +} diff --git a/userland/capsule_app_store/src/store/tab.rs b/userland/capsule_app_store/src/store/tab.rs new file mode 100644 index 0000000000..02b23db63c --- /dev/null +++ b/userland/capsule_app_store/src/store/tab.rs @@ -0,0 +1,50 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! The source filter across the top of the list. + +use super::listing::Source; + +/// Which of the three namespaces the list is filtered to, or all of them. +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum Tab { + All, + NonOs, + Linux, + Community, +} + +pub const TABS: [Tab; 4] = [Tab::All, Tab::NonOs, Tab::Linux, Tab::Community]; + +impl Tab { + pub fn label(self) -> &'static [u8] { + match self { + Tab::All => b"All", + Tab::NonOs => b"NONOS", + Tab::Linux => b"Linux", + Tab::Community => b"Community", + } + } + + pub fn accepts(self, source: Source) -> bool { + match self { + Tab::All => true, + Tab::NonOs => source == Source::NonOs, + Tab::Linux => source == Source::Linux, + Tab::Community => source == Source::Community, + } + } +} diff --git a/userland/capsule_app_store/src/store/theme.rs b/userland/capsule_app_store/src/store/theme.rs new file mode 100644 index 0000000000..730761c730 --- /dev/null +++ b/userland/capsule_app_store/src/store/theme.rs @@ -0,0 +1,50 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . +// The house palette, the same values the About and Settings restyles +// established. Layout sizes are not here: they live in ui/metrics.rs. +pub const BACKGROUND: u32 = 0xFF0B1319; +pub const CARD_BG: u32 = 0xFF0E1920; +pub const CARD_SEL_BG: u32 = 0xFF13242E; +pub const CARD_SEL_EDGE: u32 = 0xFF35C4E2; +pub const PANE_BG: u32 = 0xFF101C24; +pub const STATUS_BG: u32 = 0xFF0D171E; +pub const RULE: u32 = 0xFF16262F; + +pub const TITLE: u32 = 0xFFEAF4F8; +pub const FOREGROUND: u32 = 0xFFCDDDE5; +pub const MUTED: u32 = 0xFF6D818C; +pub const ACCENT: u32 = 0xFF35C4E2; + +pub const TAB_FG: u32 = 0xFF93A7B2; +pub const TAB_FG_ACTIVE: u32 = 0xFFA8E7F6; +pub const TAB_BG_ACTIVE: u32 = 0x2035C4E2; + +/// The tile behind a listing's initial. Tinted per source so the three +/// namespaces are distinguishable before a single word is read. +pub const TILE_NONOS: u32 = 0xFF17323D; +pub const TILE_LINUX: u32 = 0xFF1B2E3A; +pub const TILE_COMMUNITY: u32 = 0xFF2A2438; + +/// The install action, filled rather than lettered: a coloured word is not +/// obviously a control, and every row on this screen is an offer to do +/// something. +pub const BUTTON_BG: u32 = 0xFF1B6E5A; +pub const BUTTON_FG: u32 = 0xFFD6F5EA; +pub const BUTTON_OFF_BG: u32 = 0xFF17242B; +pub const BUTTON_OFF_FG: u32 = 0xFF6D818C; + +pub const OK: u32 = 0xFF33CF7D; +pub const DANGER: u32 = 0xFFE06C75; diff --git a/userland/capsule_app_store/src/store/ui/card.rs b/userland/capsule_app_store/src/store/ui/card.rs new file mode 100644 index 0000000000..6caa0c62d1 --- /dev/null +++ b/userland/capsule_app_store/src/store/ui/card.rs @@ -0,0 +1,70 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! One listing, drawn as a card. + +use nonos_app_skeleton::PaintBuffer; + +use crate::store::listing::{Listing, Source}; +use crate::store::theme::{ + BUTTON_BG, BUTTON_FG, BUTTON_OFF_BG, BUTTON_OFF_FG, CARD_BG, CARD_SEL_BG, CARD_SEL_EDGE, MUTED, + TILE_COMMUNITY, TILE_LINUX, TILE_NONOS, TITLE, +}; + +use super::geometry::action_rect; +use super::metrics::{CARD_H, CARD_PAD, NAME_PX, SMALL_PX, TILE, TILE_GAP}; +use super::text; + +pub fn paint(fb: &mut PaintBuffer, l: &Listing, x: u32, y: u32, w: u32, selected: bool) { + fb.fill_rect(x, y, w, CARD_H, if selected { CARD_SEL_BG } else { CARD_BG }); + if selected { + /* + * A rule down the leading edge rather than a full border: it marks the + * row without boxing every card on the screen. + */ + fb.fill_rect(x, y, 3, CARD_H, CARD_SEL_EDGE); + } + + let tile_y = y + (CARD_H - TILE) / 2; + fb.fill_rect(x + CARD_PAD, tile_y, TILE, TILE, tint(l.source)); + let initial = [l.name.first().copied().unwrap_or(b'?').to_ascii_uppercase()]; + let ix = x + CARD_PAD + (TILE - text::width_of(&initial, NAME_PX)) / 2; + text::line(fb, ix, text::top_of(tile_y as i32, TILE, NAME_PX), &initial, TITLE, NAME_PX); + + let text_x = x + CARD_PAD + TILE + TILE_GAP; + text::line(fb, text_x, y as i32 + 12, &l.name, TITLE, NAME_PX); + text::line(fb, text_x, y as i32 + 34, l.source.label(), MUTED, SMALL_PX); + + action(fb, l, action_rect(x, y, w)); +} + +fn action(fb: &mut PaintBuffer, l: &Listing, (x, y, w, h): (u32, u32, u32, u32)) { + let (bg, fg, word): (u32, u32, &[u8]) = match l.ready { + true => (BUTTON_BG, BUTTON_FG, b"Install"), + false => (BUTTON_OFF_BG, BUTTON_OFF_FG, b"Details"), + }; + fb.fill_rect(x, y, w, h, bg); + let tx = x + (w - text::width_of(word, SMALL_PX)) / 2; + text::line(fb, tx, text::top_of(y as i32, h, SMALL_PX), word, fg, SMALL_PX); +} + +fn tint(source: Source) -> u32 { + match source { + Source::NonOs => TILE_NONOS, + Source::Linux => TILE_LINUX, + Source::Community => TILE_COMMUNITY, + } +} diff --git a/userland/capsule_app_store/src/store/ui/chrome.rs b/userland/capsule_app_store/src/store/ui/chrome.rs new file mode 100644 index 0000000000..f37923e382 --- /dev/null +++ b/userland/capsule_app_store/src/store/ui/chrome.rs @@ -0,0 +1,64 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! The head band and the source tabs. + +use nonos_app_skeleton::PaintBuffer; + +use crate::store::state::{State, TABS}; +use crate::store::theme::{MUTED, TAB_BG_ACTIVE, TAB_FG, TAB_FG_ACTIVE, TITLE}; + +use super::counter::listed; +use super::metrics::{ + BODY_PX, HEAD_H, PAD_TOP, PAD_X, SMALL_PX, TAB_GAP, TAB_H, TAB_PAD_X, TITLE_PX, +}; +use super::searchbar; +use super::text; + +pub fn head(fb: &mut PaintBuffer, state: &State) { + let top = text::top_of(PAD_TOP as i32, HEAD_H, TITLE_PX); + text::line(fb, PAD_X, top, b"Marketplace", TITLE, TITLE_PX); + let right = state.fb_w.saturating_sub(PAD_X); + let field = searchbar::paint(fb, &state.search, right); + let meta_top = text::top_of(PAD_TOP as i32, HEAD_H, BODY_PX); + let count = state.visible().len(); + let at = right.saturating_sub(field + if field == 0 { 0 } else { PAD_X }); + text::right(fb, at, meta_top, &listed(count), MUTED, BODY_PX); +} + +/// Where each tab sits. +pub fn tab_rect(index: usize) -> (u32, u32) { + let mut x = PAD_X; + for tab in TABS.iter().take(index) { + x += text::width_of(tab.label(), SMALL_PX) + TAB_PAD_X * 2 + TAB_GAP; + } + let w = text::width_of(TABS[index].label(), SMALL_PX) + TAB_PAD_X * 2; + (x, w) +} + +pub fn tabs(fb: &mut PaintBuffer, state: &State) { + let y = PAD_TOP + HEAD_H; + for (i, tab) in TABS.iter().enumerate() { + let (x, w) = tab_rect(i); + let active = *tab == state.tab; + if active { + fb.fill_rect(x, y, w, TAB_H, TAB_BG_ACTIVE); + } + let fg = if active { TAB_FG_ACTIVE } else { TAB_FG }; + let top = text::top_of(y as i32, TAB_H, SMALL_PX); + text::line(fb, x + TAB_PAD_X, top, tab.label(), fg, SMALL_PX); + } +} diff --git a/userland/capsule_app_store/src/store/ui/consent_text.rs b/userland/capsule_app_store/src/store/ui/consent_text.rs new file mode 100644 index 0000000000..ec9e594184 --- /dev/null +++ b/userland/capsule_app_store/src/store/ui/consent_text.rs @@ -0,0 +1,29 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! How each stage of enrolment reads to the user. + +use crate::store::consent::Consent; + +pub fn label(c: Consent) -> &'static [u8] { + match c { + Consent::Idle => b"", + Consent::Typing(_, 0) => b"code is on the console; type it", + Consent::Typing(..) => b"typing code, Enter to confirm", + Consent::Granted => b"this machine will run what it installs", + Consent::Refused => b"enrolment refused", + } +} diff --git a/userland/capsule_app_store/src/store/ui/counter.rs b/userland/capsule_app_store/src/store/ui/counter.rs new file mode 100644 index 0000000000..b8f836b1e8 --- /dev/null +++ b/userland/capsule_app_store/src/store/ui/counter.rs @@ -0,0 +1,34 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! "N listed", built without a formatter because this is `no_std`. + +/// Right-aligned in a fixed field so the head band does not reflow as the +/// count changes. +pub fn listed(n: usize) -> [u8; 16] { + let mut out = *b" 0 listed "; + let mut at = 8; + let mut left = n; + loop { + at -= 1; + out[at] = b'0' + (left % 10) as u8; + left /= 10; + if left == 0 || at == 0 { + break; + } + } + out +} diff --git a/userland/capsule_app_store/src/store/ui/detail.rs b/userland/capsule_app_store/src/store/ui/detail.rs new file mode 100644 index 0000000000..68ce3d5e49 --- /dev/null +++ b/userland/capsule_app_store/src/store/ui/detail.rs @@ -0,0 +1,54 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . +//! The selected listing: what it is, who stands behind it, and whether this +//! machine will run it. + +use nonos_app_skeleton::PaintBuffer; + +use crate::store::state::State; +use crate::store::theme::{ACCENT, FOREGROUND, MUTED, PANE_BG, TITLE}; +use super::hex::short; +use super::metrics::{BODY_PX, DETAIL_PAD, SMALL_PX, TITLE_PX}; +use super::text; +use super::wrap::wrap; + +pub fn paint(state: &State, fb: &mut PaintBuffer, x: u32, y: u32, w: u32, h: u32) { + fb.fill_rect(x, y, w, h, PANE_BG); + let left = x + DETAIL_PAD; + let room = w.saturating_sub(DETAIL_PAD * 2); + let Some(listing) = state.current() else { + text::line(fb, left, y as i32 + DETAIL_PAD as i32, b"Nothing selected", MUTED, BODY_PX); + return; + }; + let mut top = y as i32 + DETAIL_PAD as i32; + text::line(fb, left, top, &listing.name, TITLE, TITLE_PX); + top += 32; + + if let Some(d) = &state.detail { + text::line(fb, left, top, &d.publisher, ACCENT, SMALL_PX); + top += 26; + for line in wrap(&d.description, room, SMALL_PX).iter().take(4) { + text::line(fb, left, top, line, FOREGROUND, SMALL_PX); + top += 20; + } + top += 10; + } + + top = super::standing::paint(fb, state, left, top); + text::line(fb, left, top, b"measurement", MUTED, SMALL_PX); + top += 20; + text::line(fb, left, top, &short(&listing.measurement), MUTED, SMALL_PX); +} diff --git a/userland/capsule_app_store/src/store/ui/frame.rs b/userland/capsule_app_store/src/store/ui/frame.rs new file mode 100644 index 0000000000..0c431d4192 --- /dev/null +++ b/userland/capsule_app_store/src/store/ui/frame.rs @@ -0,0 +1,49 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! One frame: ground, head, tabs, list, detail, status. + +use nonos_app_skeleton::PaintBuffer; + +use crate::store::state::State; +use crate::store::theme::BACKGROUND; + +use super::metrics::{CARD_GAP, CARD_H, DETAIL_W, PAD_X, STATUS_H}; +use super::{chrome, detail, geometry, rows, scrollbar, status}; + +pub fn frame(state: &mut State, fb: &mut PaintBuffer) { + fb.clear(BACKGROUND); + state.fb_w = fb.width; + state.fb_h = fb.height; + chrome::head(fb, state); + chrome::tabs(fb, state); + let top = geometry::list_top(); + + let bottom = fb.height.saturating_sub(STATUS_H + PAD_X); + let pane_h = bottom.saturating_sub(top); + state.rows = (pane_h / (CARD_H + CARD_GAP)).max(1) as usize; + // Clamped here because this is where the row count is known. + state.clamp_scroll(); + + let list_w = geometry::list_w(fb.width); + rows::paint(state, fb, PAD_X, top, list_w, state.rows); + let total = state.visible().len(); + scrollbar::paint(fb, PAD_X, top, list_w, state.rows, total, state.scroll); + + let detail_x = PAD_X + list_w + PAD_X; + detail::paint(state, fb, detail_x, top, DETAIL_W, pane_h); + status::paint(fb, state); +} diff --git a/userland/capsule_app_store/src/store/ui/gates.rs b/userland/capsule_app_store/src/store/ui/gates.rs new file mode 100644 index 0000000000..ec55841f19 --- /dev/null +++ b/userland/capsule_app_store/src/store/ui/gates.rs @@ -0,0 +1,53 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! Why the selected listing can or cannot be installed. + +use nonos_app_skeleton::PaintBuffer; + +use crate::store::market::{Readiness, GATES}; +use crate::store::theme::{ACCENT, DANGER, MUTED, OK}; +use crate::store::verdict::Verdict; + +use super::metrics::{BODY_PX, GATE_ROW_H, SMALL_PX}; +use super::text; + +/// The column the verdicts line up in, left of the pane's right edge by +/// enough that the longest label above still clears it. +const MARK_X: u32 = 190; + +pub fn paint(fb: &mut PaintBuffer, x: u32, mut top: i32, r: &Readiness) { + let verdict = Verdict::of(r); + let hue = match verdict { + Verdict::Ready => OK, + Verdict::Installed => ACCENT, + Verdict::Blocked => DANGER, + }; + text::line(fb, x, top, verdict.label(), hue, BODY_PX); + top += 24; + if verdict == Verdict::Installed { + // The package gate below will read as a failure. + text::line(fb, x, top, b"in this image; nothing to fetch", MUTED, SMALL_PX); + } + top += 24; + for (label, pass) in GATES.iter().zip(r.gates.iter()) { + let mark: &[u8] = if *pass { b"pass" } else { b"fail" }; + let hue = if *pass { OK } else { DANGER }; + text::line(fb, x, top, label, MUTED, SMALL_PX); + text::line(fb, x + MARK_X, top, mark, hue, SMALL_PX); + top += GATE_ROW_H as i32; + } +} diff --git a/userland/capsule_app_store/src/store/ui/geometry.rs b/userland/capsule_app_store/src/store/ui/geometry.rs new file mode 100644 index 0000000000..168827bd5b --- /dev/null +++ b/userland/capsule_app_store/src/store/ui/geometry.rs @@ -0,0 +1,65 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! Where the list is. + +use super::metrics::{ + ACTION_H, ACTION_W, CARD_GAP, CARD_H, CARD_PAD, DETAIL_W, HEAD_H, PAD_TOP, PAD_X, TAB_H, + TAB_TO_LIST, +}; + +/// The y the first card starts at. +pub fn list_top() -> u32 { + PAD_TOP + HEAD_H + TAB_H + TAB_TO_LIST +} + +/// How wide the list is, given the surface. The detail pane and three +/// gutters take the rest. +pub fn list_w(fb_w: u32) -> u32 { + fb_w.saturating_sub(PAD_X * 3 + DETAIL_W) +} + +pub fn row_top(slot: usize) -> u32 { + list_top() + slot as u32 * (CARD_H + CARD_GAP) +} + +/// Which visible slot a point falls in. +pub fn slot_at(y: i32, rows: usize) -> Option { + let top = list_top() as i32; + if y < top { + return None; + } + let pitch = (CARD_H + CARD_GAP) as i32; + let slot = (y - top) / pitch; + let within = (y - top) % pitch; + match within < CARD_H as i32 && (slot as usize) < rows { + true => Some(slot as usize), + false => None, + } +} + +/// The action control inside a card whose box is `x, top, w`. +pub fn action_rect(x: u32, top: u32, w: u32) -> (u32, u32, u32, u32) { + let ax = x + w.saturating_sub(CARD_PAD + ACTION_W); + let ay = top + (CARD_H - ACTION_H) / 2; + (ax, ay, ACTION_W, ACTION_H) +} + +/// Whether a point is inside that control. +pub fn on_action(x: i32, y: i32, card_x: u32, top: u32, w: u32) -> bool { + let (ax, ay, aw, ah) = action_rect(card_x, top, w); + x >= ax as i32 && x < (ax + aw) as i32 && y >= ay as i32 && y < (ay + ah) as i32 +} diff --git a/userland/capsule_app_store/src/store/ui/hex.rs b/userland/capsule_app_store/src/store/ui/hex.rs new file mode 100644 index 0000000000..bdaea50799 --- /dev/null +++ b/userland/capsule_app_store/src/store/ui/hex.rs @@ -0,0 +1,28 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! A measurement, short enough to read off the screen. + +/// The first six bytes. +pub fn short(m: &[u8; 32]) -> [u8; 12] { + const HEX: &[u8; 16] = b"0123456789abcdef"; + let mut out = [0u8; 12]; + for i in 0..6 { + out[i * 2] = HEX[(m[i] >> 4) as usize]; + out[i * 2 + 1] = HEX[(m[i] & 0xF) as usize]; + } + out +} diff --git a/userland/capsule_app_store/src/store/ui/metrics.rs b/userland/capsule_app_store/src/store/ui/metrics.rs new file mode 100644 index 0000000000..8900c4fab9 --- /dev/null +++ b/userland/capsule_app_store/src/store/ui/metrics.rs @@ -0,0 +1,60 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . +// Every layout size in real pixels at 1x. The list is a column of cards +/* + * rather than table rows: a row of one name reads as a database dump, and the + * catalogue has a description and a publisher for every entry that were going + * unshown. + */ + +pub const WIN_W: u32 = 1000; +pub const WIN_H: u32 = 680; +pub const WIN_X: u32 = 188; +pub const WIN_Y: u32 = 52; + +pub const PAD_X: u32 = 22; +pub const PAD_TOP: u32 = 18; +pub const HEAD_H: u32 = 44; +pub const TAB_H: u32 = 32; +pub const TAB_GAP: u32 = 6; +pub const TAB_PAD_X: u32 = 14; +/// Air between the tab strip and the first card. +pub const TAB_TO_LIST: u32 = 10; + +/// A card holds two lines of text over a tile, so it is tall enough for +/// both plus the breathing room that stops a list looking like a table. +pub const CARD_H: u32 = 64; +pub const CARD_GAP: u32 = 6; +pub const CARD_PAD: u32 = 14; +pub const TILE: u32 = 36; +pub const TILE_GAP: u32 = 14; + +/// The action sits at a fixed width on the right so every card's button +/// starts at the same x and the eye can run straight down them. +pub const ACTION_W: u32 = 96; +pub const ACTION_H: u32 = 28; + +pub const DETAIL_W: u32 = 332; +pub const DETAIL_PAD: u32 = 18; +pub const GATE_ROW_H: u32 = 24; + +pub const STATUS_H: u32 = 28; +pub const STATUS_PAD_X: u32 = 16; + +pub const TITLE_PX: f32 = 23.0; +pub const NAME_PX: f32 = 18.0; +pub const BODY_PX: f32 = 17.0; +pub const SMALL_PX: f32 = 17.0; diff --git a/userland/capsule_app_store/src/store/ui/mod.rs b/userland/capsule_app_store/src/store/ui/mod.rs new file mode 100644 index 0000000000..9112fe8750 --- /dev/null +++ b/userland/capsule_app_store/src/store/ui/mod.rs @@ -0,0 +1,37 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! The painter. + +pub mod chrome; +mod card; +mod consent_text; +mod counter; +mod detail; +mod frame; +mod gates; +pub mod geometry; +mod hex; +pub mod metrics; +mod rows; +mod scrollbar; +mod searchbar; +mod standing; +mod status; +mod text; +mod wrap; + +pub use frame::frame; diff --git a/userland/capsule_app_store/src/store/ui/rows.rs b/userland/capsule_app_store/src/store/ui/rows.rs new file mode 100644 index 0000000000..8ee6983c89 --- /dev/null +++ b/userland/capsule_app_store/src/store/ui/rows.rs @@ -0,0 +1,49 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . +//! The catalogue, as a column of cards. + +use nonos_app_skeleton::PaintBuffer; + +use crate::store::state::State; +use crate::store::theme::MUTED; + +use super::card; +use super::metrics::{BODY_PX, CARD_GAP, CARD_H}; +use super::text; + +pub fn paint(state: &State, fb: &mut PaintBuffer, x: u32, y: u32, w: u32, rows: usize) { + let visible = state.visible(); + if visible.is_empty() { + text::line(fb, x, y as i32 + 10, empty_because(state), MUTED, BODY_PX); + return; + } + for slot in 0..rows { + let Some(&index) = visible.get(state.scroll + slot) else { break }; + let Some(listing) = state.listings.get(index) else { break }; + let top = y + slot as u32 * (CARD_H + CARD_GAP); + card::paint(fb, listing, x, top, w, state.scroll + slot == state.cursor); + } +} + +/// Why there is nothing to show. +fn empty_because(state: &State) -> &'static [u8] { + match (state.trouble, state.listings.is_empty()) { + (Some(why), _) => why, + (None, true) => b"the catalogue is empty", + (None, false) if !state.search.text().is_empty() => b"nothing matches that", + (None, false) => b"nothing under this tab", + } +} diff --git a/userland/capsule_app_store/src/store/ui/scrollbar.rs b/userland/capsule_app_store/src/store/ui/scrollbar.rs new file mode 100644 index 0000000000..72f35182c6 --- /dev/null +++ b/userland/capsule_app_store/src/store/ui/scrollbar.rs @@ -0,0 +1,45 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! How far down the list you are. + +use nonos_app_skeleton::PaintBuffer; + +use crate::store::theme::{CARD_SEL_EDGE, RULE}; + +use super::metrics::{CARD_GAP, CARD_H}; + +const W: u32 = 3; +const GAP: u32 = 6; + +pub fn paint(fb: &mut PaintBuffer, x: u32, y: u32, w: u32, rows: usize, total: usize, at: usize) { + if total <= rows || rows == 0 { + return; + } + let track_h = rows as u32 * (CARD_H + CARD_GAP) - CARD_GAP; + let left = x + w + GAP; + fb.fill_rect(left, y, W, track_h, RULE); + /* + * The thumb is the fraction of the list in view, never thinner than it can + * be seen: a two hundred entry catalogue would otherwise round it away to + * nothing at the very moment it is most wanted. + */ + let span = (track_h as usize * rows / total).max(12) as u32; + let travel = track_h.saturating_sub(span); + let most = total.saturating_sub(rows); + let top = y + (travel as usize * at.min(most) / most.max(1)) as u32; + fb.fill_rect(left, top, W, span, CARD_SEL_EDGE); +} diff --git a/userland/capsule_app_store/src/store/ui/searchbar.rs b/userland/capsule_app_store/src/store/ui/searchbar.rs new file mode 100644 index 0000000000..106bb334e5 --- /dev/null +++ b/userland/capsule_app_store/src/store/ui/searchbar.rs @@ -0,0 +1,53 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! The search field, in the head band. + +use nonos_app_skeleton::PaintBuffer; + +use crate::store::search::Search; +use crate::store::theme::{ACCENT, CARD_BG, MUTED, TITLE}; + +use super::metrics::{HEAD_H, PAD_TOP, SMALL_PX}; +use super::text; + +const W: u32 = 260; +const H: u32 = 26; +const PAD: u32 = 10; + +/// Paints the field and reports how much width it took, so the head +/// can put its count to the left of it rather than underneath. +pub fn paint(fb: &mut PaintBuffer, search: &Search, right: u32) -> u32 { + if !search.active && search.text().is_empty() { + return 0; + } + let x = right.saturating_sub(W); + let y = PAD_TOP + (HEAD_H - H) / 2; + fb.fill_rect(x, y, W, H, CARD_BG); + if search.active { + fb.fill_rect(x, y + H - 2, W, 2, ACCENT); + } + let top = text::top_of(y as i32, H, SMALL_PX); + match search.text().is_empty() { + true => text::line(fb, x + PAD, top, b"type to search", MUTED, SMALL_PX), + false => text::line(fb, x + PAD, top, search.text(), TITLE, SMALL_PX), + } + if search.active { + let caret = x + PAD + text::width_of(search.text(), SMALL_PX) + 2; + fb.fill_rect(caret.min(x + W - 3), y + 5, 1, H - 10, ACCENT); + } + W +} diff --git a/userland/capsule_app_store/src/store/ui/standing.rs b/userland/capsule_app_store/src/store/ui/standing.rs new file mode 100644 index 0000000000..84248fb7ee --- /dev/null +++ b/userland/capsule_app_store/src/store/ui/standing.rs @@ -0,0 +1,50 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! Where the selected listing stands with this machine. + +use nonos_app_skeleton::PaintBuffer; + +use crate::store::state::State; +use crate::store::theme::{ACCENT, DANGER, MUTED, OK}; +use crate::store::verdict::Verdict; + +use super::gates; +use super::metrics::{BODY_PX, GATE_ROW_H, SMALL_PX}; +use super::text; + +/// Paints and returns the y to carry on from. +pub fn paint(fb: &mut PaintBuffer, state: &State, left: u32, mut top: i32) -> i32 { + match state.ready { + Some(r) => { + let v = Verdict::of(&r); + let hue = match v { + Verdict::Ready => OK, + Verdict::Installed => ACCENT, + Verdict::Blocked => DANGER, + }; + text::line(fb, left, top, v.sentence(), hue, BODY_PX); + top += 30; + gates::paint(fb, left, top, &r); + top += 6 * GATE_ROW_H as i32 + 14; + } + None => { + text::line(fb, left, top, b"checking", MUTED, SMALL_PX); + top += 26; + } + } + top +} diff --git a/userland/capsule_app_store/src/store/ui/status.rs b/userland/capsule_app_store/src/store/ui/status.rs new file mode 100644 index 0000000000..672f84d7ba --- /dev/null +++ b/userland/capsule_app_store/src/store/ui/status.rs @@ -0,0 +1,43 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! The strip along the bottom: what the keys do, and what the last +//! install request was told. + +use nonos_app_skeleton::PaintBuffer; + +use crate::store::state::State; +use crate::store::theme::{ACCENT, MUTED, RULE, STATUS_BG}; + +use super::metrics::{SMALL_PX, STATUS_H, STATUS_PAD_X}; +use super::text; + +pub fn paint(fb: &mut PaintBuffer, state: &State) { + let y = state.fb_h.saturating_sub(STATUS_H); + fb.fill_rect(0, y, state.fb_w, STATUS_H, STATUS_BG); + fb.fill_rect(0, y, state.fb_w, 1, RULE); + let top = text::top_of(y as i32, STATUS_H, SMALL_PX); + let keys: &[u8] = b"up/down select Enter install e enrol r refresh Esc close"; + text::line(fb, STATUS_PAD_X, top, keys, MUTED, SMALL_PX); + // The answer to the last request sits opposite the keys. + let right = state.fb_w.saturating_sub(STATUS_PAD_X); + let consent = super::consent_text::label(state.consent); + if !consent.is_empty() { + text::right(fb, right, top, consent, ACCENT, SMALL_PX); + } else if let Some(asked) = state.asked { + text::right(fb, right, top, asked.label(), ACCENT, SMALL_PX); + } +} diff --git a/userland/capsule_app_store/src/store/ui/text.rs b/userland/capsule_app_store/src/store/ui/text.rs new file mode 100644 index 0000000000..f31afda24f --- /dev/null +++ b/userland/capsule_app_store/src/store/ui/text.rs @@ -0,0 +1,51 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! Text placement. Every string this window draws goes through here so a +//! painter and a hit test cannot disagree about where a line sits. + +use nonos_app_skeleton::PaintBuffer; +use nonos_toolkit::font::ttf::line_height; + +fn valid(bytes: &[u8]) -> &str { + core::str::from_utf8(bytes).unwrap_or("") +} + +/// The rasteriser takes a signed baseline box and drops pixels outside the +/// target, so a scrolled line needs no clamping of its own. +pub fn line(fb: &mut PaintBuffer, x: u32, top: i32, bytes: &[u8], argb: u32, px: f32) -> i32 { + fb.text_ttf(x as i32, top, valid(bytes), argb, px) +} + +pub fn width(fb: &PaintBuffer, bytes: &[u8], px: f32) -> u32 { + fb.measure_ttf(valid(bytes), px).max(0) as u32 +} + +/// The same advance sum without a surface. +pub fn width_of(bytes: &[u8], px: f32) -> u32 { + nonos_toolkit::paint::measure_ttf(valid(bytes), px).max(0) as u32 +} + +pub fn right(fb: &mut PaintBuffer, right_x: u32, top: i32, bytes: &[u8], argb: u32, px: f32) { + let w = width(fb, bytes, px); + line(fb, right_x.saturating_sub(w), top, bytes, argb, px); +} + +/// `text_ttf` takes the top of the line box, so centring one line inside a +/// box is the caller's job. Painter and hit test both come through here. +pub fn top_of(y: i32, h: u32, px: f32) -> i32 { + y + (h.saturating_sub(line_height(px).max(1) as u32) / 2) as i32 +} diff --git a/userland/capsule_app_store/src/store/ui/wrap.rs b/userland/capsule_app_store/src/store/ui/wrap.rs new file mode 100644 index 0000000000..a8b44318b7 --- /dev/null +++ b/userland/capsule_app_store/src/store/ui/wrap.rs @@ -0,0 +1,42 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . +//! Breaking a description to the width it has. + +use alloc::vec::Vec; + +use super::text::width_of; + +pub fn wrap(text: &[u8], room: u32, px: f32) -> Vec> { + let mut out: Vec> = Vec::new(); + let mut line: Vec = Vec::new(); + for word in text.split(|b| *b == b' ').filter(|w| !w.is_empty()) { + let mut candidate = line.clone(); + if !candidate.is_empty() { + candidate.push(b' '); + } + candidate.extend_from_slice(word); + if width_of(&candidate, px) > room && !line.is_empty() { + out.push(core::mem::take(&mut line)); + line.extend_from_slice(word); + } else { + line = candidate; + } + } + if !line.is_empty() { + out.push(line); + } + out +} diff --git a/userland/capsule_app_store/src/store/verdict.rs b/userland/capsule_app_store/src/store/verdict.rs new file mode 100644 index 0000000000..327a106526 --- /dev/null +++ b/userland/capsule_app_store/src/store/verdict.rs @@ -0,0 +1,61 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! What a listing's gate vector actually means for the user. + +use crate::store::market::Readiness; + +/// The package gate's position in the vector the capsule returns. +const PACKAGE_GATE: usize = 1; + +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum Verdict { + Ready, + /// Every gate passes except the one asking for something to fetch. + /// That is what a capsule already in the image looks like. + Installed, + Blocked, +} + +impl Verdict { + pub fn of(r: &Readiness) -> Verdict { + if r.install_ready { + return Verdict::Ready; + } + let others = r.gates.iter().enumerate().all(|(i, ok)| *ok || i == PACKAGE_GATE); + match others && !r.gates[PACKAGE_GATE] { + true => Verdict::Installed, + false => Verdict::Blocked, + } + } + + pub fn label(self) -> &'static [u8] { + match self { + Verdict::Ready => b"Install", + Verdict::Installed => b"Installed", + Verdict::Blocked => b"Blocked", + } + } + + /// The same verdict as something to read rather than a word to decode. + pub fn sentence(self) -> &'static [u8] { + match self { + Verdict::Ready => b"Ready to install on this machine", + Verdict::Installed => b"Already in this image, nothing to fetch", + Verdict::Blocked => b"This machine will not run it yet", + } + } +} diff --git a/userland/capsule_desktop_shell/src/render/icons.rs b/userland/capsule_desktop_shell/src/render/icons.rs index 950e964645..ac07ab454b 100644 --- a/userland/capsule_desktop_shell/src/render/icons.rs +++ b/userland/capsule_desktop_shell/src/render/icons.rs @@ -44,6 +44,7 @@ fn icon_bytes(icon: LauncherIcon) -> &'static [u8] { LauncherIcon::Calculator => IconId::Calc, LauncherIcon::Clock => IconId::Clock, LauncherIcon::Snake => IconId::Snake, + LauncherIcon::Store => IconId::Store, LauncherIcon::Wallet => IconId::Wallet, LauncherIcon::Browser => IconId::Browser, LauncherIcon::ImageViewer => IconId::ImageViewer, diff --git a/userland/capsule_desktop_shell/src/state/apps.rs b/userland/capsule_desktop_shell/src/state/apps.rs index c057d9bf69..9591d14b6b 100644 --- a/userland/capsule_desktop_shell/src/state/apps.rs +++ b/userland/capsule_desktop_shell/src/state/apps.rs @@ -25,6 +25,7 @@ pub enum LauncherIcon { Calculator, Clock, Snake, + Store, Wallet, Browser, ImageViewer, @@ -38,7 +39,7 @@ pub struct LauncherApp { pub service: &'static [u8], } -pub const LAUNCHER_APPS: [LauncherApp; 12] = [ +pub const LAUNCHER_APPS: [LauncherApp; 13] = [ LauncherApp { icon: LauncherIcon::Terminal, label: b"Terminal", service: b"app.terminal" }, LauncherApp { icon: LauncherIcon::FileManager, label: b"Files", service: b"app.file_manager" }, LauncherApp { icon: LauncherIcon::TextEditor, label: b"Editor", service: b"app.text_editor" }, @@ -49,6 +50,7 @@ pub const LAUNCHER_APPS: [LauncherApp; 12] = [ service: b"app.process_manager", }, LauncherApp { icon: LauncherIcon::About, label: b"About", service: b"app.about" }, + LauncherApp { icon: LauncherIcon::Store, label: b"Marketplace", service: b"app.store" }, LauncherApp { icon: LauncherIcon::Calculator, label: b"Calculator", From bd5704eb5b24ba011e6fe5c723c6ca3d313e9c0c Mon Sep 17 00:00:00 2001 From: eKisNonos Date: Sun, 20 Sep 2026 12:00:00 +0200 Subject: [PATCH 002/244] market: one release codec, and check a release before offering it Release encode, decode and signing move into marketplace_abi, so the tool that writes the index and the capsule that reads it share a codec instead of having one each. install_ready checks arch and readiness against the running image rather than the index, and adds a seventh gate for whether a release carries a zk trailer for its own measurement. The other six are signatures over the artifact. The catalogue generator reads what is on disk and writes JSON; the CLI encodes the binary the market ingests. Signing is a separate step with the operator seed, which is not in any build rule. --- .keys/marketplace_operator_ed25519.pub | 2 + .../market_capsule/client/install_ready.rs | 12 +- tools/nonos-market-catalogue | 352 ++++++++++++++++++ tools/nonos-market-sign-releases | 84 +++++ userland/capsule_market/Capsule.mk | 7 + userland/capsule_market/Cargo.toml | 19 +- userland/capsule_market/src/boot_index.rs | 54 +++ .../src/bootstrap_trust/keys.rs | 8 +- .../capsule_market/src/install_ready/arch.rs | 8 + .../src/install_ready/checks.rs | 19 +- userland/capsule_market/src/main.rs | 5 + .../handlers/install_ready/constants.rs | 2 +- .../handlers/install_ready/find_release.rs | 12 +- .../server/handlers/install_ready/handle.rs | 1 + .../marketplace_abi/src/codec/decode_index.rs | 4 +- .../src/codec/decode_release.rs | 2 + .../src/codec/encode_release.rs | 1 + .../src/codec/release_signing.rs | 8 +- .../marketplace_abi/src/types/readiness.rs | 19 +- userland/marketplace_abi/src/types/release.rs | 12 +- userland/toolkit/src/icons/all.rs | 3 +- userland/toolkit/src/icons/id.rs | 1 + userland/toolkit/src/icons/name.rs | 1 + userland/toolkit/src/icons/table.rs | 7 +- userland/toolkit/tests/host/icon_table.rs | 7 +- 25 files changed, 591 insertions(+), 59 deletions(-) create mode 100644 .keys/marketplace_operator_ed25519.pub create mode 100755 tools/nonos-market-catalogue create mode 100755 tools/nonos-market-sign-releases create mode 100644 userland/capsule_market/src/boot_index.rs diff --git a/.keys/marketplace_operator_ed25519.pub b/.keys/marketplace_operator_ed25519.pub new file mode 100644 index 0000000000..f9cd71ad83 --- /dev/null +++ b/.keys/marketplace_operator_ed25519.pub @@ -0,0 +1,2 @@ +§É-²M™çºî‹E mÃSÌÔ&"Á© +Rµ2}²æÑx \ No newline at end of file diff --git a/src/security/market_capsule/client/install_ready.rs b/src/security/market_capsule/client/install_ready.rs index 208bbfbf66..c949f1de59 100644 --- a/src/security/market_capsule/client/install_ready.rs +++ b/src/security/market_capsule/client/install_ready.rs @@ -14,12 +14,7 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -//! `OP_INSTALL_READY`. The userland capsule evaluates a hard AND -//! of nine install gates and returns the verdict as six bytes: -//! one for the AND-result followed by the per-check bits. The -//! kernel surfaces the result as a structured value so a caller -//! can short-circuit on the AND-result while still being able to -//! tell which gate refused. +//! `OP_INSTALL_READY`. use alloc::vec::Vec; @@ -30,7 +25,7 @@ use super::seq::next_request_id; use super::status_map::lift; use super::transport::round_trip; -const READINESS_LEN: usize = 6; +const READINESS_LEN: usize = 7; #[derive(Debug, Clone, Copy)] pub struct InstallReadiness { @@ -40,6 +35,8 @@ pub struct InstallReadiness { pub publisher_signature_present: bool, pub validation_passed: bool, pub arch_match: bool, + /// The release offers a zk trailer for its own measurement. + pub attestation_present: bool, } pub fn install_ready(listing_id: &str, release_id: &str) -> Result { @@ -66,5 +63,6 @@ pub fn install_ready(listing_id: &str, release_id: &str) -> Result. +"""Build the marketplace index JSON from what actually exists on disk. + +Three sources, one catalogue: + + nonos capsules this tree builds and signs, read out of the trust + directory so a listing exists only for something that has a + manifest and a certificate + + linux distribution packages, fetched and hashed here. A listing + asserts "these bytes, this hash", and a hash nobody computed + is not an assertion, so a package that has not been fetched + is not listed + + community submissions under nonos-data/marketplace/community, each a + JSON file naming a publisher key and a release the operator + has already validated + +The output is the plain JSON the `marketplace-index` CLI encodes and +signs. Nothing here signs anything: the operator key never touches a +generator. +""" + +import argparse +import json +import subprocess +import sys +import tarfile +import time +import urllib.request +from pathlib import Path + +MIRROR = "https://dl-cdn.alpinelinux.org/alpine" +BRANCHES = ("main", "community") +# 2: the release carries the hash of its zk trailer, and the publisher +# signature covers it. +SCHEMA = 2 + +# Capsules that are not applications. A driver or a transport is part of +# the system, cannot be installed or removed by a user, and listing one +# would offer an install that cannot happen. +NOT_APPS = ( + "driver_", + "net_", + "input_", + "proof_", + "std_proof", + "egui_proof", + "tokio-smoke", + "hello", + "gui_demo", + "boot_splash", + "compositor", + "wm", + "vfs", + "ramfs", + "keyring", + "policy", + "entropy", + "market", + "login", + "setup_wizard", + "toolkit", + "wallpaper", + "wallpaper_catalog", + "image_codec", + "audio_server", +) + +FREE = {"kind": "free", "amount_atomic": "0", "period_seconds": 0} +NOX = {"symbol": "NOX", "decimals": 18, "chain_id": 1, "contract_address": ""} + + +def blake3(data: bytes) -> str: + """BLAKE3-256, the hash every other artifact in this tree is named by. + + b3sum is what the signing tools use, so the digest in a listing is + the same one a person gets checking the artifact by hand. + """ + try: + done = subprocess.run( + ["b3sum", "--no-names", "--raw"], + input=data, stdout=subprocess.PIPE, check=True, + ) + except FileNotFoundError: + sys.exit("b3sum not found: install it, or the digests would be guesses") + return done.stdout[:32].hex() + + +def validation(note: str, validator: str, when_ms: int) -> dict: + return { + "status": "validated", + "note": note, + "validator_id": validator, + "validated_at_ms": when_ms, + } + + +def release(rid, manifest, package, url, arches, caps, note, validator, + when_ms, trailer=""): + return { + "release_id": rid, + "manifest_hash": manifest, + "package_hash": package, + "package_url": url, + "publisher_signature": "", + "supported_arches": arches, + "kernel_abi_min": 1, + "required_capabilities": caps, + "zk_trailer_hash": trailer, + "validation": validation(note, validator, when_ms), + } + + +def entry(listing, capsule_id, name, publisher, pubkey, text, releases): + return { + "listing_id": listing, + "capsule_id": capsule_id, + "name": name, + "publisher_name": publisher, + "publisher_pubkey": pubkey, + "publisher_eth_address": "00" * 20, + "description": text, + "price": FREE, + "token": NOX, + "releases": releases, + } + + +def is_app(slug: str) -> bool: + return not any(slug == n or slug.startswith(n) for n in NOT_APPS) + + +def nonos_entries(trust: Path, pubkey: str, when_ms: int) -> list: + """One listing per signed capsule that is an application.""" + out = [] + for manifest in sorted(trust.glob("*.manifest.bin")): + slug = manifest.name[: -len(".manifest.bin")] + if not is_app(slug): + continue + cert = trust / f"{slug}.nonos_id_cert.bin" + trailer = trust / f"{slug}.zk_trailer.bin" + if not cert.exists() or not trailer.exists(): + # No proof, no listing. An entry whose install is going to + # be refused at the spawn gate is worse than no entry: the + # refusal arrives after the download and reads like a bug. + print(f" skip {slug}: no trailer", file=sys.stderr) + continue + mhash = blake3(manifest.read_bytes()) + thash = blake3(trailer.read_bytes()) + out.append( + entry( + f"nonos.app.{slug}", + blake3(cert.read_bytes()), + slug.replace("_", " "), + "NONOS", + pubkey, + f"NONOS capsule {slug}, signed and attested in this image.", + [ + release( + f"{slug}@builtin", + mhash, + mhash, + "", + ["x86_64-nonos"], + [], + "built and signed by this tree", + "nonos.build", + when_ms, + thash, + ) + ], + ) + ) + return out + + +def apkindex(cache: Path, release_name: str, arch: str, branch: str) -> dict: + """name -> record, from one branch's APKINDEX.""" + base = f"{MIRROR}/{release_name}/{branch}/{arch}" + tgz = cache / f"{branch}-APKINDEX.tar.gz" + if not tgz.exists(): + tgz.parent.mkdir(parents=True, exist_ok=True) + with urllib.request.urlopen(f"{base}/APKINDEX.tar.gz", timeout=120) as r: + tgz.write_bytes(r.read()) + with tarfile.open(tgz) as t: + raw = t.extractfile("APKINDEX").read().decode(errors="replace") + out, rec = {}, {} + for line in raw.split("\n"): + if not line: + if rec.get("P"): + rec["base"] = base + out[rec["P"]] = rec + rec = {} + continue + if len(line) > 2 and line[1] == ":": + rec[line[0]] = line[2:] + return out + + +def linux_trailer(cache: Path, apk: str) -> str: + """The trailer an operator minted for this package, if they have. + + A Linux package carries no NONOS proof of its own, so somebody has + to enrol its measurement before the machine will run it. Until that + has happened there is nothing truthful to put in the field, and the + listing is held back rather than shipped as ready. + """ + at = cache / f"{apk}.zk_trailer.bin" + return blake3(at.read_bytes()) if at.exists() else "" + + +def linux_entries(cache, names, release_name, arch, pubkey, when_ms) -> list: + """One listing per package, fetched so its hash is a measured fact.""" + table = {} + for branch in BRANCHES: + table.update(apkindex(cache, release_name, arch, branch)) + out = [] + for name in names: + rec = table.get(name) + if rec is None: + print(f" skip {name}: not in the index", file=sys.stderr) + continue + apk = f"{rec['P']}-{rec['V']}.apk" + url = f"{rec['base']}/{apk}" + blob = cache / apk + if not blob.exists(): + try: + with urllib.request.urlopen(url, timeout=180) as r: + blob.write_bytes(r.read()) + except OSError as e: + print(f" skip {name}: {e}", file=sys.stderr) + continue + raw = blob.read_bytes() + digest = blake3(raw) + out.append( + entry( + f"linux.{rec['P']}", + digest, + rec["P"], + f"Alpine {release_name}", + pubkey, + rec.get("T", "").strip() or f"Linux package {rec['P']}", + [ + release( + f"{rec['P']}@{rec['V']}", + digest, + digest, + url, + ["x86_64-linux"], + ["ForeignExec"], + f"fetched and hashed at {len(raw)} bytes", + "nonos.operator.linux", + when_ms, + linux_trailer(cache, apk), + ) + ], + ) + ) + return out + + +def community_entries(where: Path) -> list: + """Submissions, passed through as the operator validated them.""" + out = [] + for path in sorted(where.glob("*.json")): + item = json.loads(path.read_text()) + if not item.get("listing_id", "").startswith("community."): + sys.exit(f"{path}: listing_id must start with 'community.'") + out.append(item) + return out + + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("--out", type=Path, required=True) + ap.add_argument("--trust", type=Path, default=Path("nonos-data/trust/capsules")) + ap.add_argument("--community", type=Path, + default=Path("nonos-data/marketplace/community")) + ap.add_argument("--cache", type=Path, default=Path("target/market-cache")) + ap.add_argument("--operator-pubkey", required=True, + help="hex Ed25519 key the index will be signed under") + ap.add_argument("--alpine-release", default="v3.21") + ap.add_argument("--alpine-arch", default="x86_64") + ap.add_argument("--linux-package", action="append", default=[], + help="repeatable; a package to fetch, hash and list") + ap.add_argument("--linux-list", type=Path, + help="file of package names, one per line") + ap.add_argument("--serial", type=int, required=True) + ap.add_argument("--no-nonos", action="store_true") + args = ap.parse_args() + + when_ms = int(time.time() * 1000) + key = args.operator_pubkey.removeprefix("0x").lower() + if len(key) != 64: + sys.exit("--operator-pubkey must be 32 hex bytes") + + entries = [] + if not args.no_nonos and args.trust.is_dir(): + found = nonos_entries(args.trust, key, when_ms) + print(f"nonos: {len(found)} capsules", file=sys.stderr) + entries += found + + names = list(args.linux_package) + if args.linux_list and args.linux_list.exists(): + names += [ + line.split("#", 1)[0].strip() + for line in args.linux_list.read_text().splitlines() + if line.split("#", 1)[0].strip() + ] + if names: + args.cache.mkdir(parents=True, exist_ok=True) + found = linux_entries(args.cache, names, args.alpine_release, + args.alpine_arch, key, when_ms) + print(f"linux: {len(found)} of {len(names)} packages", file=sys.stderr) + entries += found + + if args.community.is_dir(): + found = community_entries(args.community) + print(f"community: {len(found)} submissions", file=sys.stderr) + entries += found + + index = { + "schema_version": SCHEMA, + "operator_id": "nonos.marketplace.v1", + "published_at_ms": when_ms, + "serial": args.serial, + "entries": entries, + } + args.out.parent.mkdir(parents=True, exist_ok=True) + args.out.write_text(json.dumps(index, indent=2) + "\n") + print(f"{args.out}: {len(entries)} listings, serial {args.serial}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/nonos-market-sign-releases b/tools/nonos-market-sign-releases new file mode 100755 index 0000000000..6d532a9ccf --- /dev/null +++ b/tools/nonos-market-sign-releases @@ -0,0 +1,84 @@ +#!/usr/bin/env python3 +# NONOS Operating System +# Copyright (C) 2026 NONOS Contributors +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +"""Attach a publisher signature to every release in the index. + +The generator cannot do this: it never touches a key. The CLI signs one +release per invocation, deliberately, so the loop lives here rather than +inside a command that would then be holding a key across a whole +catalogue. + +Every release this signs is one whose publisher is the operator, which +is true for the capsules this tree builds and for packages the operator +fetched and hashed itself. A third-party submission arrives already +signed by its own publisher and is skipped: re-signing it here would +replace the submitter's authority with the operator's, quietly. +""" + +import argparse +import json +import subprocess +import sys +from pathlib import Path + + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("--cli", type=Path, required=True) + ap.add_argument("--index", type=Path, required=True) + ap.add_argument("--key-file", type=Path, required=True) + ap.add_argument("--operator-pubkey", required=True) + args = ap.parse_args() + + key = args.operator_pubkey.removeprefix("0x").lower() + doc = json.loads(args.index.read_text()) + todo = [] + for entry in doc["entries"]: + if entry["publisher_pubkey"].lower() != key: + continue + for rel in entry["releases"]: + if not rel.get("publisher_signature"): + todo.append((entry["listing_id"], rel["release_id"])) + + signed = 0 + for listing_id, release_id in todo: + done = subprocess.run( + [ + str(args.cli), "sign-release", + "--in", str(args.index), + "--listing-id", listing_id, + "--release-id", release_id, + "--key-file", str(args.key_file), + "--out", str(args.index), + ], + stdout=subprocess.PIPE, stderr=subprocess.PIPE, + ) + if done.returncode != 0: + print(f" {listing_id}: {done.stderr.decode().strip()}", file=sys.stderr) + continue + signed += 1 + + doc = json.loads(args.index.read_text()) + total = sum(len(e["releases"]) for e in doc["entries"]) + have = sum( + 1 for e in doc["entries"] for r in e["releases"] if r.get("publisher_signature") + ) + print(f"signed {signed}; {have} of {total} releases now carry a signature") + return 0 if have == total else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/userland/capsule_market/Capsule.mk b/userland/capsule_market/Capsule.mk index 3782855c50..5545c423a3 100644 --- a/userland/capsule_market/Capsule.mk +++ b/userland/capsule_market/Capsule.mk @@ -17,4 +17,11 @@ CAPSULE_REPLY_ENDPOINT := reply:4107:endpoint.4294967303 CAPSULE_REQUIRED_CAPS := 0x39 CAPSULE_KERNEL_MIRROR := src/security/market_capsule +# The capsule embeds the signed catalogue, so a newer index has to +# rebuild it. Cargo tracks the include_bytes! path, but the make rule +# lists only sources, and without this line a freshly signed catalogue +# was silently left out of the image: the build succeeded, the boot +# succeeded, and the machine served the previous one. +CAPSULE_EXTRA_DEPS := nonos-data/marketplace/index.bin + include nonos-mk/capsule.mk diff --git a/userland/capsule_market/Cargo.toml b/userland/capsule_market/Cargo.toml index 94640ad717..78ac05118d 100644 --- a/userland/capsule_market/Cargo.toml +++ b/userland/capsule_market/Cargo.toml @@ -22,16 +22,17 @@ name = "market" path = "src/main.rs" [dependencies] -nonos_ed25519 = { path = "../nonos_ed25519" } nonos_libc = { package = "nonos_userland_libc", path = "../libc" } nonos_marketplace_abi = { path = "../marketplace_abi" } +nonos_app_skeleton = { path = "../app_skeleton" } [features] default = [] -# Replaces the default `CryptoVerifier`, which verifies in this -# process with nonos_ed25519, with `RejectAll`. Every signed index -# ends up refused, which keeps install readiness honest in a build -# that is meant to accept nothing. +# Replaces the default `CryptoVerifier` (which routes to +# capsule_crypto through the kernel's `CryptoEd25519Verify` +# syscall) with `RejectAll`. Useful when the kernel image does +# not embed capsule_crypto; every signed index ends up refused, +# which keeps install readiness honest in the offline build. offline-verify = [] [profile.release] panic = "abort" @@ -39,11 +40,3 @@ opt-level = 2 lto = false debug = false strip = true - -# Matches every sibling capsule. Without it `cargo check` on the host fails -# with "unwinding panics are not supported without std", so this crate could -# not be checked outside a target build. -[profile.dev] -panic = "abort" -opt-level = 0 -debug = true diff --git a/userland/capsule_market/src/boot_index.rs b/userland/capsule_market/src/boot_index.rs new file mode 100644 index 0000000000..0ede60340d --- /dev/null +++ b/userland/capsule_market/src/boot_index.rs @@ -0,0 +1,54 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! The catalogue this capsule starts with. + +use nonos_app_skeleton::clients::vfs::read_file; +use nonos_libc::mk_getpid; + +use crate::ingest::load_verified; +use crate::store::Store; +use crate::verify::Verifier; + +/// Where an operator drops a catalogue newer than the built-in one. +const PATH: &[u8] = b"/nonos/marketplace/index.bin"; + +/// A catalogue of every listing a machine could offer is still small next to +/// one package. +const MAX: u32 = 8 << 20; + +/// The catalogue this image shipped with. Empty when the build had none, +/// which reads as "no baseline" rather than as a failure. +static BASELINE: &[u8] = include_bytes!("../../../nonos-data/marketplace/index.bin"); + +pub fn load(store: &mut Store, verifier: &V) { + if !BASELINE.is_empty() { + take(store, verifier, BASELINE); + } + if let Ok(blob) = read_file(mk_getpid(), PATH, MAX) { + take(store, verifier, &blob); + } +} + +fn take(store: &mut Store, verifier: &V, blob: &[u8]) { + /* + * A serial no newer than the one already held is the ordinary outcome, not + * an error: it means no operator has published since this image was built. + */ + if let Ok(v) = load_verified(blob, verifier, store.last_serial()) { + store.install(v.index, v.signature_verified, v.publisher_signature_verified); + } +} diff --git a/userland/capsule_market/src/bootstrap_trust/keys.rs b/userland/capsule_market/src/bootstrap_trust/keys.rs index 777e184243..b03b429a5d 100644 --- a/userland/capsule_market/src/bootstrap_trust/keys.rs +++ b/userland/capsule_market/src/bootstrap_trust/keys.rs @@ -14,9 +14,13 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . +//! The operators whose catalogues this machine will read. + +/// Marketplace operator, v1. pub(super) const NOX_OPERATOR_V1: [u8; 32] = [ - 0x29, 0x5f, 0x84, 0xc9, 0x7c, 0x62, 0x01, 0x3c, 0x43, 0x8b, 0xca, 0x3d, 0x81, 0xc1, 0x80, 0x98, - 0x1b, 0x9f, 0x0a, 0x04, 0x3b, 0xa1, 0xfa, 0xe2, 0x54, 0xad, 0x0e, 0x12, 0xea, 0x8e, 0x07, 0x63, + 0xa7, 0xc9, 0x2d, 0xb2, 0x4d, 0x99, 0xe7, 0xba, 0xee, 0x8b, 0x45, 0xa0, 0x6d, 0xc3, 0x53, 0xcc, + 0xd4, 0x14, 0x26, 0x22, 0xc1, 0xa9, 0x0a, 0x52, 0xb5, 0x32, 0x7d, 0xb2, 0xe6, 0xd1, 0x78, 0x11, ]; +// One entry, deliberately. pub(super) const TRUSTED_OPERATORS: &[[u8; 32]] = &[NOX_OPERATOR_V1]; diff --git a/userland/capsule_market/src/install_ready/arch.rs b/userland/capsule_market/src/install_ready/arch.rs index 7f0ca8f16d..47c0020d6d 100644 --- a/userland/capsule_market/src/install_ready/arch.rs +++ b/userland/capsule_market/src/install_ready/arch.rs @@ -17,6 +17,14 @@ #[cfg(target_arch = "x86_64")] pub const RUNNING_ARCH: &str = "x86_64-nonos"; +/// The other triple this machine runs: a Linux binary of the same hardware +/// arch, hosted by the personality capsule. +#[cfg(target_arch = "x86_64")] +pub const HOSTED_ARCH: &str = "x86_64-linux"; + +#[cfg(not(target_arch = "x86_64"))] +pub const HOSTED_ARCH: &str = ""; + #[cfg(target_arch = "aarch64")] pub const RUNNING_ARCH: &str = "aarch64-nonos"; diff --git a/userland/capsule_market/src/install_ready/checks.rs b/userland/capsule_market/src/install_ready/checks.rs index 7c9675c958..e16dba2982 100644 --- a/userland/capsule_market/src/install_ready/checks.rs +++ b/userland/capsule_market/src/install_ready/checks.rs @@ -16,10 +16,14 @@ use nonos_marketplace_abi::{CapsuleRelease, InstallReadiness, ValidationStatus}; -use super::arch::RUNNING_ARCH; +use super::arch::{HOSTED_ARCH, RUNNING_ARCH}; pub const RUNNING_KERNEL_ABI: u32 = 1; +/// Releases carrying this arch are distribution packages the personality +/// hosts. +const LOCAL_ARCH: &str = HOSTED_ARCH; + pub fn evaluate( signature_verified: bool, release: &CapsuleRelease, @@ -30,8 +34,15 @@ pub fn evaluate( let package_url_present = !release.package_url.is_empty(); let package_hash_present = release.package_hash.iter().any(|&b| b != 0); let manifest_hash_present = release.manifest_hash.iter().any(|&b| b != 0); - let arch_match = release.supported_arches.iter().any(|a| a.as_str() == RUNNING_ARCH); + let runs_here = |a: &alloc::string::String| { + a.as_str() == RUNNING_ARCH || (!HOSTED_ARCH.is_empty() && a.as_str() == HOSTED_ARCH) + }; + let arch_match = release.supported_arches.iter().any(runs_here); let kernel_abi_compatible = release.kernel_abi_min <= RUNNING_KERNEL_ABI; + // Everything above this line is somebody's word. + let minted_locally = release.supported_arches.iter().any(|a| a.as_str() == LOCAL_ARCH); + let ships_proof = release.zk_trailer_hash.iter().any(|&b| b != 0); + let attestation_present = ships_proof || minted_locally; let install_ready = index_signature_valid && validation_passed @@ -40,7 +51,8 @@ pub fn evaluate( && manifest_hash_present && publisher_signature_verified && arch_match - && kernel_abi_compatible; + && kernel_abi_compatible + && attestation_present; InstallReadiness { install_ready, @@ -49,5 +61,6 @@ pub fn evaluate( publisher_signature_present: publisher_signature_verified, validation_passed, arch_match: arch_match && kernel_abi_compatible, + attestation_present, } } diff --git a/userland/capsule_market/src/main.rs b/userland/capsule_market/src/main.rs index acf2e9fe00..ef5c13927f 100644 --- a/userland/capsule_market/src/main.rs +++ b/userland/capsule_market/src/main.rs @@ -19,6 +19,7 @@ extern crate alloc; +mod boot_index; mod bootstrap_trust; mod ingest; mod install_ready; @@ -46,5 +47,9 @@ pub unsafe extern "C" fn _start() -> ! { let mut store = Store::empty(); let verifier = DefaultVerifier; + // Before the first query arrives, so a client never sees an empty + // catalogue on a machine that has one. + boot_index::load(&mut store, &verifier); + server::run(&mut store, &verifier); } diff --git a/userland/capsule_market/src/server/handlers/install_ready/constants.rs b/userland/capsule_market/src/server/handlers/install_ready/constants.rs index dce0b67089..a24d162638 100644 --- a/userland/capsule_market/src/server/handlers/install_ready/constants.rs +++ b/userland/capsule_market/src/server/handlers/install_ready/constants.rs @@ -14,4 +14,4 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -pub(super) const READINESS_LEN: usize = 6; +pub(super) const READINESS_LEN: usize = 7; diff --git a/userland/capsule_market/src/server/handlers/install_ready/find_release.rs b/userland/capsule_market/src/server/handlers/install_ready/find_release.rs index 9d68acb58e..abcc297cc0 100644 --- a/userland/capsule_market/src/server/handlers/install_ready/find_release.rs +++ b/userland/capsule_market/src/server/handlers/install_ready/find_release.rs @@ -25,10 +25,12 @@ pub(super) fn find_release<'a>( if e.listing_id != listing_id { return None; } - e.releases - .iter() - .enumerate() - .find(|(_, r)| r.release_id == release_id) - .map(|(release_index, r)| (entry_index, release_index, r)) + // An empty id asks for the default, which the index defines as the + // first release. + let wanted = match release_id.is_empty() { + true => e.releases.first().map(|r| (0usize, r)), + false => e.releases.iter().enumerate().find(|(_, r)| r.release_id == release_id), + }; + wanted.map(|(release_index, r)| (entry_index, release_index, r)) }) } diff --git a/userland/capsule_market/src/server/handlers/install_ready/handle.rs b/userland/capsule_market/src/server/handlers/install_ready/handle.rs index 6eaa63456c..99be95d450 100644 --- a/userland/capsule_market/src/server/handlers/install_ready/handle.rs +++ b/userland/capsule_market/src/server/handlers/install_ready/handle.rs @@ -49,5 +49,6 @@ pub(crate) fn handle(store: &Store, body: &[u8], req: &Request, tx: &mut [u8]) { slot[3] = verdict.publisher_signature_present as u8; slot[4] = verdict.validation_passed as u8; slot[5] = verdict.arch_match as u8; + slot[6] = verdict.attestation_present as u8; reply_with_body(tx, req, READINESS_LEN); } diff --git a/userland/marketplace_abi/src/codec/decode_index.rs b/userland/marketplace_abi/src/codec/decode_index.rs index 9b05a1299a..cdc53ebee3 100644 --- a/userland/marketplace_abi/src/codec/decode_index.rs +++ b/userland/marketplace_abi/src/codec/decode_index.rs @@ -25,7 +25,9 @@ use super::strings::{bounded_bytes, bounded_count, bounded_string}; use crate::limits::{MAX_ENTRIES, MAX_INDEX_BLOB, MAX_PUBLISHER, MAX_SIGNATURE}; use crate::types::{MarketplaceEntry, MarketplaceIndex}; -const SUPPORTED_SCHEMA: u32 = 1; +// 2: every release carries the hash of the zk trailer binding its own +// measurement to the enrolled set, and the publisher signature covers it. +const SUPPORTED_SCHEMA: u32 = 2; pub struct DecodedIndex<'a> { pub index: MarketplaceIndex, diff --git a/userland/marketplace_abi/src/codec/decode_release.rs b/userland/marketplace_abi/src/codec/decode_release.rs index a9e705b90f..9c74dc40be 100644 --- a/userland/marketplace_abi/src/codec/decode_release.rs +++ b/userland/marketplace_abi/src/codec/decode_release.rs @@ -50,6 +50,7 @@ pub(super) fn read(r: &mut Reader<'_>) -> Result { required_capabilities.push(bounded_string(r, MAX_PUBLISHER)?); } + let zk_trailer_hash = r.fixed::<32>()?; let validation = decode_validation::read(r)?; Ok(CapsuleRelease { @@ -61,6 +62,7 @@ pub(super) fn read(r: &mut Reader<'_>) -> Result { supported_arches, kernel_abi_min, required_capabilities, + zk_trailer_hash, validation, }) } diff --git a/userland/marketplace_abi/src/codec/encode_release.rs b/userland/marketplace_abi/src/codec/encode_release.rs index 6bebe2ea82..4a21730dae 100644 --- a/userland/marketplace_abi/src/codec/encode_release.rs +++ b/userland/marketplace_abi/src/codec/encode_release.rs @@ -37,5 +37,6 @@ pub(super) fn write(w: &mut Writer<'_>, release: &CapsuleRelease) { w.lp_string(cap); } + w.fixed(&release.zk_trailer_hash); encode_validation::write(w, &release.validation); } diff --git a/userland/marketplace_abi/src/codec/release_signing.rs b/userland/marketplace_abi/src/codec/release_signing.rs index c06d228d26..82f7d85cf4 100644 --- a/userland/marketplace_abi/src/codec/release_signing.rs +++ b/userland/marketplace_abi/src/codec/release_signing.rs @@ -14,9 +14,7 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -//! Canonical bytes a publisher signs for one release. Publisher -//! authority covers artifact identity and requested authority. -//! Marketplace validation is signed by the enclosing operator index. +//! Canonical bytes a publisher signs for one release. extern crate alloc; @@ -25,7 +23,8 @@ use alloc::vec::Vec; use super::writer::Writer; use crate::types::CapsuleRelease; -const RELEASE_SIGNING_DOMAIN: &[u8] = b"NONOS.marketplace.release.v1"; +// v2 because the signed bytes gained the trailer hash below. +const RELEASE_SIGNING_DOMAIN: &[u8] = b"NONOS.marketplace.release.v2"; pub fn release_signing_bytes(release: &CapsuleRelease) -> Vec { let mut out = Vec::new(); @@ -46,5 +45,6 @@ pub fn release_signing_bytes(release: &CapsuleRelease) -> Vec { for cap in &release.required_capabilities { w.lp_string(cap); } + w.fixed(&release.zk_trailer_hash); out } diff --git a/userland/marketplace_abi/src/types/readiness.rs b/userland/marketplace_abi/src/types/readiness.rs index 8b7fdd4c7d..b0d286b77e 100644 --- a/userland/marketplace_abi/src/types/readiness.rs +++ b/userland/marketplace_abi/src/types/readiness.rs @@ -14,10 +14,8 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -//! Verdict the capsule emits when a caller asks "is this release -//! ready to install?". Five independent gates must all pass; the -//! report carries which ones tripped so a UI can explain the -//! refusal precisely. +//! Verdict the capsule emits when a caller asks "is this release ready to +//! install?". #[derive(Clone, Copy, PartialEq, Eq)] pub struct InstallReadiness { @@ -28,13 +26,14 @@ pub struct InstallReadiness { /// `package_url` is non-empty. pub package_url_present: bool, /// Publisher signature verifies against the listing pubkey. - /// The field name is kept for wire compatibility with earlier - /// six-byte readiness replies. pub publisher_signature_present: bool, /// Operator's `validation_status` is `Validated`. pub validation_passed: bool, /// Running kernel arch is in the release's `supported_arches`. pub arch_match: bool, + /// The release names a zk trailer binding its own measurement to the + /// enrolled set. + pub attestation_present: bool, } impl InstallReadiness { @@ -46,10 +45,11 @@ impl InstallReadiness { publisher_signature_present: false, validation_passed: false, arch_match: false, + attestation_present: false, } } - /// Compose a verdict from the five checks. `install_ready` is + /// Compose a verdict from the six checks. `install_ready` is /// the AND of the inputs; anything `false` blocks install. pub fn from_checks( index_signature_valid: bool, @@ -57,12 +57,14 @@ impl InstallReadiness { publisher_signature_verified: bool, validation_passed: bool, arch_match: bool, + attestation_present: bool, ) -> Self { let install_ready = index_signature_valid && package_url_present && publisher_signature_verified && validation_passed - && arch_match; + && arch_match + && attestation_present; Self { install_ready, index_signature_valid, @@ -70,6 +72,7 @@ impl InstallReadiness { publisher_signature_present: publisher_signature_verified, validation_passed, arch_match, + attestation_present, } } } diff --git a/userland/marketplace_abi/src/types/release.rs b/userland/marketplace_abi/src/types/release.rs index 8102b1c0c9..27f4719e68 100644 --- a/userland/marketplace_abi/src/types/release.rs +++ b/userland/marketplace_abi/src/types/release.rs @@ -14,10 +14,7 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -//! One concrete release of a marketplace entry. A release is the -//! signed unit the future capsule_installer fetches and verifies; -//! everything an installer needs to refuse a stale, mistargeted, or -//! tampered package lives here. +//! One concrete release of a marketplace entry. extern crate alloc; @@ -39,10 +36,6 @@ pub struct CapsuleRelease { /// the entry is index-only (no fetchable artifact). pub package_url: String, /// Publisher's Ed25519 signature over `release_signing_bytes`. - /// This covers the artifact hashes, URL, supported arches, - /// kernel ABI, and requested capabilities. It deliberately does - /// not cover the marketplace-operator validation report, which - /// is signed by the enclosing index. pub publisher_signature: Vec, /// Architecture triples the release supports (e.g. /// "x86_64-nonos"). At least one entry is required. @@ -51,6 +44,9 @@ pub struct CapsuleRelease { pub kernel_abi_min: u32, /// Capability names the manifest requests at install time. pub required_capabilities: Vec, + /// BLAKE3-256 of the zk trailer that proves this package's own measurement + /// is enrolled under the trust root the kernel enforces at spawn. + pub zk_trailer_hash: [u8; 32], /// Marketplace operator's validation report. pub validation: ValidationReport, } diff --git a/userland/toolkit/src/icons/all.rs b/userland/toolkit/src/icons/all.rs index 96b0b05b14..546133e3ed 100644 --- a/userland/toolkit/src/icons/all.rs +++ b/userland/toolkit/src/icons/all.rs @@ -17,7 +17,7 @@ use super::id::IconId; impl IconId { - pub const ALL: [IconId; 47] = [ + pub const ALL: [IconId; 48] = [ IconId::About, IconId::AudioPlayer, IconId::Browser, @@ -31,6 +31,7 @@ impl IconId { IconId::Processes, IconId::Settings, IconId::Snake, + IconId::Store, IconId::Terminal, IconId::VideoPlayer, IconId::Wallet, diff --git a/userland/toolkit/src/icons/id.rs b/userland/toolkit/src/icons/id.rs index a89ec54229..9fdb511f84 100644 --- a/userland/toolkit/src/icons/id.rs +++ b/userland/toolkit/src/icons/id.rs @@ -29,6 +29,7 @@ pub enum IconId { Processes, Settings, Snake, + Store, Terminal, VideoPlayer, Wallet, diff --git a/userland/toolkit/src/icons/name.rs b/userland/toolkit/src/icons/name.rs index 572e46af8d..398205a5cf 100644 --- a/userland/toolkit/src/icons/name.rs +++ b/userland/toolkit/src/icons/name.rs @@ -32,6 +32,7 @@ impl IconId { IconId::Processes => "processes", IconId::Settings => "settings", IconId::Snake => "snake", + IconId::Store => "store", IconId::Terminal => "terminal", IconId::VideoPlayer => "video_player", IconId::Wallet => "wallet", diff --git a/userland/toolkit/src/icons/table.rs b/userland/toolkit/src/icons/table.rs index e3c10cdd4a..2b37aa5ef9 100644 --- a/userland/toolkit/src/icons/table.rs +++ b/userland/toolkit/src/icons/table.rs @@ -14,10 +14,8 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -/// One 8-bit coverage mask per icon, ordered to match `IconId`. The host test -/// compares every entry against the file `IconId::name` points at, so the -/// ordinal indexing below is proven rather than assumed. -pub(super) const MASKS: [&[u8]; 47] = [ +/// One 8-bit coverage mask per icon, ordered to match `IconId`. +pub(super) const MASKS: [&[u8]; 48] = [ include_bytes!("../../../assets/icons/about.a8"), include_bytes!("../../../assets/icons/audio_player.a8"), include_bytes!("../../../assets/icons/browser.a8"), @@ -31,6 +29,7 @@ pub(super) const MASKS: [&[u8]; 47] = [ include_bytes!("../../../assets/icons/processes.a8"), include_bytes!("../../../assets/icons/settings.a8"), include_bytes!("../../../assets/icons/snake.a8"), + include_bytes!("../../../assets/icons/store.a8"), include_bytes!("../../../assets/icons/terminal.a8"), include_bytes!("../../../assets/icons/video_player.a8"), include_bytes!("../../../assets/icons/wallet.a8"), diff --git a/userland/toolkit/tests/host/icon_table.rs b/userland/toolkit/tests/host/icon_table.rs index 2fdbdbef2e..dbe6afd271 100644 --- a/userland/toolkit/tests/host/icon_table.rs +++ b/userland/toolkit/tests/host/icon_table.rs @@ -15,8 +15,11 @@ use mask::{dim, mask}; fn main() { let all = IconId::ALL; let mut fail = 0usize; - if all.len() != 42 { - println!("expected 42 icons, got {}", all.len()); + // Kept in step with IconId::ALL by hand, which is the point: a count that + // updated itself would not catch an icon added to one list and not the + // other. + if all.len() != 48 { + println!("expected 48 icons, got {}", all.len()); fail += 1; } for (i, a) in all.iter().enumerate() { From b1533e872fdb033b79eac53a0f7b01dc1c83a2ea Mon Sep 17 00:00:00 2001 From: senseix21 Date: Thu, 24 Sep 2026 22:02:16 +0600 Subject: [PATCH 003/244] fix(toolkit): ship the store icon mask the icon table includes IconId::Store points table.rs at assets/icons/store.a8, which only existed on the app-store branch, so every build of this branch failed to read it. Add the mask and its SVG source here so the table stands on its own. --- userland/assets/icons/store.a8 | Bin 0 -> 36864 bytes userland/assets/icons/store.svg | 1 + 2 files changed, 1 insertion(+) create mode 100644 userland/assets/icons/store.a8 create mode 100644 userland/assets/icons/store.svg diff --git a/userland/assets/icons/store.a8 b/userland/assets/icons/store.a8 new file mode 100644 index 0000000000000000000000000000000000000000..e086216b2c331c0e487616ce1ed05662085076cc GIT binary patch literal 36864 zcmeI5Z`Gqb5XEs22}r1fNI(J-kbndvL;@0!fP_du0^9DsPtW8JeVFMb;XP;gvS~WG z_cxcM<-=~bY4p+rGyzRO6VL=S0Zl*?&;&FAO+XXS1T+CnKoigeGyzRO6VL=S0Zrgs z0>%#G^4~QM)|^{)k1EqSzeksM-T$PA&PC|&m#(`UC;a0egL>2bA#C=Iz8-1T*<~p|OuDR0 ztw}cizTc?<0sfsD5aHjafqoZW_c+A%kbYgJah#U*u)x+juf*5n9~t~a;OykHJ#07l zBFld3Is*T%SL<>e%p{09!!Cohv^kb`$&_}v%7SyoCs5lVQm&Y?AaUE(Hc%9#BdvWLy0GtR8)yqT zPR8Ff(G95e3G@i@*N>S8Z?56WNATulFWSK^q45>37B`%}k&IT&5QTOH`;X>27zWyj zQZq(-{m1F5KerLtLFU2tD)6`N@niUjyn_D_+Jg=H^u4MF(+DbZaj^eD}0nh&3ip^TxaK&yk^PSN@OQx&s&q0H$*L}N9wi!06YdnO8Y04gCZ!N-hzNmV3?bGK5e76Vg;+yG2oz%ov4)5+ph+pj8X`iV z7(<9PM1%oNN+H$|5dy^+LaZSo3}{jcv4)5cD8>+C4H02LlTwH^M1(*wh7fCr2m_jw zLaZSo1d1_)SVKe@(4-V%4G|$wj3LAtBEov0YtRW%{Xi^HXc00W){`~<*I%5n+iob7+ zm0y=`f%PcU3;A~|Q<1E)0sb!JkNy>w@GeUDUC1x|muGFLQtk%W$hehc@Lc0Vo$Vpi zVJ>@wyMOo0_3v^Q^6zDSyLpK4<-gP#9e`&yez5QEE*aRtN1^SzaQi>`QmsV%`U&y& zX`23ey5Al8FK)E|UHC=Pfh+NS6F_G9M2n>rK0qB!77=;0G;R z^qXJ?)4y((>1SW!F8KMv|0L$pAOHC)&f3>ntO;lWnt&#t31|YEfF_^`XabsmCZGwN GOW+S$=4$!? literal 0 HcmV?d00001 diff --git a/userland/assets/icons/store.svg b/userland/assets/icons/store.svg new file mode 100644 index 0000000000..9db2b1c838 --- /dev/null +++ b/userland/assets/icons/store.svg @@ -0,0 +1 @@ + From 1d95cf3f67f489fe9780b4cf1a8ba48606fa9dd3 Mon Sep 17 00:00:00 2001 From: senseix21 Date: Thu, 24 Sep 2026 22:08:22 +0600 Subject: [PATCH 004/244] fix(store): end the search-bar match as a statement text::line returns the drawn width, so the bare match evaluated to i32 where the function body expects (), failing the capsule build. --- userland/capsule_app_store/src/store/ui/searchbar.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/userland/capsule_app_store/src/store/ui/searchbar.rs b/userland/capsule_app_store/src/store/ui/searchbar.rs index 106bb334e5..b029babfd3 100644 --- a/userland/capsule_app_store/src/store/ui/searchbar.rs +++ b/userland/capsule_app_store/src/store/ui/searchbar.rs @@ -44,7 +44,7 @@ pub fn paint(fb: &mut PaintBuffer, search: &Search, right: u32) -> u32 { match search.text().is_empty() { true => text::line(fb, x + PAD, top, b"type to search", MUTED, SMALL_PX), false => text::line(fb, x + PAD, top, search.text(), TITLE, SMALL_PX), - } + }; if search.active { let caret = x + PAD + text::width_of(search.text(), SMALL_PX) + 2; fb.fill_rect(caret.min(x + W - 3), y + 5, 1, H - 10, ACCENT); From 5d9e2d6ff7507ebe4bb4b3318f90d743c2d1f622 Mon Sep 17 00:00:00 2001 From: senseix21 Date: Thu, 24 Sep 2026 22:23:22 +0600 Subject: [PATCH 005/244] fix(market): track the signed index only when the tree has one nonos-data/marketplace/index.bin has no make rule, so naming it as a hard prerequisite failed every build on a checkout without it (CI: No rule to make target). Wrapping it in $(wildcard) keeps the rebuild on a newer catalogue where it exists and drops the prerequisite where it does not. --- userland/capsule_market/Capsule.mk | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/userland/capsule_market/Capsule.mk b/userland/capsule_market/Capsule.mk index 5545c423a3..cc66a62043 100644 --- a/userland/capsule_market/Capsule.mk +++ b/userland/capsule_market/Capsule.mk @@ -22,6 +22,6 @@ CAPSULE_KERNEL_MIRROR := src/security/market_capsule # lists only sources, and without this line a freshly signed catalogue # was silently left out of the image: the build succeeded, the boot # succeeded, and the machine served the previous one. -CAPSULE_EXTRA_DEPS := nonos-data/marketplace/index.bin +CAPSULE_EXTRA_DEPS := $(wildcard nonos-data/marketplace/index.bin) include nonos-mk/capsule.mk From d221a0d7d8dba43fe8878f13d837c1375caa20d5 Mon Sep 17 00:00:00 2001 From: senseix21 Date: Thu, 24 Sep 2026 22:53:27 +0600 Subject: [PATCH 006/244] fix(market): keep nonos_ed25519, which verify/crypto.rs still uses The branch's manifest predated the switch to in-process Ed25519 and dropped the dependency while verify/crypto.rs imports it, so the capsule failed with an unresolved import. Restore main's manifest and add only the app_skeleton dependency boot_index.rs needs. --- userland/capsule_market/Cargo.lock | 80 ++++++++++++++++++++++++++++-- userland/capsule_market/Cargo.toml | 18 +++++-- 2 files changed, 90 insertions(+), 8 deletions(-) diff --git a/userland/capsule_market/Cargo.lock b/userland/capsule_market/Cargo.lock index ccce193879..d24abf9bdf 100644 --- a/userland/capsule_market/Cargo.lock +++ b/userland/capsule_market/Cargo.lock @@ -2,6 +2,41 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "ab_glyph" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01c0457472c38ea5bd1c3b5ada5e368271cb550be7a4ca4a0b4634e9913f6cc2" +dependencies = [ + "ab_glyph_rasterizer", + "libm", + "owned_ttf_parser", +] + +[[package]] +name = "ab_glyph_rasterizer" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" +dependencies = [ + "libm", +] + +[[package]] +name = "core_maths" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77745e017f5edba1a9c1d854f6f3a52dac8a12dd5af5d2f54aecf61e43d80d30" +dependencies = [ + "libm", +] + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + [[package]] name = "linked_list_allocator" version = "0.10.6" @@ -20,10 +55,19 @@ dependencies = [ "scopeguard", ] +[[package]] +name = "nonos_app_skeleton" +version = "0.3.0" +dependencies = [ + "nonos_toolkit", + "nonos_userland_libc", +] + [[package]] name = "nonos_capsule_market" version = "0.3.0" dependencies = [ + "nonos_app_skeleton", "nonos_ed25519", "nonos_marketplace_abi", "nonos_userland_libc", @@ -33,18 +77,27 @@ dependencies = [ name = "nonos_ed25519" version = "0.1.0" dependencies = [ - "nonos_hd", + "nonos_hash", "spin", ] [[package]] -name = "nonos_hd" -version = "0.3.0" +name = "nonos_hash" +version = "0.1.0" [[package]] name = "nonos_marketplace_abi" version = "0.3.0" +[[package]] +name = "nonos_toolkit" +version = "0.3.0" +dependencies = [ + "ab_glyph", + "nonos_userland_libc", + "spin", +] + [[package]] name = "nonos_userland_libc" version = "0.3.0" @@ -52,6 +105,15 @@ dependencies = [ "linked_list_allocator", ] +[[package]] +name = "owned_ttf_parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36820e9051aca1014ddc75770aab4d68bc1e9e632f0f5627c4086bc216fb583b" +dependencies = [ + "ttf-parser", +] + [[package]] name = "scopeguard" version = "1.2.0" @@ -63,6 +125,9 @@ name = "spin" version = "0.9.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" +dependencies = [ + "lock_api", +] [[package]] name = "spinning_top" @@ -72,3 +137,12 @@ checksum = "5b9eb1a2f4c41445a3a0ff9abc5221c5fcd28e1f13cd7c0397706f9ac938ddb0" dependencies = [ "lock_api", ] + +[[package]] +name = "ttf-parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" +dependencies = [ + "core_maths", +] diff --git a/userland/capsule_market/Cargo.toml b/userland/capsule_market/Cargo.toml index 78ac05118d..ebd837c5be 100644 --- a/userland/capsule_market/Cargo.toml +++ b/userland/capsule_market/Cargo.toml @@ -22,17 +22,17 @@ name = "market" path = "src/main.rs" [dependencies] +nonos_ed25519 = { path = "../nonos_ed25519" } nonos_libc = { package = "nonos_userland_libc", path = "../libc" } nonos_marketplace_abi = { path = "../marketplace_abi" } nonos_app_skeleton = { path = "../app_skeleton" } [features] default = [] -# Replaces the default `CryptoVerifier` (which routes to -# capsule_crypto through the kernel's `CryptoEd25519Verify` -# syscall) with `RejectAll`. Useful when the kernel image does -# not embed capsule_crypto; every signed index ends up refused, -# which keeps install readiness honest in the offline build. +# Replaces the default `CryptoVerifier`, which verifies in this +# process with nonos_ed25519, with `RejectAll`. Every signed index +# ends up refused, which keeps install readiness honest in a build +# that is meant to accept nothing. offline-verify = [] [profile.release] panic = "abort" @@ -40,3 +40,11 @@ opt-level = 2 lto = false debug = false strip = true + +# Matches every sibling capsule. Without it `cargo check` on the host fails +# with "unwinding panics are not supported without std", so this crate could +# not be checked outside a target build. +[profile.dev] +panic = "abort" +opt-level = 0 +debug = true From ad49205d55dc662854fe3ff6fc0290e4e5f6fd21 Mon Sep 17 00:00:00 2001 From: eKisNonos Date: Sat, 26 Sep 2026 13:35:16 +0000 Subject: [PATCH 007/244] abi: publish the twenty-five hardware and time gates the kernel applies Twenty-five syscalls were published as caps = ["valid_token"] while the cap table demands a hardware, dev-root or time capability. Twenty-two take any one of Admin or a hardware capability, now published with caps_any; the dev-root and time calls need one capability, published with caps. --- abi/syscalls.toml | 57 ++++++++++++++++++++++++++--------------------- 1 file changed, 32 insertions(+), 25 deletions(-) diff --git a/abi/syscalls.toml b/abi/syscalls.toml index 9ad8bcd219..85e19c0033 100644 --- a/abi/syscalls.toml +++ b/abi/syscalls.toml @@ -1,3 +1,10 @@ +# Each [desc.TAG] block publishes the gate the kernel applies to that call. +# `caps = [...]` means all of the listed capabilities: a token needs every one. +# `caps_any = [...]` means any one of them is enough. A block carries one field +# or the other, never both. `caps = ["valid_token"]` means any valid token. +# scripts/check_syscall_caps.py compares each block against the kernel's cap +# table and fails on any difference, so a gate here is what the kernel enforces. + version = 3 abi = "nonos-sys-v1" @@ -282,7 +289,7 @@ ret = {type="i64"} [desc.MDLS] nr = 0x534C444D -caps = ["valid_token"] +caps_any = ["DeviceEnum", "Admin"] args = [{name="class",type="u32",dir="in"},{name="buf",type="u8*",dir="out"},{name="count",type="usize",dir="in",max="@limits.max_devices"}] ret = {type="i64"} @@ -294,85 +301,85 @@ ret = {type="i64"} [desc.MDCL] nr = 0x4C43444D -caps = ["valid_token"] +caps_any = ["Driver", "Admin"] args = [{name="device_id",type="u64",dir="in"}] ret = {type="i64"} [desc.MDRL] nr = 0x4C52444D -caps = ["valid_token"] +caps_any = ["Driver", "Admin"] args = [{name="device_id",type="u64",dir="in"}] ret = {type="i64"} [desc.MMMP] nr = 0x504D4D4D -caps = ["valid_token"] +caps_any = ["Mmio", "Admin"] args = [{name="device_id",type="u64",dir="in"},{name="bar_index",type="u32",dir="in"},{name="length",type="usize",dir="in"},{name="flags",type="u32",dir="in"},{name="vaddr_out",type="u64*",dir="out"}] ret = {type="i64"} [desc.MMUM] nr = 0x4D554D4D -caps = ["valid_token"] +caps_any = ["Mmio", "Admin"] args = [{name="device_id",type="u64",dir="in"},{name="vaddr",type="u64",dir="in"}] ret = {type="i64"} [desc.MIRB] nr = 0x4252494D -caps = ["valid_token"] +caps_any = ["Irq", "Admin"] args = [{name="device_id",type="u64",dir="in"},{name="claim_epoch",type="u64",dir="in"},{name="vector_count",type="u32",dir="in"},{name="flags",type="u32",dir="in"},{name="slot_base_out",type="u32*",dir="out"}] ret = {type="i64"} [desc.MIRU] nr = 0x5552494D -caps = ["valid_token"] +caps_any = ["Irq", "Admin"] args = [{name="device_id",type="u64",dir="in"},{name="slot_base",type="u32",dir="in"},{name="vector_count",type="u32",dir="in"}] ret = {type="i64"} [desc.MIRA] nr = 0x4152494D -caps = ["valid_token"] +caps_any = ["Irq", "Admin"] args = [{name="slot",type="u32",dir="in"}] ret = {type="i64"} [desc.MIRP] nr = 0x5052494D -caps = ["valid_token"] +caps_any = ["Irq", "Admin"] args = [{name="slot",type="u32",dir="in"},{name="seq_out",type="u64*",dir="out"}] ret = {type="i64"} [desc.MDMM] nr = 0x4D4D444D -caps = ["valid_token"] +caps_any = ["Dma", "Admin"] args = [{name="device_id",type="u64",dir="in"},{name="claim_epoch",type="u64",dir="in"},{name="length",type="usize",dir="in"},{name="flags",type="u32",dir="in"},{name="grant_out",type="u8*",dir="out"}] ret = {type="i64"} [desc.MDMU] nr = 0x554D444D -caps = ["valid_token"] +caps_any = ["Dma", "Admin"] args = [{name="device_id",type="u64",dir="in"},{name="grant_id",type="u64",dir="in"}] ret = {type="i64"} [desc.MPGT] nr = 0x5447504D -caps = ["valid_token"] +caps_any = ["Pio", "Admin"] args = [{name="device_id",type="u64",dir="in"},{name="claim_epoch",type="u64",dir="in"},{name="port_base",type="u16",dir="in"},{name="port_count",type="u16",dir="in"},{name="grant_out",type="u32*",dir="out"}] ret = {type="i64"} [desc.MPRD] nr = 0x4452504D -caps = ["valid_token"] +caps_any = ["Pio", "Admin"] args = [{name="grant_id",type="u32",dir="in"},{name="offset",type="u16",dir="in"},{name="width",type="u8",dir="in"},{name="value_out",type="u32*",dir="out"}] ret = {type="i64"} [desc.MPWR] nr = 0x5257504D -caps = ["valid_token"] +caps_any = ["Pio", "Admin"] args = [{name="grant_id",type="u32",dir="in"},{name="offset",type="u16",dir="in"},{name="width",type="u8",dir="in"},{name="value",type="u32",dir="in"}] ret = {type="i64"} [desc.MPRL] nr = 0x4C52504D -caps = ["valid_token"] +caps_any = ["Pio", "Admin"] args = [{name="grant_id",type="u32",dir="in"}] ret = {type="i64"} @@ -420,13 +427,13 @@ ret = {type="i64"} [desc.MIEP] nr = 0x5045494D -caps = ["valid_token"] +caps_any = ["InputSource", "Irq", "Admin"] args = [{name="event_kind",type="u32",dir="in"},{name="payload",type="u8*",dir="in"},{name="len",type="usize",dir="in"}] ret = {type="i64"} [desc.MIED] nr = 0x4445494D -caps = ["valid_token"] +caps_any = ["InputSource", "Admin"] args = [{name="buf",type="u8*",dir="out"},{name="cap",type="usize",dir="in"},{name="count_out",type="u32*",dir="out"}] ret = {type="i64"} @@ -520,13 +527,13 @@ ret = {type="i64"} [desc.MIEW] nr = 0x5745494D -caps = ["valid_token"] +caps_any = ["InputSource", "Admin"] args = [{name="last_seq",type="u64",dir="in"},{name="timeout_ms",type="u64",dir="in"},{name="out_ptr",type="u64",dir="out"}] ret = {type="i64"} [desc.MIRW] nr = 0x5752494D -caps = ["valid_token"] +caps_any = ["Irq", "Admin"] args = [{name="grant_id",type="u64",dir="in"},{name="last_seq",type="u64",dir="in"},{name="timeout_ms",type="u64",dir="in"},{name="out_ptr",type="u64",dir="out"}] ret = {type="i64"} @@ -562,13 +569,13 @@ ret = {type="i64"} [desc.MPCR] nr = 0x5243504D -caps = ["valid_token"] +caps_any = ["Driver", "Admin"] args = [{name="device_id",type="u64",dir="in"},{name="claim_epoch",type="u64",dir="in"},{name="offset",type="u32",dir="in"},{name="width",type="u32",dir="in"}] ret = {type="i64"} [desc.MPCW] nr = 0x5743504D -caps = ["valid_token"] +caps_any = ["Driver", "Admin"] args = [{name="device_id",type="u64",dir="in"},{name="claim_epoch",type="u64",dir="in"},{name="offset",type="u32",dir="in"},{name="value",type="u32",dir="in"}] ret = {type="i64"} @@ -640,7 +647,7 @@ ret = {type="i64"} [desc.MSPI] nr = 0x4950534D -caps = ["valid_token"] +caps_any = ["SpawnWindow", "Admin"] args = [{name="name_ptr",type="u64",dir="in"},{name="name_len",type="u64",dir="in"}] ret = {type="i64"} @@ -670,7 +677,7 @@ ret = {type="i64"} [desc.MTAD] nr = 0x4441544D -caps = ["valid_token"] +caps = ["TimeSet"] args = [{name="correct_ms",type="u64",dir="in"}] ret = {type="i64"} @@ -718,12 +725,12 @@ ret = {type="i64"} [desc.MDRC] nr = 0x4352444D -caps = ["valid_token"] +caps = ["EnrolDevRoot"] args = [{name="answer",type="u64",dir="in"}] ret = {type="i64"} [desc.MDRQ] nr = 0x5152444D -caps = ["valid_token"] +caps = ["EnrolDevRoot"] args = [{name="root_ptr",type="u64",dir="in"}] ret = {type="i64"} From dc64cb153db5057a63094b0f3eb4ace6ac66d088 Mon Sep 17 00:00:00 2001 From: eKisNonos Date: Sat, 26 Sep 2026 13:37:37 +0000 Subject: [PATCH 008/244] abi: publish MkDevRootLocal as EnrolDevRoot, the gate the kernel applies The cap table gates MDRO with MDRQ and MDRC on can_enrol_dev_root. The old syscall caps check cannot resolve that predicate and wants valid_token, so this fails it until abi/caps-check-fail-closed lands; the fixed check passes. --- abi/syscalls.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/abi/syscalls.toml b/abi/syscalls.toml index fc0a979760..bc899db3cb 100644 --- a/abi/syscalls.toml +++ b/abi/syscalls.toml @@ -684,7 +684,7 @@ ret = {type="i64"} [desc.MDRO] nr = 0x4F52444D -caps = ["valid_token"] +caps = ["EnrolDevRoot"] args = [] ret = {type="i64"} From 6a2b83b4df1ab11aed615ff478ffd51945e9fd67 Mon Sep 17 00:00:00 2001 From: eKisNonos Date: Sat, 26 Sep 2026 13:45:37 +0000 Subject: [PATCH 009/244] mk: pick the qemu accelerator from the host, and say which in doctor Every lane was pinned to -accel hvf -cpu host, which only macOS has, so no Linux host could boot an image. KVM when /dev/kvm opens read-write, hvf on macOS, TCG otherwise, the rule the boot matrix already uses; the display and audio backends follow the host too. --- Makefile | 5 +++++ mk/10-qemu.mk | 31 +++++++++++++++++++++++++++++-- mk/20-build.mk | 2 +- mk/30-image.mk | 2 +- mk/40-run.mk | 16 ++++++++-------- 5 files changed, 44 insertions(+), 12 deletions(-) diff --git a/Makefile b/Makefile index 508961b9b6..d8310ce179 100644 --- a/Makefile +++ b/Makefile @@ -197,6 +197,11 @@ doctor: else echo " MISS rust $(TOOLCHAIN) (rustup toolchain install $(TOOLCHAIN))"; ok=0; fi; \ if [ -n "$(OVMF)" ] && [ -f "$(OVMF)" ]; then echo " ok OVMF $(OVMF)"; \ else echo " MISS OVMF UEFI firmware"; ok=0; fi; \ + if ! $(QEMU) -accel help 2>/dev/null | grep -qx "$(QEMU_ACCEL)"; then \ + echo " MISS accel $(QEMU_ACCEL) ($(QEMU) does not offer it; set QEMU_ACCEL)"; ok=0; \ + elif [ "$(QEMU_ACCEL)" = tcg ]; then \ + echo " ok accel tcg (no /dev/kvm or hvf: the CPU is emulated and boots are slow)"; \ + else echo " ok accel $(QEMU_ACCEL)"; fi; \ echo; \ if [ $$ok = 1 ]; then echo " This host can build and boot NONOS."; \ else \ diff --git a/mk/10-qemu.mk b/mk/10-qemu.mk index e0334a47da..89aebe8b11 100644 --- a/mk/10-qemu.mk +++ b/mk/10-qemu.mk @@ -44,6 +44,23 @@ endif QEMU_MEM := 2G QEMU_CPU := max +# The accelerator follows the host, by the rule scripts/bootmatrix/qemu.py +# already uses: KVM when /dev/kvm opens read-write, hvf on macOS, TCG +# otherwise. TCG emulates the processor, so `-cpu host` names nothing there and +# it gets `max`, which carries RDRAND. Set QEMU_ACCEL to override. +ifeq ($(shell [ -r /dev/kvm ] && [ -w /dev/kvm ] && echo y),y) + QEMU_ACCEL_AUTO := kvm +else ifeq ($(UNAME_S),Darwin) + QEMU_ACCEL_AUTO := hvf +else + QEMU_ACCEL_AUTO := tcg +endif +QEMU_ACCEL ?= $(QEMU_ACCEL_AUTO) +ifeq ($(QEMU_ACCEL),tcg) + QEMU_ACCEL_ARGS := -accel tcg -cpu $(QEMU_CPU) +else + QEMU_ACCEL_ARGS := -accel $(QEMU_ACCEL) -cpu host,+rdrand,+rdseed +endif QEMU_SMP ?= 4 QEMU_HOST_SSH_PORT ?= 2222 QEMU_HOST_HTTP_PORT ?= 8080 @@ -84,19 +101,29 @@ QEMU_YRES ?= 1080 # QEMU_GL=1 swaps the display device for virtio-vga-gl (modern transport, # virglrenderer backend) so the guest can negotiate the 3D command set; the # cocoa display then needs a GL context. Default stays the plain 2D device. +# cocoa exists only on macOS; elsewhere the window is gtk. +ifeq ($(UNAME_S),Darwin) +QEMU_UI := cocoa +else +QEMU_UI := gtk +endif ifeq ($(QEMU_GL),1) QEMU_GPU := -device virtio-vga-gl,xres=$(QEMU_XRES),yres=$(QEMU_YRES) -QEMU_DISPLAY := cocoa,gl=es,zoom-to-fit=on +QEMU_DISPLAY ?= $(QEMU_UI),gl=es,zoom-to-fit=on else QEMU_GPU := -device virtio-vga,disable-modern=on,vectors=0,edid=on,xres=$(QEMU_XRES),yres=$(QEMU_YRES) -QEMU_DISPLAY := cocoa,zoom-to-fit=on +QEMU_DISPLAY ?= $(QEMU_UI),zoom-to-fit=on endif # Keyboard/mouse via the q35 i8042 (PS/2). USB HID interrupt-IN transfers # are not serviced under macOS hvf, so usb-kbd/usb-mouse never deliver input # there; the xHCI controller stays for the USB stack/storage paths. QEMU_USB := -device qemu-xhci,id=xhci QEMU_RNG := -device virtio-rng-pci +ifeq ($(UNAME_S),Darwin) QEMU_AUDIODEV ?= coreaudio +else +QEMU_AUDIODEV ?= none +endif QEMU_AUDIO := -audiodev $(QEMU_AUDIODEV),id=snd0 -device intel-hda -device hda-duplex,audiodev=snd0 # Software TPM 2.0 for measured boot. The guest reaches it by direct MMIO at diff --git a/mk/20-build.mk b/mk/20-build.mk index 3fa720bde4..9598229885 100644 --- a/mk/20-build.mk +++ b/mk/20-build.mk @@ -1002,7 +1002,7 @@ nonos-mk-run-from-config: $(QEMU_BLK_IMG) $(QEMU_OVMF_VARS_RW) @test -f $(ESP_DIR)/EFI/nonos/kernel.bin || { echo "no image; run 'make from-config' first"; exit 1; } @mkdir -p $(dir $(QEMU_SERIAL_LOG)) @echo "Booting the from-config image in QEMU (serial log: $(QEMU_SERIAL_LOG))..." - @$(QEMU) -m $(QEMU_MEM) -accel hvf -cpu host,+rdrand,+rdseed -smp 1 -machine q35 \ + @$(QEMU) -m $(QEMU_MEM) $(QEMU_ACCEL_ARGS) -smp 1 -machine q35 \ -drive "format=raw,file=fat:rw:$(ESP_DIR)" \ -drive if=pflash,format=raw,readonly=on,file="$(OVMF)" \ $(QEMU_BLK) $(QEMU_GPU) $(QEMU_NET) $(QEMU_USB) $(QEMU_RNG) \ diff --git a/mk/30-image.mk b/mk/30-image.mk index 26062e9721..de594db26e 100644 --- a/mk/30-image.mk +++ b/mk/30-image.mk @@ -43,7 +43,7 @@ nonos-mk-iso: nonos-mk-esp # partition table and ESP filesystem the USB actually boots from. nonos-mk-usb-run: nonos-mk-usb-img $(QEMU_OVMF_VARS_RW) @echo "Booting $(USB_IMG) as a real GPT disk..." - @$(QEMU) -m $(QEMU_MEM) -accel hvf -cpu host,+rdrand,+rdseed -smp 1 -machine q35 \ + @$(QEMU) -m $(QEMU_MEM) $(QEMU_ACCEL_ARGS) -smp 1 -machine q35 \ -drive format=raw,file=$(USB_IMG) \ -drive if=pflash,format=raw,unit=0,readonly=on,file="$(OVMF)" \ -drive if=pflash,format=raw,unit=1,file="$(QEMU_OVMF_VARS_RW)" \ diff --git a/mk/40-run.mk b/mk/40-run.mk index d801cf1a93..4c67d22fa0 100644 --- a/mk/40-run.mk +++ b/mk/40-run.mk @@ -96,7 +96,7 @@ nonos-mk-run: nonos-mk-swtpm-start nonos-mk-live-production-proof $(QEMU_BLK_IMG @echo " TPM: swtpm CRB" @echo " Quit: Ctrl+A then X" @rm -f "$(QEMU_QMP_SOCK)" - @$(QEMU) -m $(QEMU_MEM) -accel hvf -cpu host,+rdrand,+rdseed -smp 1 -machine q35 \ + @$(QEMU) -m $(QEMU_MEM) $(QEMU_ACCEL_ARGS) -smp 1 -machine q35 \ -drive "format=raw,file=fat:rw:$(ESP_DIR)" \ -drive if=pflash,format=raw,unit=0,readonly=on,file="$(OVMF)" \ -drive if=pflash,format=raw,unit=1,file="$(QEMU_OVMF_VARS_RW)" \ @@ -153,7 +153,7 @@ nonos-mk-run-wizard: nonos-mk-setup-wizard-esp $(QEMU_BLK_IMG) $(QEMU_OVMF_VARS_ @echo "Booting NONOS (first-boot setup wizard) in QEMU..." @echo " Network: $(QEMU_NET_DESC)" @echo " Drive it with the host keyboard; Quit: Ctrl+A then X" - @$(QEMU) -m $(QEMU_MEM) -accel hvf -cpu host,+rdrand,+rdseed -smp 1 -machine q35 \ + @$(QEMU) -m $(QEMU_MEM) $(QEMU_ACCEL_ARGS) -smp 1 -machine q35 \ -drive "format=raw,file=fat:rw:$(TARGET_DIR)/esp-setup-wizard" \ -drive if=pflash,format=raw,unit=0,readonly=on,file="$(OVMF)" \ -drive if=pflash,format=raw,unit=1,file="$(QEMU_OVMF_VARS_RW)" \ @@ -175,7 +175,7 @@ nonos-mk-run-serial: $(QEMU_BLK_IMG) $(QEMU_BLK_STORE_STAMP) $(call nonos_kernel_and_esp,nonos-mk-desktop-gui-prod) @echo "Booting NONOS serial console in QEMU..." @echo " Network: $(QEMU_NET_DESC)" - @$(QEMU) -m $(QEMU_MEM) -accel hvf -cpu host,+rdrand,+rdseed -smp 1 -machine q35 \ + @$(QEMU) -m $(QEMU_MEM) $(QEMU_ACCEL_ARGS) -smp 1 -machine q35 \ -drive "format=raw,file=fat:rw:$(ESP_DIR)" \ -drive if=pflash,format=raw,readonly=on,file="$(OVMF)" \ $(QEMU_BLK) $(QEMU_GPU) $(QEMU_NET) $(QEMU_USB) $(QEMU_RNG) \ @@ -193,7 +193,7 @@ nonos-mk-run-serial-log: $(QEMU_BLK_IMG) $(QEMU_BLK_STORE_STAMP) @echo "Booting NONOS serial console in QEMU..." @echo " Network: $(QEMU_NET_DESC)" @echo " Serial log: $(QEMU_SERIAL_LOG)" - @$(QEMU) -m $(QEMU_MEM) -accel hvf -cpu host,+rdrand,+rdseed -smp 1 -machine q35 \ + @$(QEMU) -m $(QEMU_MEM) $(QEMU_ACCEL_ARGS) -smp 1 -machine q35 \ -drive "format=raw,file=fat:rw:$(ESP_DIR)" \ -drive if=pflash,format=raw,readonly=on,file="$(OVMF)" \ $(QEMU_BLK) $(QEMU_GPU) $(QEMU_NET) $(QEMU_USB) $(QEMU_RNG) \ @@ -204,7 +204,7 @@ nonos-mk-run-input-probe-inject-serial-log: nonos-mk-input-probe-inject-esp $(QE @echo "Booting NONOS input-probe inject serial console in QEMU..." @echo " Network: $(QEMU_NET_DESC)" @echo " Serial log: $(QEMU_SERIAL_LOG)" - @$(QEMU) -m $(QEMU_MEM) -accel hvf -cpu host,+rdrand,+rdseed -smp 1 -machine q35 \ + @$(QEMU) -m $(QEMU_MEM) $(QEMU_ACCEL_ARGS) -smp 1 -machine q35 \ -drive "format=raw,file=fat:rw:$(NONOS_INPUT_PROBE_INJECT_ESP)" \ -drive if=pflash,format=raw,readonly=on,file="$(OVMF)" \ $(QEMU_BLK) $(QEMU_GPU) $(QEMU_NET) $(QEMU_USB) $(QEMU_RNG) \ @@ -393,7 +393,7 @@ nonos-mk-run-smp-serial-log: $(QEMU_BLK_IMG) $(QEMU_BLK_STORE_STAMP) @echo "Booting NONOS on $(QEMU_SMP) CPUs in QEMU..." @echo " Network: $(QEMU_NET_DESC)" @echo " Serial log: $(QEMU_SMP_SERIAL_LOG)" - @$(QEMU) -m $(QEMU_MEM) -accel hvf -cpu host,+rdrand,+rdseed -smp $(QEMU_SMP) -machine q35 \ + @$(QEMU) -m $(QEMU_MEM) $(QEMU_ACCEL_ARGS) -smp $(QEMU_SMP) -machine q35 \ -drive "format=raw,file=fat:rw:$(ESP_DIR)" \ -drive if=pflash,format=raw,readonly=on,file="$(OVMF)" \ $(QEMU_BLK) $(QEMU_GPU) $(QEMU_NET) $(QEMU_USB) $(QEMU_RNG) \ @@ -426,7 +426,7 @@ nonos-mk-run-install: nonos-mk-swtpm-start $(QEMU_BLK_IMG) $(QEMU_BLK_STORE_STAM @echo "Booting NONOS with a blank NVMe install target..." @echo " Target: $(INSTALL_TARGET_IMG) (nvme, serial NONOS-TARGET)" @echo " Quit: Ctrl+A then X" - @$(QEMU) -m $(QEMU_MEM) -accel hvf -cpu host,+rdrand,+rdseed -smp 1 -machine q35 \ + @$(QEMU) -m $(QEMU_MEM) $(QEMU_ACCEL_ARGS) -smp 1 -machine q35 \ -drive "format=raw,file=fat:rw:$(ESP_DIR)" \ -drive if=pflash,format=raw,unit=0,readonly=on,file="$(OVMF)" \ -drive if=pflash,format=raw,unit=1,file="$(QEMU_OVMF_VARS_RW)" \ @@ -439,7 +439,7 @@ nonos-mk-run-install: nonos-mk-swtpm-start $(QEMU_BLK_IMG) $(QEMU_BLK_STORE_STAM nonos-mk-run-installed: nonos-mk-swtpm-start $(QEMU_OVMF_VARS_RW) @test -f $(INSTALL_TARGET_IMG) || { echo "no install target yet: run make qemu-install and install first"; exit 1; } @echo "Booting the disk the installer wrote, as the only disk..." - @$(QEMU) -m $(QEMU_MEM) -accel hvf -cpu host,+rdrand,+rdseed -smp 1 -machine q35 \ + @$(QEMU) -m $(QEMU_MEM) $(QEMU_ACCEL_ARGS) -smp 1 -machine q35 \ -drive if=pflash,format=raw,unit=0,readonly=on,file="$(OVMF)" \ -drive if=pflash,format=raw,unit=1,file="$(QEMU_OVMF_VARS_RW)" \ -drive "file=$(INSTALL_TARGET_IMG),if=none,id=tgt,format=raw" \ From a3b8dab0b5f227a879e5c949e987f0d9655de6eb Mon Sep 17 00:00:00 2001 From: eKisNonos Date: Sat, 26 Sep 2026 13:50:20 +0000 Subject: [PATCH 010/244] attest: take only a STARK against the vendor root in a STARK build The trailer's magic picked the verifier for every root, so a Pedersen trailer was checked against the vendor root too. A local root's leaf is a commitment to a secret this kernel holds; the vendor root's is not, so there the trailer may no longer choose the weaker proof. --- src/security/capsule_attest/against_root.rs | 46 ++++++++++++--------- src/security/capsule_attest/stark.rs | 2 +- src/security/capsule_attest/verify.rs | 4 +- 3 files changed, 29 insertions(+), 23 deletions(-) diff --git a/src/security/capsule_attest/against_root.rs b/src/security/capsule_attest/against_root.rs index 61e6bb9ea4..5c32cf69de 100644 --- a/src/security/capsule_attest/against_root.rs +++ b/src/security/capsule_attest/against_root.rs @@ -16,37 +16,43 @@ use super::error::AttestError; -/// The two proof shapes, told apart by their own first eight bytes. -const STARK_MAGIC: &[u8; 8] = b"NZKSTRK1"; - -/// Verify a capsule's proof against one specific root. -pub(super) fn verify( +/// Verify a capsule's proof against the vendor's root. +/// +/// A kernel built for STARK attestation accepts only a STARK here, whatever +/// the trailer says it is: letting the trailer choose would let a prover pick +/// the weaker verifier for the root everything shipped is measured under. +pub(super) fn vendor( trailer: &[u8], elf: &[u8], granted_caps: u64, root: &[u8; 32], ) -> Result<[u8; 32], AttestError> { - if trailer.len() >= 8 && &trailer[0..8] == STARK_MAGIC { - return stark(trailer, elf, granted_caps, root); + #[cfg(feature = "nonos-stark-attest")] + { + super::stark::verify_against(trailer, elf, granted_caps, root) + } + #[cfg(not(feature = "nonos-stark-attest"))] + { + super::against_pedersen::verify(trailer, elf, granted_caps, root) } - super::against_pedersen::verify(trailer, elf, granted_caps, root) } -#[cfg(feature = "nonos-stark-attest")] -fn stark( +/// Verify against a root a human enrolled on this machine. Here the trailer's +/// magic picks the verifier: a local root's leaf is a commitment to a secret +/// only this kernel holds, so the Pedersen proof it mints is sound for it. +pub(super) fn enrolled( trailer: &[u8], elf: &[u8], granted_caps: u64, root: &[u8; 32], ) -> Result<[u8; 32], AttestError> { - super::stark::verify_against(trailer, elf, granted_caps, root) -} - -/// A build without the STARK verifier cannot check a STARK trailer, and saying -/// so is the only safe answer: the alternative is falling through to the other -/// parser, which would refuse for the wrong reason. -#[cfg(not(feature = "nonos-stark-attest"))] -fn stark(_: &[u8], _: &[u8], _: u64, _: &[u8; 32]) -> Result<[u8; 32], AttestError> { - Err(AttestError::Rejected) + /* + * A build without the STARK verifier has no reader for that magic; the + * Pedersen parser refuses it as malformed, which is the right answer. + */ + #[cfg(feature = "nonos-stark-attest")] + if trailer.starts_with(super::stark::MAGIC) { + return super::stark::verify_against(trailer, elf, granted_caps, root); + } + super::against_pedersen::verify(trailer, elf, granted_caps, root) } - diff --git a/src/security/capsule_attest/stark.rs b/src/security/capsule_attest/stark.rs index c2bd2e5c51..45fd07907d 100644 --- a/src/security/capsule_attest/stark.rs +++ b/src/security/capsule_attest/stark.rs @@ -31,7 +31,7 @@ use alloc::vec::Vec; // agree exactly; a drift downward in queries or grinding still verifies. use crate::crypto::stark::attest_params::{GRIND_BITS, LOG_ROUNDS, N_QUERIES, EXTRA_BLOWUP_BITS as EXTRA_BLOWUP}; -const MAGIC: &[u8; 8] = b"NZKSTRK1"; +pub(super) const MAGIC: &[u8; 8] = b"NZKSTRK1"; /// Read four little-endian words into a rate-width Poseidon digest. fn to_rate(bytes: &[u8]) -> [Fp; RATE] { diff --git a/src/security/capsule_attest/verify.rs b/src/security/capsule_attest/verify.rs index 7c3f046311..1ba9cb2cee 100644 --- a/src/security/capsule_attest/verify.rs +++ b/src/security/capsule_attest/verify.rs @@ -36,13 +36,13 @@ pub fn verify_capsule_attestation( granted_caps: u64, ) -> Result { let vendor = super::policy_root::root().ok_or(AttestError::RootUnavailable)?; - if let Ok(measurement) = super::against_root::verify(trailer, elf, granted_caps, &vendor) { + if let Ok(measurement) = super::against_root::vendor(trailer, elf, granted_caps, &vendor) { return Ok(Proved { measurement, authority: Authority::Vendor }); } let (roots, n) = enrolled_roots(); for root in roots.iter().take(n) { - if let Ok(measurement) = super::against_root::verify(trailer, elf, granted_caps, root) { + if let Ok(measurement) = super::against_root::enrolled(trailer, elf, granted_caps, root) { // The slot is looked up rather than inferred from the loop index, // so the reported authority is the table's answer and cannot drift // from it if the table is reordered. From a8cd3c09d70dbb80e4fc27fe5a7750e1b5cdb358 Mon Sep 17 00:00:00 2001 From: eKisNonos Date: Sat, 26 Sep 2026 13:50:20 +0000 Subject: [PATCH 011/244] local_build: mint a local proof for the ambient capabilities only MkLocalSign let a LocalSign holder prove any capability it held, including LocalSign itself, so one signer could hand out the right to sign. A local proof now names nothing beyond AMBIENT_CAPS. crypto_proofs checks a minted trailer and its refusals against the kernel's own verifier files. --- src/process/core/table/inherit.rs | 2 +- src/process/core/table/mod.rs | 1 + src/security/local_build/error.rs | 3 + src/security/local_build/sign.rs | 11 ++- src/security/local_build/trailer.rs | 8 +- userland/crypto_proofs/Cargo.lock | 75 ++++++++++++++++++- userland/crypto_proofs/Cargo.toml | 3 + userland/crypto_proofs/src/lib.rs | 1 + .../capsule_attest/local_root_refusals.rs | 47 ++++++++++++ .../capsule_attest/local_root_tests.rs | 56 ++++++++++++++ .../src/security/capsule_attest/mod.rs | 34 +++++++++ userland/crypto_proofs/src/security/mod.rs | 19 +++++ 12 files changed, 253 insertions(+), 7 deletions(-) create mode 100644 userland/crypto_proofs/src/security/capsule_attest/local_root_refusals.rs create mode 100644 userland/crypto_proofs/src/security/capsule_attest/local_root_tests.rs create mode 100644 userland/crypto_proofs/src/security/capsule_attest/mod.rs create mode 100644 userland/crypto_proofs/src/security/mod.rs diff --git a/src/process/core/table/inherit.rs b/src/process/core/table/inherit.rs index d140d3d01d..8094bac97c 100644 --- a/src/process/core/table/inherit.rs +++ b/src/process/core/table/inherit.rs @@ -44,7 +44,7 @@ use crate::capabilities::Capability; // spawner. `RegisterService` and `Network`/`FileSystem`/`Crypto`/ // `Hardware` are not part of the active syscall surface today and // are deliberately excluded from the ambient. -const AMBIENT_CAPS: u64 = +pub(crate) const AMBIENT_CAPS: u64 = Capability::CoreExec.bit() | Capability::IPC.bit() | Capability::Memory.bit(); // Bits that must never appear in `AMBIENT_CAPS` in any production diff --git a/src/process/core/table/mod.rs b/src/process/core/table/mod.rs index 09e04b8bd6..9fe8cee168 100644 --- a/src/process/core/table/mod.rs +++ b/src/process/core/table/mod.rs @@ -24,6 +24,7 @@ mod thread_spawn; mod types; pub(crate) use create::create_process_with_parent; +pub(crate) use inherit::AMBIENT_CAPS; pub use claim::{claim_new, release_new}; pub use create::{create_process, create_process_with_mem}; pub use thread_spawn::{admit_thread, spawn_thread, spawn_thread_in, spawn_thread_parked}; diff --git a/src/security/local_build/error.rs b/src/security/local_build/error.rs index 83fd7bd306..6bb0a9b6ef 100644 --- a/src/security/local_build/error.rs +++ b/src/security/local_build/error.rs @@ -19,6 +19,8 @@ pub enum LocalBuildError { NoIdentity, ProofFailed, TrailerShape, + /// The capabilities asked for include one a local proof may not carry. + ScarceCapability, } impl LocalBuildError { @@ -27,6 +29,7 @@ impl LocalBuildError { Self::NoIdentity => "no local build identity", Self::ProofFailed => "local proof generation failed", Self::TrailerShape => "proof does not match the trailer layout", + Self::ScarceCapability => "a local proof may carry only the ambient capabilities", } } } diff --git a/src/security/local_build/sign.rs b/src/security/local_build/sign.rs index 4f3902b5a8..a334f081dc 100644 --- a/src/security/local_build/sign.rs +++ b/src/security/local_build/sign.rs @@ -25,7 +25,7 @@ use super::error::LocalBuildError; use super::identity::with_identity; use super::trailer::encode; -/// Laid out as `against_root::verify` lays it out. If the two disagree the +/// Laid out as `against_pedersen::verify` lays it out. If the two disagree the /// proof verifies against nothing. fn context(elf: &[u8], granted_caps: u64) -> [u8; 48] { let mut ctx = [0u8; 48]; @@ -42,6 +42,15 @@ pub fn sign(elf: &[u8], granted_caps: u64) -> Result, LocalBuildError> { * picked its parser from that flag and would have read these NZKCAPS2 * bytes as a malformed STARK. */ + /* + * A proof made here admits a capsule holding what it names, so it names + * nothing beyond what every process inherits. Minting LocalSign, or any + * scarce right, would let a signer hand out authority it cannot be asked + * to justify. + */ + if granted_caps & !crate::process::core::AMBIENT_CAPS != 0 { + return Err(LocalBuildError::ScarceCapability); + } let ctx = context(elf, granted_caps); let proof = with_identity(|id| { prove_enrolled(&id.secret, &id.blinding, 0, &super::tree::empty_siblings(), &id.root, &ctx) diff --git a/src/security/local_build/trailer.rs b/src/security/local_build/trailer.rs index 8dbec781c5..a4aa74e14d 100644 --- a/src/security/local_build/trailer.rs +++ b/src/security/local_build/trailer.rs @@ -23,6 +23,10 @@ use crate::security::capsule_attest::layout::POLICY_TREE_DEPTH; const TRAILER_MAGIC: &[u8; 8] = b"NZKCAPS2"; +/// Magic, four 32-byte fields, the depth, the siblings, the packed directions. +pub const TRAILER_LEN: usize = + 8 + 4 * 32 + 1 + POLICY_TREE_DEPTH * 32 + POLICY_TREE_DEPTH.div_ceil(8); + /// The inverse of `capsule_attest::trailer::parse`, field for field. Written /// against that reader: a trailer one byte long is refused as malformed, and /// that looks identical to a proof that was simply wrong. @@ -33,7 +37,7 @@ pub fn encode(proof: &EnrolledSecretProof) -> Option> { return None; } let dir_bytes = POLICY_TREE_DEPTH.div_ceil(8); - let mut out = Vec::with_capacity(137 + POLICY_TREE_DEPTH * 32 + dir_bytes); + let mut out = Vec::with_capacity(TRAILER_LEN); out.extend_from_slice(TRAILER_MAGIC); out.extend_from_slice(&proof.commitment); out.extend_from_slice(&proof.nonce_point); @@ -48,5 +52,5 @@ pub fn encode(proof: &EnrolledSecretProof) -> Option> { packed[i / 8] |= (d & 1) << (i % 8); } out.extend_from_slice(&packed); - Some(out) + (out.len() == TRAILER_LEN).then_some(out) } diff --git a/userland/crypto_proofs/Cargo.lock b/userland/crypto_proofs/Cargo.lock index a6442c680c..7e3587fb3b 100644 --- a/userland/crypto_proofs/Cargo.lock +++ b/userland/crypto_proofs/Cargo.lock @@ -2,25 +2,94 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + +[[package]] +name = "blake3" +version = "1.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d9e454fc11f76977dc803893aff6304ed33d6a26efae8696573bea74baa27ae" +dependencies = [ + "arrayvec", + "cc", + "cfg-if", + "constant_time_eq", + "cpufeatures", +] + +[[package]] +name = "cc" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f360145194ee8e21db5ee7f3fcd4fe52210864c75c985dae33218202c8bbe040" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "constant_time_eq" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" + +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + [[package]] name = "crypto_proofs" version = "0.3.0" dependencies = [ + "blake3", "nonos_ed25519", "spin", ] +[[package]] +name = "find-msvc-tools" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + [[package]] name = "nonos_ed25519" version = "0.1.0" dependencies = [ - "nonos_hd", + "nonos_hash", "spin", ] [[package]] -name = "nonos_hd" -version = "0.3.0" +name = "nonos_hash" +version = "0.1.0" + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" [[package]] name = "spin" diff --git a/userland/crypto_proofs/Cargo.toml b/userland/crypto_proofs/Cargo.toml index 9c6379e1f1..012e0a164e 100644 --- a/userland/crypto_proofs/Cargo.toml +++ b/userland/crypto_proofs/Cargo.toml @@ -21,5 +21,8 @@ spin = { version = "0.9", default-features = false, features = ["once", "mutex", # kernel keeps only its boot-chain verify. nonos_ed25519 = { path = "../nonos_ed25519" } +# The spawn gate hashes an image with the blake3 crate, as the kernel does. +blake3 = { version = "1.0", default-features = false } + [lints.rust] unexpected_cfgs = { level = "warn", check-cfg = ['cfg(kani)'] } diff --git a/userland/crypto_proofs/src/lib.rs b/userland/crypto_proofs/src/lib.rs index 644223fff9..ddc5097170 100644 --- a/userland/crypto_proofs/src/lib.rs +++ b/userland/crypto_proofs/src/lib.rs @@ -25,6 +25,7 @@ extern crate alloc; // primitives expect from their parent module. pub mod crypto; pub mod hash; +pub mod security; #[cfg(test)] mod aesgcm_tests; diff --git a/userland/crypto_proofs/src/security/capsule_attest/local_root_refusals.rs b/userland/crypto_proofs/src/security/capsule_attest/local_root_refusals.rs new file mode 100644 index 0000000000..80e3098de3 --- /dev/null +++ b/userland/crypto_proofs/src/security/capsule_attest/local_root_refusals.rs @@ -0,0 +1,47 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! What a local trailer must not verify for. + +use super::local_root_tests::{minted, ELF}; +use super::against_pedersen::verify; + +#[test] +fn it_does_not_verify_for_another_image_or_other_capabilities() { + let (root, trailer) = minted(7, ELF, 0); + assert!(verify(&trailer, b"\x7fELF something else", 0, &root).is_err()); + assert!(verify(&trailer, ELF, 1 << 33, &root).is_err()); + assert!(verify(&trailer, ELF, 0x8, &root).is_err()); +} + +#[test] +fn another_machine_root_refuses_it() { + let (_, trailer) = minted(7, ELF, 0); + let (other_root, _) = minted(9, ELF, 0); + assert!(verify(&trailer, ELF, 0, &other_root).is_err()); +} + +#[test] +fn tampering_or_guessing_the_secret_fails() { + let (root, trailer) = minted(7, ELF, 0); + for i in [8, 40, 72, 104, 137] { + let mut t = trailer.clone(); + t[i] ^= 1; + assert!(verify(&t, ELF, 0, &root).is_err(), "flipped byte {i}"); + } + let (_, guessed) = minted(8, ELF, 0); + assert!(verify(&guessed, ELF, 0, &root).is_err()); +} diff --git a/userland/crypto_proofs/src/security/capsule_attest/local_root_tests.rs b/userland/crypto_proofs/src/security/capsule_attest/local_root_tests.rs new file mode 100644 index 0000000000..b945ef7395 --- /dev/null +++ b/userland/crypto_proofs/src/security/capsule_attest/local_root_tests.rs @@ -0,0 +1,56 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! A trailer minted the way `local_build::sign` mints one, checked by the +//! verifier an enrolled root is sent to. The tree and the encoder are the +//! kernel's own files; only the context is restated here, and a mismatch +//! would fail the first test. + +use super::against_pedersen::verify; +use crate::crypto::zk_kernel::{prove_enrolled, PedersenCommitment}; + +#[path = "../../../../../src/security/local_build/tree.rs"] +mod tree; + +#[path = "../../../../../src/security/local_build/trailer.rs"] +mod mint; + +pub(super) const ELF: &[u8] = b"\x7fELF an installed program"; + +fn ctx(elf: &[u8], caps: u64) -> [u8; 48] { + let mut c = [0u8; 48]; + c[..32].copy_from_slice(blake3::hash(elf).as_bytes()); + c[32..40].copy_from_slice(&caps.to_be_bytes()); + c[40..48].copy_from_slice(&super::layout::POLICY_EPOCH.to_be_bytes()); + c +} + +/// An identity from two fixed secrets, its root, and a trailer it minted. +pub(super) fn minted(secret: u8, elf: &[u8], caps: u64) -> ([u8; 32], alloc::vec::Vec) { + let (x, r) = ([secret; 32], [secret ^ 0x5a; 32]); + let root = tree::root_for(&PedersenCommitment::commit(&x, &r).commitment); + let proof = prove_enrolled(&x, &r, 0, &tree::empty_siblings(), &root, &ctx(elf, caps)) + .expect("proof"); + (root, mint::encode(&proof).expect("trailer")) +} + +#[test] +fn a_local_trailer_verifies_for_exactly_what_it_was_minted_for() { + let (root, trailer) = minted(7, ELF, 0); + assert_eq!(trailer.len(), mint::TRAILER_LEN); + let got = verify(&trailer, ELF, 0, &root).map_err(|e| e.as_str()); + assert_eq!(got, Ok(*blake3::hash(ELF).as_bytes())); +} diff --git a/userland/crypto_proofs/src/security/capsule_attest/mod.rs b/userland/crypto_proofs/src/security/capsule_attest/mod.rs new file mode 100644 index 0000000000..ceb532204f --- /dev/null +++ b/userland/crypto_proofs/src/security/capsule_attest/mod.rs @@ -0,0 +1,34 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! The kernel's attestation files a local trailer passes through. + +#[path = "../../../../../src/security/capsule_attest/error.rs"] +pub mod error; + +#[path = "../../../../../src/security/capsule_attest/layout.rs"] +pub mod layout; + +#[path = "../../../../../src/security/capsule_attest/trailer.rs"] +pub mod trailer; + +#[path = "../../../../../src/security/capsule_attest/against_pedersen.rs"] +pub mod against_pedersen; + +#[cfg(test)] +mod local_root_refusals; +#[cfg(test)] +mod local_root_tests; diff --git a/userland/crypto_proofs/src/security/mod.rs b/userland/crypto_proofs/src/security/mod.rs new file mode 100644 index 0000000000..df815b219e --- /dev/null +++ b/userland/crypto_proofs/src/security/mod.rs @@ -0,0 +1,19 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! The spawn gate's local-root path, mounted from the kernel. + +pub mod capsule_attest; From a10c5b519eecdbcd9534a7d7e4ff38a59f45b506 Mon Sep 17 00:00:00 2001 From: eKisNonos Date: Sat, 26 Sep 2026 13:56:20 +0000 Subject: [PATCH 012/244] crypto: verify a SHA-1 PKCS#1 v1.5 signature made over a DigestInfo An Alpine package index is signed that way, and the capsule offered SHA-1 only without the prefix, so the index could not be checked at all. The rsa crate rebuilds the whole padded block and compares it. Nothing here signs, so offering SHA-1 verification mints nothing new with it. --- userland/capsule_crypto/Cargo.lock | 12 ++++ userland/capsule_crypto/Cargo.toml | 1 + .../src/server/handlers/rsa_scheme.rs | 14 ++-- userland/capsule_crypto_proofs/Cargo.toml | 2 +- userland/capsule_crypto_proofs/src/tests.rs | 2 + .../src/tests/scheme_tests.rs | 4 +- .../src/tests/sha1_prefixed_tests.rs | 51 ++++++++++++++ .../src/tests/sha1_vector.rs | 68 +++++++++++++++++++ .../src/tests/unprefixed_tests.rs | 6 +- 9 files changed, 148 insertions(+), 12 deletions(-) create mode 100644 userland/capsule_crypto_proofs/src/tests/sha1_prefixed_tests.rs create mode 100644 userland/capsule_crypto_proofs/src/tests/sha1_vector.rs diff --git a/userland/capsule_crypto/Cargo.lock b/userland/capsule_crypto/Cargo.lock index 4711f39644..6d6ffdc356 100644 --- a/userland/capsule_crypto/Cargo.lock +++ b/userland/capsule_crypto/Cargo.lock @@ -440,6 +440,7 @@ dependencies = [ "p256", "p384", "rsa", + "sha1", "sha2", "sha3", "x25519-dalek", @@ -728,6 +729,17 @@ dependencies = [ "syn", ] +[[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + [[package]] name = "sha2" version = "0.10.9" diff --git a/userland/capsule_crypto/Cargo.toml b/userland/capsule_crypto/Cargo.toml index 4f7ad5b265..1a2074ba54 100644 --- a/userland/capsule_crypto/Cargo.toml +++ b/userland/capsule_crypto/Cargo.toml @@ -22,6 +22,7 @@ path = "src/main.rs" [dependencies] nonos_libc = { package = "nonos_userland_libc", path = "../libc" } blake3 = { version = "1.0", default-features = false } +sha1 = { version = "0.10", default-features = false, features = ["oid"] } sha2 = { version = "0.10", default-features = false, features = ["force-soft", "oid"] } sha3 = { version = "0.10", default-features = false } digest = { version = "0.10", default-features = false } diff --git a/userland/capsule_crypto/src/server/handlers/rsa_scheme.rs b/userland/capsule_crypto/src/server/handlers/rsa_scheme.rs index 33ccd04c61..d307b3439c 100644 --- a/userland/capsule_crypto/src/server/handlers/rsa_scheme.rs +++ b/userland/capsule_crypto/src/server/handlers/rsa_scheme.rs @@ -19,14 +19,15 @@ use rsa::pkcs8::DecodePublicKey; use rsa::pss::Pss; use rsa::{Pkcs1v15Sign, RsaPublicKey}; +use sha1::Sha1; use sha2::{Sha256, Sha384, Sha512}; /* - * hashid 3 is a twenty byte digest and is reachable only under scheme 2. A - * directory authority certificate is signed over a SHA-1 digest with no - * DigestInfo, so that length has to be accepted for the chain to be checkable - * at all. It is deliberately not paired with a prefixed scheme: there is no - * reason to sign a new SHA-1 DigestInfo and every reason not to offer one. + * hashid 3 is a twenty byte SHA-1 digest. A directory authority certificate is + * signed over one with no DigestInfo (scheme 2), and an Alpine package index + * is signed over one with it (scheme 0): both are signatures someone else + * already made, and neither chain is checkable without them. This capsule + * only verifies, so offering SHA-1 here signs nothing new with it. */ /// The digest length `hashid` names, or `None` if the pair is not offered. pub fn digest_len(scheme: u8, hashid: u8) -> Option { @@ -34,7 +35,7 @@ pub fn digest_len(scheme: u8, hashid: u8) -> Option { 0 => Some(32), 1 => Some(48), 2 => Some(64), - 3 if scheme == 2 => Some(20), + 3 if scheme == 0 || scheme == 2 => Some(20), _ => None, } } @@ -59,6 +60,7 @@ pub fn verify(scheme: u8, hashid: u8, spki: &[u8], sig: &[u8], digest: &[u8]) -> (0, 0) => key.verify(Pkcs1v15Sign::new::(), digest, sig).is_ok(), (0, 1) => key.verify(Pkcs1v15Sign::new::(), digest, sig).is_ok(), (0, 2) => key.verify(Pkcs1v15Sign::new::(), digest, sig).is_ok(), + (0, 3) => key.verify(Pkcs1v15Sign::new::(), digest, sig).is_ok(), (1, 0) => key.verify(Pss::new::(), digest, sig).is_ok(), (1, 1) => key.verify(Pss::new::(), digest, sig).is_ok(), (1, 2) => key.verify(Pss::new::(), digest, sig).is_ok(), diff --git a/userland/capsule_crypto_proofs/Cargo.toml b/userland/capsule_crypto_proofs/Cargo.toml index 3bbe7ba199..88a0302b81 100644 --- a/userland/capsule_crypto_proofs/Cargo.toml +++ b/userland/capsule_crypto_proofs/Cargo.toml @@ -23,5 +23,5 @@ path = "src/lib.rs" # The same crates, at the same versions, that capsule_crypto pins. rsa = { version = "0.9", default-features = false, features = ["sha2"] } sha2 = { version = "0.10", default-features = false, features = ["force-soft", "oid"] } -sha1 = { version = "0.10", default-features = false } +sha1 = { version = "0.10", default-features = false, features = ["oid"] } base64ct = { version = "1", features = ["alloc"] } diff --git a/userland/capsule_crypto_proofs/src/tests.rs b/userland/capsule_crypto_proofs/src/tests.rs index 2b1c77acc3..9578d5b216 100644 --- a/userland/capsule_crypto_proofs/src/tests.rs +++ b/userland/capsule_crypto_proofs/src/tests.rs @@ -18,4 +18,6 @@ mod anchor_tests; mod scheme_tests; +mod sha1_prefixed_tests; +mod sha1_vector; mod unprefixed_tests; diff --git a/userland/capsule_crypto_proofs/src/tests/scheme_tests.rs b/userland/capsule_crypto_proofs/src/tests/scheme_tests.rs index 32a019b784..a9985e115e 100644 --- a/userland/capsule_crypto_proofs/src/tests/scheme_tests.rs +++ b/userland/capsule_crypto_proofs/src/tests/scheme_tests.rs @@ -32,9 +32,9 @@ fn the_prefixed_and_pss_schemes_keep_their_lengths() { } #[test] -fn a_twenty_byte_digest_is_reachable_only_under_scheme_two() { +fn a_twenty_byte_digest_is_reachable_under_the_pkcs1_schemes_only() { assert_eq!(digest_len(2, 3), Some(20)); - assert_eq!(digest_len(0, 3), None); + assert_eq!(digest_len(0, 3), Some(20)); assert_eq!(digest_len(1, 3), None); assert_eq!(digest_len(3, 3), None, "there is no scheme 3"); } diff --git a/userland/capsule_crypto_proofs/src/tests/sha1_prefixed_tests.rs b/userland/capsule_crypto_proofs/src/tests/sha1_prefixed_tests.rs new file mode 100644 index 0000000000..9b942e3f29 --- /dev/null +++ b/userland/capsule_crypto_proofs/src/tests/sha1_prefixed_tests.rs @@ -0,0 +1,51 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + + +//! Scheme 0 at SHA-1: a signature made over a DigestInfo, which is how an +//! Alpine package index is signed. + +use super::sha1_vector::{DIGEST, SIG, SPKI}; +use crate::rsa_scheme::verify; + +#[test] +fn a_prefixed_sha1_signature_verifies_under_scheme_zero() { + assert_eq!(verify(0, 3, &SPKI, &SIG, &DIGEST), Some(true)); +} + +#[test] +fn a_tampered_digest_or_signature_is_refused() { + let mut digest = DIGEST; + digest[19] ^= 0x01; + assert_eq!(verify(0, 3, &SPKI, &SIG, &digest), Some(false)); + let mut sig = SIG; + sig[0] ^= 0x01; + assert_eq!(verify(0, 3, &SPKI, &sig, &DIGEST), Some(false)); +} + +#[test] +fn the_prefixed_signature_is_not_an_unprefixed_one() { + /* + * Scheme 2 expects the bare digest in the padded block, so a DigestInfo + * there is a different block and must not verify. + */ + assert_eq!(verify(2, 3, &SPKI, &SIG, &DIGEST), Some(false)); +} + +#[test] +fn pss_is_never_offered_at_sha1() { + assert_eq!(verify(1, 3, &SPKI, &SIG, &DIGEST), None); +} diff --git a/userland/capsule_crypto_proofs/src/tests/sha1_vector.rs b/userland/capsule_crypto_proofs/src/tests/sha1_vector.rs new file mode 100644 index 0000000000..233df8941f --- /dev/null +++ b/userland/capsule_crypto_proofs/src/tests/sha1_vector.rs @@ -0,0 +1,68 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + + +//! A SHA-1 PKCS#1 v1.5 signature with its DigestInfo, the shape an Alpine +//! index is signed in, made by `openssl dgst -sha1 -sign` with a throwaway key. + +pub const SPKI: [u8; 294] = [ + 0x30, 0x82, 0x01, 0x22, 0x30, 0x0d, 0x06, 0x09, 0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, + 0x01, 0x01, 0x01, 0x05, 0x00, 0x03, 0x82, 0x01, 0x0f, 0x00, 0x30, 0x82, 0x01, 0x0a, + 0x02, 0x82, 0x01, 0x01, 0x00, 0xb3, 0x8a, 0x07, 0x5a, 0xa8, 0x17, 0x66, 0x67, 0x73, + 0x2f, 0x79, 0xc2, 0x62, 0x06, 0x30, 0x1d, 0x26, 0xad, 0x92, 0x7e, 0xb6, 0x38, 0x92, + 0xb3, 0x23, 0x7c, 0x6e, 0x77, 0x9a, 0xd3, 0xbe, 0x01, 0x2d, 0xdb, 0x4d, 0x6e, 0xaf, + 0xc2, 0xcb, 0x64, 0xa3, 0x9a, 0xb9, 0x42, 0x15, 0xb3, 0x81, 0x7d, 0x77, 0x23, 0x2b, + 0xa9, 0x69, 0x5c, 0xc3, 0xc0, 0x49, 0x72, 0xc9, 0xbf, 0xfb, 0x10, 0xce, 0x4d, 0x51, + 0xd6, 0xfc, 0xf3, 0x8e, 0xad, 0x78, 0xc4, 0x99, 0x0d, 0x89, 0xa1, 0x71, 0x8f, 0x36, + 0xc2, 0x80, 0x2b, 0x34, 0xf8, 0x55, 0xd7, 0xde, 0xa7, 0x18, 0xfe, 0x98, 0x4e, 0xbf, + 0xb4, 0x39, 0x4d, 0x0c, 0x2a, 0x09, 0x36, 0xbd, 0xef, 0xb5, 0x6d, 0x05, 0x4f, 0xdc, + 0x48, 0xb4, 0xb3, 0x1d, 0xaf, 0x9b, 0x29, 0x7c, 0xff, 0x51, 0xb6, 0x40, 0xaf, 0xe9, + 0xf8, 0xd2, 0xda, 0x18, 0x32, 0xb4, 0xbc, 0xa6, 0xf9, 0x2f, 0xd8, 0x04, 0x6c, 0x6e, + 0xcf, 0xce, 0x75, 0x9a, 0xe6, 0xbb, 0x51, 0x3f, 0x75, 0x5a, 0x77, 0x3f, 0xbf, 0x3e, + 0x60, 0x75, 0xa2, 0x74, 0xe7, 0xd0, 0xea, 0x4f, 0x6b, 0x36, 0x0c, 0x67, 0x26, 0x1a, + 0xc0, 0xcc, 0x3f, 0x8d, 0x6f, 0xa0, 0xb7, 0xe6, 0xdd, 0x36, 0x3d, 0x48, 0xa2, 0x2d, + 0x48, 0x9e, 0xb0, 0x8e, 0xf2, 0x4c, 0xd6, 0x4b, 0xb0, 0xba, 0x71, 0x3a, 0xc0, 0x27, + 0x03, 0x30, 0xdd, 0x17, 0xf5, 0x0d, 0x88, 0x6f, 0x5c, 0x84, 0x16, 0x6e, 0xec, 0x47, + 0x12, 0xcb, 0x2d, 0x22, 0x6b, 0x14, 0x37, 0xe3, 0xb3, 0xee, 0xfa, 0x8a, 0x5b, 0x7c, + 0x34, 0xaf, 0x37, 0x86, 0x06, 0x99, 0x16, 0x4c, 0x85, 0xad, 0xf6, 0xee, 0x0a, 0x83, + 0xce, 0x7b, 0xdf, 0x32, 0xa6, 0xf3, 0xce, 0x4a, 0x33, 0xb2, 0x68, 0xde, 0x28, 0xe6, + 0x99, 0xb5, 0x0a, 0xfc, 0x4e, 0x02, 0x8b, 0x32, 0xc9, 0x02, 0x03, 0x01, 0x00, 0x01, +]; +pub const SIG: [u8; 256] = [ + 0x8e, 0x29, 0x17, 0x9a, 0x77, 0xd8, 0x6e, 0x4d, 0xe0, 0x05, 0x8e, 0x57, 0xd9, 0x92, + 0x61, 0x61, 0xff, 0xe5, 0xb9, 0x24, 0x68, 0x45, 0x41, 0x8a, 0xa6, 0xac, 0xeb, 0x00, + 0xf4, 0x32, 0x8d, 0xb6, 0xea, 0xd1, 0x67, 0x7c, 0x79, 0xd2, 0x11, 0x0c, 0x23, 0x36, + 0x66, 0x4c, 0x3b, 0xf1, 0x2d, 0xc7, 0xe2, 0x34, 0x70, 0x08, 0xa3, 0x6e, 0x5f, 0xd0, + 0x60, 0xe1, 0xce, 0x5f, 0x66, 0xc9, 0xb2, 0x24, 0x0c, 0x31, 0xac, 0x4c, 0x15, 0xe4, + 0x72, 0x5d, 0x36, 0x8a, 0x96, 0x5b, 0xd8, 0xf0, 0xf4, 0x50, 0xa6, 0x12, 0x14, 0xfc, + 0x38, 0x4f, 0x08, 0x3d, 0x50, 0x60, 0x3e, 0x26, 0x12, 0xb8, 0xff, 0xaa, 0xa0, 0xe0, + 0xe7, 0xc9, 0xa4, 0x7e, 0xa9, 0xeb, 0xe7, 0x9d, 0xcd, 0x13, 0xa5, 0x07, 0xa5, 0x7b, + 0x4b, 0x76, 0x18, 0x88, 0x0f, 0x0c, 0xf4, 0x1a, 0xc8, 0x65, 0xde, 0xb9, 0xbf, 0xa1, + 0x2e, 0x1d, 0xe5, 0x7c, 0x72, 0x02, 0x63, 0x0e, 0x42, 0x49, 0x28, 0x5e, 0x42, 0x71, + 0x03, 0xbc, 0x31, 0xa0, 0x41, 0x01, 0x9a, 0x4d, 0xa8, 0xca, 0xa3, 0xca, 0x5d, 0x92, + 0x42, 0xef, 0xc3, 0x17, 0x3c, 0x5d, 0xc8, 0x37, 0x56, 0x0e, 0xeb, 0xe6, 0x84, 0x16, + 0x11, 0x83, 0xe1, 0x08, 0x12, 0x2f, 0x6e, 0x0f, 0x40, 0xae, 0xd7, 0x4b, 0xa7, 0x7b, + 0x5b, 0xf3, 0x06, 0xb3, 0x9b, 0x8a, 0x09, 0xf4, 0x63, 0x62, 0xd5, 0x76, 0xa3, 0x8f, + 0xfc, 0x10, 0xac, 0xef, 0xf1, 0xe8, 0x03, 0x4b, 0x47, 0x62, 0xb0, 0x83, 0x85, 0x6a, + 0x4f, 0xed, 0x6c, 0x5a, 0xa4, 0xf5, 0xee, 0x61, 0xde, 0x6f, 0x62, 0x34, 0xcc, 0x3f, + 0xa6, 0xdc, 0xb7, 0x02, 0xac, 0xf8, 0x78, 0xbf, 0x91, 0x36, 0x90, 0x6e, 0x97, 0x55, + 0xec, 0x0d, 0xd0, 0x5d, 0xee, 0x52, 0xff, 0x4f, 0x49, 0x7b, 0x45, 0x62, 0x58, 0x53, + 0x0c, 0x3e, 0xef, 0x59, +]; +pub const DIGEST: [u8; 20] = [ + 0xde, 0x3e, 0xca, 0xfc, 0x5a, 0x39, 0xcc, 0x9c, 0x4e, 0xdb, 0xda, 0x4c, 0x52, 0x33, + 0x42, 0xf1, 0x18, 0x74, 0x6e, 0x1c, +]; diff --git a/userland/capsule_crypto_proofs/src/tests/unprefixed_tests.rs b/userland/capsule_crypto_proofs/src/tests/unprefixed_tests.rs index 3b48121b2c..e13352cc97 100644 --- a/userland/capsule_crypto_proofs/src/tests/unprefixed_tests.rs +++ b/userland/capsule_crypto_proofs/src/tests/unprefixed_tests.rs @@ -36,10 +36,10 @@ fn the_same_signature_is_refused_by_the_prefixed_schemes() { let cert = parse(CERT); let spki = wrap_pkcs1(&cert.identity_pkcs1); /* - * A 20 byte digest has no prefixed home, so this is a bad argument rather - * than a failed signature, which is itself the point. + * Under scheme 0 a SHA-1 digest is expected behind a DigestInfo, which + * this block does not carry. */ - assert_eq!(verify(0, 3, &spki, &cert.signature, &cert.digest), None); + assert_eq!(verify(0, 3, &spki, &cert.signature, &cert.digest), Some(false)); // Offered at a SHA-256 length, the digest no longer fits. assert_eq!(verify(0, 0, &spki, &cert.signature, &cert.digest), None); } From a45d63bd4a8516e674486442dcd7f2888bf578a1 Mon Sep 17 00:00:00 2001 From: eKisNonos Date: Sat, 26 Sep 2026 14:04:38 +0000 Subject: [PATCH 013/244] linux: make the proof crate compile, and run it in CI resolve.rs names super::root, which the crate never mounted, so it did not build and nothing noticed because no workflow ran it. It mounts root.rs, follows the rename of absolute to visible, adds the /linux confinement and Phdr::file_range tests, and joins the proof-crate matrix. --- .github/workflows/verify.yml | 1 + .../capsule_linux_proofs/src/image/mod.rs | 3 ++ .../src/image/phdr_tests.rs | 46 +++++++++++++++++++ userland/capsule_linux_proofs/src/lib.rs | 4 ++ userland/capsule_linux_proofs/src/tests.rs | 1 + .../src/tests/dirent_tests.rs | 2 +- .../src/tests/key_tests.rs | 43 +++++++++++++++++ .../src/tests/resolve_tests.rs | 4 +- 8 files changed, 101 insertions(+), 3 deletions(-) create mode 100644 userland/capsule_linux_proofs/src/image/phdr_tests.rs create mode 100644 userland/capsule_linux_proofs/src/tests/key_tests.rs diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml index 68f65b4f4d..53b1bea09a 100644 --- a/.github/workflows/verify.yml +++ b/.github/workflows/verify.yml @@ -343,6 +343,7 @@ jobs: - audio_proto_proofs - capsule_crypto_proofs - aes_proofs + - capsule_linux_proofs steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v4.2.2 with: diff --git a/userland/capsule_linux_proofs/src/image/mod.rs b/userland/capsule_linux_proofs/src/image/mod.rs index f9f645513d..d9632e952f 100644 --- a/userland/capsule_linux_proofs/src/image/mod.rs +++ b/userland/capsule_linux_proofs/src/image/mod.rs @@ -32,3 +32,6 @@ pub mod elf; #[path = "../../../capsule_linux/src/linux/image/elf_phdr.rs"] pub mod elf_phdr; + +#[cfg(test)] +mod phdr_tests; diff --git a/userland/capsule_linux_proofs/src/image/phdr_tests.rs b/userland/capsule_linux_proofs/src/image/phdr_tests.rs new file mode 100644 index 0000000000..f0950d9bfb --- /dev/null +++ b/userland/capsule_linux_proofs/src/image/phdr_tests.rs @@ -0,0 +1,46 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! The file span a program header names, on the offsets an ELF chooses. + +use super::phdr::Phdr; + +fn ph(offset: u64, filesz: u64) -> Phdr { + Phdr { kind: 1, flags: 0, offset, vaddr: 0, filesz, memsz: filesz } +} + +#[test] +fn a_header_names_exactly_its_bytes() { + assert_eq!(ph(0x40, 0x10).file_range(), Some(0x40..0x50)); +} + +#[test] +fn an_empty_segment_is_an_empty_span() { + assert_eq!(ph(0x1000, 0).file_range(), Some(0x1000..0x1000)); +} + +#[test] +fn a_span_ending_at_the_top_of_memory_is_kept() { + let top = usize::MAX as u64; + assert_eq!(ph(top - 4, 4).file_range(), Some(usize::MAX - 4..usize::MAX)); +} + +#[test] +fn a_span_that_wraps_is_refused() { + let top = usize::MAX as u64; + assert_eq!(ph(top - 3, 4).file_range(), None); + assert_eq!(ph(u64::MAX, u64::MAX).file_range(), None); +} diff --git a/userland/capsule_linux_proofs/src/lib.rs b/userland/capsule_linux_proofs/src/lib.rs index ab9fae3e52..bb94fbbd2c 100644 --- a/userland/capsule_linux_proofs/src/lib.rs +++ b/userland/capsule_linux_proofs/src/lib.rs @@ -25,6 +25,9 @@ pub mod wire; #[path = "../../capsule_linux/src/linux/wayland/args.rs"] pub mod args; +#[path = "../../capsule_linux/src/linux/file/root.rs"] +pub mod root; + #[path = "../../capsule_linux/src/linux/file/resolve.rs"] pub mod resolve; @@ -37,6 +40,7 @@ pub mod statbuf; #[path = "../../capsule_linux/src/linux/call/spawn/exec_shebang.rs"] pub mod exec_shebang; +#[cfg(test)] pub mod image; /// The installer's parsers, which read bytes fetched off a network. diff --git a/userland/capsule_linux_proofs/src/tests.rs b/userland/capsule_linux_proofs/src/tests.rs index ae67ca5b14..094d0526aa 100644 --- a/userland/capsule_linux_proofs/src/tests.rs +++ b/userland/capsule_linux_proofs/src/tests.rs @@ -20,6 +20,7 @@ mod dirent_tests; mod elf_tests; mod exec_shebang_tests; +mod key_tests; mod resolve_tests; mod stack_words_tests; mod stat_tests; diff --git a/userland/capsule_linux_proofs/src/tests/dirent_tests.rs b/userland/capsule_linux_proofs/src/tests/dirent_tests.rs index dcd2372ac7..8caf026136 100644 --- a/userland/capsule_linux_proofs/src/tests/dirent_tests.rs +++ b/userland/capsule_linux_proofs/src/tests/dirent_tests.rs @@ -43,7 +43,7 @@ fn walking_by_reclen_reaches_every_name() { let mut seen = Vec::new(); while at < out.len() { let reclen = u16::from_le_bytes([out[at + 16], out[at + 17]]) as usize; - assert!(reclen >= HEADER + 1 && at + reclen <= out.len()); + assert!(reclen > HEADER && at + reclen <= out.len()); let body = &out[at + 19..at + reclen]; let end = body.iter().position(|b| *b == 0).unwrap(); seen.push(String::from_utf8(body[..end].to_vec()).unwrap()); diff --git a/userland/capsule_linux_proofs/src/tests/key_tests.rs b/userland/capsule_linux_proofs/src/tests/key_tests.rs new file mode 100644 index 0000000000..c125463be2 --- /dev/null +++ b/userland/capsule_linux_proofs/src/tests/key_tests.rs @@ -0,0 +1,43 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + + +//! The store key a guest path becomes, and the root it cannot leave. + +use crate::resolve::{key, visible}; + +fn stored(cwd: &str, path: &str) -> String { + String::from_utf8(key(&visible(cwd.as_bytes(), path.as_bytes())).as_bytes().to_vec()).unwrap() +} + +#[test] +fn every_key_sits_under_the_linux_root() { + assert_eq!(stored("/", "/etc/passwd"), "/linux/etc/passwd"); + assert_eq!(stored("/home", "lib"), "/linux/home/lib"); +} + +#[test] +fn the_guest_root_is_the_store_root_itself() { + assert_eq!(stored("/", "/"), "/linux"); + assert_eq!(stored("/", ""), "/linux"); +} + +#[test] +fn dot_dot_cannot_climb_out_of_the_linux_root() { + assert_eq!(stored("/", "../../system/keys"), "/linux/system/keys"); + assert_eq!(stored("/a", "../../../.."), "/linux"); + assert!(stored("/", "/../..//../x").starts_with("/linux/")); +} diff --git a/userland/capsule_linux_proofs/src/tests/resolve_tests.rs b/userland/capsule_linux_proofs/src/tests/resolve_tests.rs index ea6096bd83..68c1e6abbc 100644 --- a/userland/capsule_linux_proofs/src/tests/resolve_tests.rs +++ b/userland/capsule_linux_proofs/src/tests/resolve_tests.rs @@ -17,10 +17,10 @@ //! Turning a guest's path into a store key. -use crate::resolve::absolute; +use crate::resolve::visible; fn at(cwd: &str, path: &str) -> String { - String::from_utf8(absolute(cwd.as_bytes(), path.as_bytes())).unwrap() + String::from_utf8(visible(cwd.as_bytes(), path.as_bytes())).unwrap() } #[test] From efccb74b2f28491c0382fc86fa7981f52dcbae13 Mon Sep 17 00:00:00 2001 From: eKisNonos Date: Sat, 26 Sep 2026 14:05:06 +0000 Subject: [PATCH 014/244] inflate: split a gzip file into its members and their byte ranges A signature over a package covers the compressed bytes of one member, so a verifier needs to know where each one starts and ends. members() refuses a file with any byte that belongs to no member, where gunzip ends the stream and ignores the rest. --- userland/inflate/Cargo.lock | 2 +- userland/inflate/src/gzip.rs | 4 +-- userland/inflate/src/lib.rs | 2 ++ userland/inflate/src/members.rs | 52 +++++++++++++++++++++++++++++++++ 4 files changed, 57 insertions(+), 3 deletions(-) create mode 100644 userland/inflate/src/members.rs diff --git a/userland/inflate/Cargo.lock b/userland/inflate/Cargo.lock index 10927120a0..ef59b1764f 100644 --- a/userland/inflate/Cargo.lock +++ b/userland/inflate/Cargo.lock @@ -4,4 +4,4 @@ version = 4 [[package]] name = "nonos_inflate" -version = "0.1.0" +version = "0.3.0" diff --git a/userland/inflate/src/gzip.rs b/userland/inflate/src/gzip.rs index b918db8586..348885c895 100644 --- a/userland/inflate/src/gzip.rs +++ b/userland/inflate/src/gzip.rs @@ -26,7 +26,7 @@ use super::tables::MAX_OUT; const TRAILER: usize = 8; /// Enough for a distribution index in several parts. -const MAX_MEMBERS: usize = 64; +pub(super) const MAX_MEMBERS: usize = 64; /// Every member, concatenated. Bytes after a verified member that do not /// decode as another member are trailing garbage, and end the stream. @@ -50,7 +50,7 @@ pub fn gunzip(data: &[u8]) -> Option> { (at == data.len()).then_some(out) } -fn verified(d: &[u8]) -> Option<(Vec, usize)> { +pub(super) fn verified(d: &[u8]) -> Option<(Vec, usize)> { let (out, end) = inflated(d)?; checked(d, &out, end)?; Some((out, end.checked_add(TRAILER)?)) diff --git a/userland/inflate/src/lib.rs b/userland/inflate/src/lib.rs index fa73064ae5..6404d5cdc0 100644 --- a/userland/inflate/src/lib.rs +++ b/userland/inflate/src/lib.rs @@ -27,10 +27,12 @@ mod gzip; mod gzip_header; mod huff; mod inflate_raw; +mod members; mod stored; mod tables; mod zlib; pub use gzip::gunzip; pub use inflate_raw::inflate; +pub use members::{members, Member}; pub use zlib::zlib; diff --git a/userland/inflate/src/members.rs b/userland/inflate/src/members.rs new file mode 100644 index 0000000000..cffcff59a0 --- /dev/null +++ b/userland/inflate/src/members.rs @@ -0,0 +1,52 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . +//! gzip, including the concatenated members RFC 1952 allows. + +//! A gzip file cut into its members, each with the bytes it occupies. + +use alloc::vec::Vec; + +use super::gzip::{verified, MAX_MEMBERS}; +use super::tables::MAX_OUT; + +/// One member: where its compressed bytes sit, and what they inflate to. +pub struct Member { + pub start: usize, + pub end: usize, + pub body: Vec, +} + +/// Every member of `data`, which must be nothing else. A signature covers a +/// byte range, so a byte that belongs to no member is not garbage to skip; it +/// is content that nothing vouched for, and the whole file is refused. +pub fn members(data: &[u8]) -> Option> { + let mut out: Vec = Vec::new(); + let (mut at, mut total) = (0usize, 0usize); + while at < data.len() { + if out.len() == MAX_MEMBERS { + return None; + } + let (body, len) = verified(data.get(at..)?)?; + total = total.checked_add(body.len())?; + if total > MAX_OUT { + return None; + } + let end = at.checked_add(len)?; + out.push(Member { start: at, end, body }); + at = end; + } + (!out.is_empty()).then_some(out) +} From 35c85c64e07958a5a6edceaa5d9ed95410900600 Mon Sep 17 00:00:00 2001 From: eKisNonos Date: Sat, 26 Sep 2026 14:06:15 +0000 Subject: [PATCH 015/244] linux: install a package only when a signed Alpine index vouches for it The index's .SIGN.RSA entry is checked against Alpine's x86_64 keys by the crypto service, the package's control member against the index's C: SHA-1, and its data against the control member's datahash. Only a Verified value reaches the store, so unauthenticated bytes are refused, not kept unvouched. --- userland/capsule_linux/Cargo.lock | 97 +++++ userland/capsule_linux/Cargo.toml | 3 + ...vel@lists.alpinelinux.org-4a6a0840.rsa.pub | 9 + ...vel@lists.alpinelinux.org-5261cecb.rsa.pub | 9 + ...vel@lists.alpinelinux.org-6165ee59.rsa.pub | 14 + .../src/linux/install/auth/base64.rs | 54 +++ .../{provenance.rs => auth/checksum.rs} | 15 +- .../src/linux/install/auth/digest.rs | 27 ++ .../src/linux/install/auth/keys.rs | 54 +++ .../src/linux/install/auth/mod.rs | 33 ++ .../src/linux/install/auth/package.rs | 48 +++ .../src/linux/install/auth/pkginfo.rs | 43 ++ .../src/linux/install/auth/rsa.rs | 22 ++ .../src/linux/install/auth/signature.rs | 63 +++ .../src/linux/install/auth/verified.rs | 35 ++ .../capsule_linux/src/linux/install/index.rs | 23 +- .../src/linux/install/index_load.rs | 15 +- .../capsule_linux/src/linux/install/mod.rs | 2 +- .../capsule_linux/src/linux/install/place.rs | 20 +- .../src/linux/install/place_entry.rs | 15 +- .../capsule_linux/src/linux/install/run.rs | 15 +- userland/capsule_linux_proofs/Cargo.lock | 373 ++++++++++++++++++ userland/capsule_linux_proofs/Cargo.toml | 11 + .../src/install/auth/mod.rs | 45 +++ .../capsule_linux_proofs/src/install/mod.rs | 2 + userland/capsule_linux_proofs/src/tests.rs | 2 + .../src/tests/auth_refusals.rs | 63 +++ .../src/tests/auth_tests.rs | 73 ++++ .../vectors/auth/APKINDEX.tar.gz | Bin 0 -> 608 bytes .../vectors/auth/hello.apk | Bin 0 -> 712 bytes .../vectors/auth/make_vectors.py | 82 ++++ .../vectors/auth/swapped.apk | Bin 0 -> 710 bytes .../vectors/auth/test_key.spki | Bin 0 -> 294 bytes .../vectors/auth/untrusted.tar.gz | Bin 0 -> 583 bytes userland/nonos_tls/src/lib.rs | 1 + 35 files changed, 1213 insertions(+), 55 deletions(-) create mode 100644 userland/capsule_linux/keys/alpine/alpine-devel@lists.alpinelinux.org-4a6a0840.rsa.pub create mode 100644 userland/capsule_linux/keys/alpine/alpine-devel@lists.alpinelinux.org-5261cecb.rsa.pub create mode 100644 userland/capsule_linux/keys/alpine/alpine-devel@lists.alpinelinux.org-6165ee59.rsa.pub create mode 100644 userland/capsule_linux/src/linux/install/auth/base64.rs rename userland/capsule_linux/src/linux/install/{provenance.rs => auth/checksum.rs} (69%) create mode 100644 userland/capsule_linux/src/linux/install/auth/digest.rs create mode 100644 userland/capsule_linux/src/linux/install/auth/keys.rs create mode 100644 userland/capsule_linux/src/linux/install/auth/mod.rs create mode 100644 userland/capsule_linux/src/linux/install/auth/package.rs create mode 100644 userland/capsule_linux/src/linux/install/auth/pkginfo.rs create mode 100644 userland/capsule_linux/src/linux/install/auth/rsa.rs create mode 100644 userland/capsule_linux/src/linux/install/auth/signature.rs create mode 100644 userland/capsule_linux/src/linux/install/auth/verified.rs create mode 100644 userland/capsule_linux_proofs/src/install/auth/mod.rs create mode 100644 userland/capsule_linux_proofs/src/tests/auth_refusals.rs create mode 100644 userland/capsule_linux_proofs/src/tests/auth_tests.rs create mode 100644 userland/capsule_linux_proofs/vectors/auth/APKINDEX.tar.gz create mode 100644 userland/capsule_linux_proofs/vectors/auth/hello.apk create mode 100755 userland/capsule_linux_proofs/vectors/auth/make_vectors.py create mode 100644 userland/capsule_linux_proofs/vectors/auth/swapped.apk create mode 100644 userland/capsule_linux_proofs/vectors/auth/test_key.spki create mode 100644 userland/capsule_linux_proofs/vectors/auth/untrusted.tar.gz diff --git a/userland/capsule_linux/Cargo.lock b/userland/capsule_linux/Cargo.lock index 5d8a4577dd..71218a8a3c 100644 --- a/userland/capsule_linux/Cargo.lock +++ b/userland/capsule_linux/Cargo.lock @@ -22,6 +22,21 @@ dependencies = [ "libm", ] +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + [[package]] name = "core_maths" version = "0.1.1" @@ -31,6 +46,51 @@ dependencies = [ "libm", ] +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + [[package]] name = "libm" version = "0.2.16" @@ -68,14 +128,28 @@ name = "nonos_capsule_linux" version = "0.1.0" dependencies = [ "nonos_app_skeleton", + "nonos_hash", "nonos_inflate", + "nonos_tls", "nonos_userland_libc", + "sha1", ] +[[package]] +name = "nonos_hash" +version = "0.1.0" + [[package]] name = "nonos_inflate" version = "0.3.0" +[[package]] +name = "nonos_tls" +version = "0.2.0" +dependencies = [ + "nonos_userland_libc", +] + [[package]] name = "nonos_toolkit" version = "0.3.0" @@ -107,6 +181,17 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "spin" version = "0.9.9" @@ -133,3 +218,15 @@ checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" dependencies = [ "core_maths", ] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" diff --git a/userland/capsule_linux/Cargo.toml b/userland/capsule_linux/Cargo.toml index 1a57cc5abd..b3b579e712 100644 --- a/userland/capsule_linux/Cargo.toml +++ b/userland/capsule_linux/Cargo.toml @@ -24,6 +24,9 @@ path = "src/main.rs" nonos_libc = { package = "nonos_userland_libc", path = "../libc" } nonos_app_skeleton = { path = "../app_skeleton", default-features = false } nonos_inflate = { path = "../inflate" } +nonos_hash = { path = "../nonos_hash" } +nonos_tls = { path = "../nonos_tls" } +sha1 = { version = "0.10", default-features = false } [profile.release] panic = "abort" diff --git a/userland/capsule_linux/keys/alpine/alpine-devel@lists.alpinelinux.org-4a6a0840.rsa.pub b/userland/capsule_linux/keys/alpine/alpine-devel@lists.alpinelinux.org-4a6a0840.rsa.pub new file mode 100644 index 0000000000..bb4bdc80fd --- /dev/null +++ b/userland/capsule_linux/keys/alpine/alpine-devel@lists.alpinelinux.org-4a6a0840.rsa.pub @@ -0,0 +1,9 @@ +-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA1yHJxQgsHQREclQu4Ohe +qxTxd1tHcNnvnQTu/UrTky8wWvgXT+jpveroeWWnzmsYlDI93eLI2ORakxb3gA2O +Q0Ry4ws8vhaxLQGC74uQR5+/yYrLuTKydFzuPaS1dK19qJPXB8GMdmFOijnXX4SA +jixuHLe1WW7kZVtjL7nufvpXkWBGjsfrvskdNA/5MfxAeBbqPgaq0QMEfxMAn6/R +L5kNepi/Vr4S39Xvf2DzWkTLEK8pcnjNkt9/aafhWqFVW7m3HCAII6h/qlQNQKSo +GuH34Q8GsFG30izUENV9avY7hSLq7nggsvknlNBZtFUcmGoQrtx3FmyYsIC8/R+B +ywIDAQAB +-----END PUBLIC KEY----- diff --git a/userland/capsule_linux/keys/alpine/alpine-devel@lists.alpinelinux.org-5261cecb.rsa.pub b/userland/capsule_linux/keys/alpine/alpine-devel@lists.alpinelinux.org-5261cecb.rsa.pub new file mode 100644 index 0000000000..83f0658e9c --- /dev/null +++ b/userland/capsule_linux/keys/alpine/alpine-devel@lists.alpinelinux.org-5261cecb.rsa.pub @@ -0,0 +1,9 @@ +-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAwlzMkl7b5PBdfMzGdCT0 +cGloRr5xGgVmsdq5EtJvFkFAiN8Ac9MCFy/vAFmS8/7ZaGOXoCDWbYVLTLOO2qtX +yHRl+7fJVh2N6qrDDFPmdgCi8NaE+3rITWXGrrQ1spJ0B6HIzTDNEjRKnD4xyg4j +g01FMcJTU6E+V2JBY45CKN9dWr1JDM/nei/Pf0byBJlMp/mSSfjodykmz4Oe13xB +Ca1WTwgFykKYthoLGYrmo+LKIGpMoeEbY1kuUe04UiDe47l6Oggwnl+8XD1MeRWY +sWgj8sF4dTcSfCMavK4zHRFFQbGp/YFJ/Ww6U9lA3Vq0wyEI6MCMQnoSMFwrbgZw +wwIDAQAB +-----END PUBLIC KEY----- diff --git a/userland/capsule_linux/keys/alpine/alpine-devel@lists.alpinelinux.org-6165ee59.rsa.pub b/userland/capsule_linux/keys/alpine/alpine-devel@lists.alpinelinux.org-6165ee59.rsa.pub new file mode 100644 index 0000000000..f2165aebad --- /dev/null +++ b/userland/capsule_linux/keys/alpine/alpine-devel@lists.alpinelinux.org-6165ee59.rsa.pub @@ -0,0 +1,14 @@ +-----BEGIN PUBLIC KEY----- +MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAutQkua2CAig4VFSJ7v54 +ALyu/J1WB3oni7qwCZD3veURw7HxpNAj9hR+S5N/pNeZgubQvJWyaPuQDm7PTs1+ +tFGiYNfAsiibX6Rv0wci3M+z2XEVAeR9Vzg6v4qoofDyoTbovn2LztaNEjTkB+oK +tlvpNhg1zhou0jDVYFniEXvzjckxswHVb8cT0OMTKHALyLPrPOJzVtM9C1ew2Nnc +3848xLiApMu3NBk0JqfcS3Bo5Y2b1FRVBvdt+2gFoKZix1MnZdAEZ8xQzL/a0YS5 +Hd0wj5+EEKHfOd3A75uPa/WQmA+o0cBFfrzm69QDcSJSwGpzWrD1ScH3AK8nWvoj +v7e9gukK/9yl1b4fQQ00vttwJPSgm9EnfPHLAtgXkRloI27H6/PuLoNvSAMQwuCD +hQRlyGLPBETKkHeodfLoULjhDi1K2gKJTMhtbnUcAA7nEphkMhPWkBpgFdrH+5z4 +Lxy+3ek0cqcI7K68EtrffU8jtUj9LFTUC8dERaIBs7NgQ/LfDbDfGh9g6qVj1hZl +k9aaIPTm/xsi8v3u+0qaq7KzIBc9s59JOoA8TlpOaYdVgSQhHHLBaahOuAigH+VI +isbC9vmqsThF2QdDtQt37keuqoda2E6sL7PUvIyVXDRfwX7uMDjlzTxHTymvq2Ck +htBqojBnThmjJQFgZXocHG8CAwEAAQ== +-----END PUBLIC KEY----- diff --git a/userland/capsule_linux/src/linux/install/auth/base64.rs b/userland/capsule_linux/src/linux/install/auth/base64.rs new file mode 100644 index 0000000000..7d723c3738 --- /dev/null +++ b/userland/capsule_linux/src/linux/install/auth/base64.rs @@ -0,0 +1,54 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! Standard base64, as PEM bodies and index checksums carry it. + +use alloc::vec::Vec; + +fn value(c: u8) -> Option { + match c { + b'A'..=b'Z' => Some(u32::from(c - b'A')), + b'a'..=b'z' => Some(u32::from(c - b'a') + 26), + b'0'..=b'9' => Some(u32::from(c - b'0') + 52), + b'+' => Some(62), + b'/' => Some(63), + _ => None, + } +} + +/// Decode `text`, which must be whole four-character groups. Padding may +/// only close the last group; any other character refuses the whole input. +pub fn decode(text: &[u8]) -> Option> { + if !text.len().is_multiple_of(4) { + return None; + } + let mut out = Vec::with_capacity(text.len() / 4 * 3); + for (i, group) in text.chunks(4).enumerate() { + let last = i + 1 == text.len() / 4; + let pad = group.iter().rev().take_while(|&&c| c == b'=').count(); + if pad > 2 || (pad > 0 && !last) { + return None; + } + let mut word = 0u32; + for &c in &group[..4 - pad] { + word = (word << 6) | value(c)?; + } + word <<= 6 * pad as u32; + let bytes = word.to_be_bytes(); + out.extend_from_slice(&bytes[1..4 - pad]); + } + Some(out) +} diff --git a/userland/capsule_linux/src/linux/install/provenance.rs b/userland/capsule_linux/src/linux/install/auth/checksum.rs similarity index 69% rename from userland/capsule_linux/src/linux/install/provenance.rs rename to userland/capsule_linux/src/linux/install/auth/checksum.rs index 3750b5ccd9..e4d0a175f8 100644 --- a/userland/capsule_linux/src/linux/install/provenance.rs +++ b/userland/capsule_linux/src/linux/install/auth/checksum.rs @@ -14,13 +14,12 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -//! Where a package's bytes came from, and whether anything vouches for them -//! arriving intact. +//! The `C:` field of an index record: the SHA-1 of a package's control +//! member, written `Q1` and then base64. -#[derive(Clone, Copy, PartialEq, Eq)] -pub(super) enum Provenance { - /// The bytes match a checksum from a signed index. - Verified, - /// Nothing says these are the bytes the distribution published. - Unauthenticated, +use super::base64::decode; + +pub fn parse(field: &str) -> Option<[u8; 20]> { + let raw = decode(field.strip_prefix("Q1")?.as_bytes())?; + raw.try_into().ok() } diff --git a/userland/capsule_linux/src/linux/install/auth/digest.rs b/userland/capsule_linux/src/linux/install/auth/digest.rs new file mode 100644 index 0000000000..3562b94bde --- /dev/null +++ b/userland/capsule_linux/src/linux/install/auth/digest.rs @@ -0,0 +1,27 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! The two digests an Alpine package is checked with. + +use sha1::{Digest, Sha1}; + +pub fn sha1(data: &[u8]) -> [u8; 20] { + Sha1::digest(data).into() +} + +pub fn sha256(data: &[u8]) -> [u8; 32] { + nonos_hash::sha256(data) +} diff --git a/userland/capsule_linux/src/linux/install/auth/keys.rs b/userland/capsule_linux/src/linux/install/auth/keys.rs new file mode 100644 index 0000000000..e43b3b816a --- /dev/null +++ b/userland/capsule_linux/src/linux/install/auth/keys.rs @@ -0,0 +1,54 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! The keys Alpine signs x86_64 indexes with, byte for byte as the +//! distribution publishes them in its alpine-keys package. + +use alloc::vec::Vec; + +use super::base64::decode; + +const KEYS: [(&[u8], &[u8]); 3] = [ + ( + b"alpine-devel@lists.alpinelinux.org-4a6a0840.rsa.pub", + include_bytes!( + "../../../../keys/alpine/alpine-devel@lists.alpinelinux.org-4a6a0840.rsa.pub" + ), + ), + ( + b"alpine-devel@lists.alpinelinux.org-5261cecb.rsa.pub", + include_bytes!( + "../../../../keys/alpine/alpine-devel@lists.alpinelinux.org-5261cecb.rsa.pub" + ), + ), + ( + b"alpine-devel@lists.alpinelinux.org-6165ee59.rsa.pub", + include_bytes!( + "../../../../keys/alpine/alpine-devel@lists.alpinelinux.org-6165ee59.rsa.pub" + ), + ), +]; + +/// The DER public key a signature entry names, if it is one trusted here. +pub fn spki(name: &[u8]) -> Option> { + let (_, pem) = KEYS.iter().find(|(n, _)| *n == name)?; + let body: Vec = pem + .split(|&c| c == b'\n') + .filter(|line| !line.starts_with(b"-----")) + .flat_map(|line| line.iter().copied().filter(|c| !c.is_ascii_whitespace())) + .collect(); + decode(&body) +} diff --git a/userland/capsule_linux/src/linux/install/auth/mod.rs b/userland/capsule_linux/src/linux/install/auth/mod.rs new file mode 100644 index 0000000000..21881b9db1 --- /dev/null +++ b/userland/capsule_linux/src/linux/install/auth/mod.rs @@ -0,0 +1,33 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! Whether a package is the one the distribution published. + +mod base64; +mod checksum; +mod digest; +mod keys; +mod package; +mod pkginfo; +mod rsa; +mod signature; +mod verified; + +pub use checksum::parse as checksum; +pub use package::verified; +pub use rsa::verify as rsa_verify; +pub use signature::signed_index; +pub use verified::Verified; diff --git a/userland/capsule_linux/src/linux/install/auth/package.rs b/userland/capsule_linux/src/linux/install/auth/package.rs new file mode 100644 index 0000000000..cc6abb1b26 --- /dev/null +++ b/userland/capsule_linux/src/linux/install/auth/package.rs @@ -0,0 +1,48 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! A downloaded package, checked against the index record that named it. + +use alloc::vec::Vec; + +use nonos_inflate::members; + +use super::digest::{sha1, sha256}; +use super::pkginfo::datahash; +use super::verified::Verified; + +/// The package's files, if its control member hashes to `checksum` from a +/// signed index and its data hashes to the `datahash` that control records. +/// Either alone leaves something unvouched: the index names only the +/// control member, and only the control member names the data. +pub fn verified(apk: &[u8], checksum: &[u8; 20]) -> Option { + let parts = members(apk)?; + let [_signature, control, data @ ..] = parts.as_slice() else { + return None; + }; + let first = data.first()?; + if sha1(apk.get(control.start..control.end)?) != *checksum { + return None; + } + if sha256(apk.get(first.start..)?) != datahash(&control.body)? { + return None; + } + let mut files: Vec = Vec::new(); + for part in data { + files.extend_from_slice(&part.body); + } + Some(Verified::checked(files)) +} diff --git a/userland/capsule_linux/src/linux/install/auth/pkginfo.rs b/userland/capsule_linux/src/linux/install/auth/pkginfo.rs new file mode 100644 index 0000000000..ab845ec30e --- /dev/null +++ b/userland/capsule_linux/src/linux/install/auth/pkginfo.rs @@ -0,0 +1,43 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! The `datahash` a package's control member records for its data. + +use super::super::tar::entries; + +/// The SHA-256 `.PKGINFO` in `control_tar` says the data member hashes to. +pub fn datahash(control_tar: &[u8]) -> Option<[u8; 32]> { + let found = entries(control_tar).into_iter().find(|e| e.name == b".PKGINFO")?; + let text = core::str::from_utf8(&found.body).ok()?; + let hex = text.lines().find_map(|line| line.strip_prefix("datahash = "))?; + let hex = hex.trim().as_bytes(); + if hex.len() != 64 { + return None; + } + let mut out = [0u8; 32]; + for (slot, pair) in out.iter_mut().zip(hex.chunks(2)) { + *slot = (nibble(pair[0])? << 4) | nibble(pair[1])?; + } + Some(out) +} + +fn nibble(c: u8) -> Option { + match c { + b'0'..=b'9' => Some(c - b'0'), + b'a'..=b'f' => Some(c - b'a' + 10), + _ => None, + } +} diff --git a/userland/capsule_linux/src/linux/install/auth/rsa.rs b/userland/capsule_linux/src/linux/install/auth/rsa.rs new file mode 100644 index 0000000000..e035b8cf08 --- /dev/null +++ b/userland/capsule_linux/src/linux/install/auth/rsa.rs @@ -0,0 +1,22 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! Asking the crypto service whether a signature verifies. The capsule +//! holds no RSA of its own, so there is one verifier on the machine. + +pub fn verify(spki: &[u8], sig: &[u8], hashid: u8, digest: &[u8]) -> bool { + nonos_tls::verify_rsa(0, hashid, spki, sig, digest) +} diff --git a/userland/capsule_linux/src/linux/install/auth/signature.rs b/userland/capsule_linux/src/linux/install/auth/signature.rs new file mode 100644 index 0000000000..8c7dc88e30 --- /dev/null +++ b/userland/capsule_linux/src/linux/install/auth/signature.rs @@ -0,0 +1,63 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! The signature an index carries in its first member. + +use alloc::vec::Vec; + +use nonos_inflate::members; + +use super::super::tar::{entries, Entry}; +use super::digest::{sha1, sha256}; +use super::keys::spki; + +/// Asks whether `sig` over `digest` verifies under `spki`; `hashid` 3 is +/// SHA-1 and 0 is SHA-256, as the crypto service numbers them. +pub type Rsa = dyn Fn(&[u8], &[u8], u8, &[u8]) -> bool; + +/// The index tar, if a key trusted here signed the member that holds it. +/// An index is exactly two members, so nothing unsigned rides along. +pub fn signed_index(raw: &[u8], rsa: &Rsa) -> Option> { + let mut parts = members(raw)?; + if parts.len() != 2 { + return None; + } + let index = parts.pop()?; + let covered = raw.get(index.start..index.end)?; + signed(&parts[0].body, covered, rsa).then_some(index.body) +} + +/// True when some signature entry in `sig_tar` verifies over `covered`. +pub fn signed(sig_tar: &[u8], covered: &[u8], rsa: &Rsa) -> bool { + entries(sig_tar).iter().any(|entry| one(entry, covered, rsa)) +} + +fn one(entry: &Entry, covered: &[u8], rsa: &Rsa) -> bool { + let (hashid, key) = if let Some(k) = entry.name.strip_prefix(b".SIGN.RSA256.") { + (0u8, k) + } else if let Some(k) = entry.name.strip_prefix(b".SIGN.RSA.") { + (3u8, k) + } else { + return false; + }; + let Some(key) = spki(key) else { + return false; + }; + match hashid { + 0 => rsa(&key, &entry.body, hashid, &sha256(covered)), + _ => rsa(&key, &entry.body, hashid, &sha1(covered)), + } +} diff --git a/userland/capsule_linux/src/linux/install/auth/verified.rs b/userland/capsule_linux/src/linux/install/auth/verified.rs new file mode 100644 index 0000000000..6fdbdb12f7 --- /dev/null +++ b/userland/capsule_linux/src/linux/install/auth/verified.rs @@ -0,0 +1,35 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! Package bytes that something authenticated. + +use alloc::vec::Vec; + +/// A package's files, decompressed. Only `package::verified` makes one, so +/// bytes that did not match a signed index cannot be unpacked at all. +pub struct Verified { + files: Vec, +} + +impl Verified { + pub(super) fn checked(files: Vec) -> Self { + Self { files } + } + + pub fn files(&self) -> &[u8] { + &self.files + } +} diff --git a/userland/capsule_linux/src/linux/install/index.rs b/userland/capsule_linux/src/linux/install/index.rs index 37118db325..6a78b2cd37 100644 --- a/userland/capsule_linux/src/linux/install/index.rs +++ b/userland/capsule_linux/src/linux/install/index.rs @@ -19,9 +19,12 @@ use alloc::string::String; use alloc::vec::Vec; +#[derive(Clone)] pub struct Pkg { pub name: String, pub version: String, + /// SHA-1 of the package's control member, from its `C:` line. + pub checksum: Option<[u8; 20]>, } pub struct Index { @@ -35,22 +38,20 @@ impl Index { pub fn parse(raw: &[u8]) -> Index { let text = String::from_utf8_lossy(raw); let (mut libs, mut names) = (Vec::new(), Vec::new()); - let (mut name, mut version) = (String::new(), String::new()); + let mut cur = Pkg { name: String::new(), version: String::new(), checksum: None }; for line in text.lines() { + let rest = line.get(2..).unwrap_or(""); match line.as_bytes().first() { - Some(b'P') => name = String::from(&line[2..]), + None => cur.checksum = None, + Some(b'C') => cur.checksum = super::auth::checksum(rest), + Some(b'P') => cur.name = String::from(rest), Some(b'V') => { - version = String::from(&line[2..]); - names - .push((name.clone(), Pkg { name: name.clone(), version: version.clone() })); + cur.version = String::from(rest); + names.push((cur.name.clone(), cur.clone())); } Some(b'p') => { - for token in line[2..].split_whitespace() { - if let Some(so) = token.strip_prefix("so:") { - let so = so.split('=').next().unwrap_or(so); - let pkg = Pkg { name: name.clone(), version: version.clone() }; - libs.push((String::from(so), pkg)); - } + for so in rest.split_whitespace().filter_map(|t| t.strip_prefix("so:")) { + libs.push((String::from(so.split('=').next().unwrap_or(so)), cur.clone())); } } _ => {} diff --git a/userland/capsule_linux/src/linux/install/index_load.rs b/userland/capsule_linux/src/linux/install/index_load.rs index 46bc16dd54..4e3f8eb154 100644 --- a/userland/capsule_linux/src/linux/install/index_load.rs +++ b/userland/capsule_linux/src/linux/install/index_load.rs @@ -19,6 +19,7 @@ use alloc::format; use alloc::vec::Vec; +use super::auth::{rsa_verify, signed_index}; use super::http::get; use super::index::Index; use super::run::{ARCH, BRANCHES, HOST, PORT, RELEASE}; @@ -29,7 +30,15 @@ pub(super) fn load_index() -> Option { for branch in BRANCHES { let path = format!("/alpine/{RELEASE}/{branch}/{ARCH}/APKINDEX.tar.gz"); let raw = get(HOST, PORT, &path)?; - let plain = nonos_inflate::gunzip(&raw)?; + /* + * A branch whose signature does not verify refuses the whole index: + * resolving against half of it would pick a dependency from whichever + * branch happened to be authentic. + */ + let Some(plain) = signed_index(&raw, &rsa_verify) else { + say(b"[LINUX] package index signature did not verify\n"); + return None; + }; for entry in entries(&plain) { if entry.name.ends_with(b"APKINDEX") { all.extend_from_slice(&entry.body); @@ -38,3 +47,7 @@ pub(super) fn load_index() -> Option { } Some(Index::parse(&all)) } + +fn say(line: &[u8]) { + let _ = nonos_libc::mk_debug(line.as_ptr(), line.len()); +} diff --git a/userland/capsule_linux/src/linux/install/mod.rs b/userland/capsule_linux/src/linux/install/mod.rs index c92a6de470..669468e7c5 100644 --- a/userland/capsule_linux/src/linux/install/mod.rs +++ b/userland/capsule_linux/src/linux/install/mod.rs @@ -16,6 +16,7 @@ //! Installing a Linux program from within the system. +mod auth; mod download; mod enrol; mod http; @@ -23,7 +24,6 @@ mod index; mod index_load; mod place; mod place_entry; -mod provenance; mod run; mod tar; mod tar_field; diff --git a/userland/capsule_linux/src/linux/install/place.rs b/userland/capsule_linux/src/linux/install/place.rs index 28012a2291..bb9990d62b 100644 --- a/userland/capsule_linux/src/linux/install/place.rs +++ b/userland/capsule_linux/src/linux/install/place.rs @@ -15,22 +15,12 @@ // along with this program. If not, see . //! Putting a package's files into the store, under the Linux root. -use nonos_inflate::gunzip; -use nonos_libc::mk_debug; - +use super::auth::Verified; use super::place_entry::one; -use super::provenance::Provenance; use super::tar::entries; -/// Unpack `apk` into the store and report how many files landed. -pub fn unpack(apk: &[u8], from: Provenance) -> usize { - let Some(raw) = gunzip(apk) else { - say(b"[LINUX] package is not readable\n"); - return 0; - }; - entries(&raw).iter().filter(|entry| one(entry, from)).count() -} - -fn say(line: &[u8]) { - let _ = mk_debug(line.as_ptr(), line.len()); +/// Unpack a package's authenticated files into the store and report how +/// many landed. +pub fn unpack(files: &Verified) -> usize { + entries(files.files()).iter().filter(|entry| one(entry)).count() } diff --git a/userland/capsule_linux/src/linux/install/place_entry.rs b/userland/capsule_linux/src/linux/install/place_entry.rs index 75aa0e08eb..8b40fb68ec 100644 --- a/userland/capsule_linux/src/linux/install/place_entry.rs +++ b/userland/capsule_linux/src/linux/install/place_entry.rs @@ -22,7 +22,6 @@ use nonos_libc::mk_debug; use crate::linux::file::{key, store_write, visible}; use super::enrol::vouch; -use super::provenance::Provenance; use super::tar::Entry; /// Paths a package may not write: a package dropping one of these @@ -30,7 +29,7 @@ use super::tar::Entry; const REFUSED: &[&[u8]] = &[b".nonos_id_cert.bin", b".manifest.bin", b".zk_trailer.bin"]; /// True when the file landed in the store. -pub(super) fn one(entry: &Entry, from: Provenance) -> bool { +pub(super) fn one(entry: &Entry) -> bool { if entry.name.starts_with(b".") { return false; } @@ -43,18 +42,14 @@ pub(super) fn one(entry: &Entry, from: Provenance) -> bool { return false; } if is_elf(&entry.body) { - vouch_for(&at, &entry.body, from); + vouch_for(&at, &entry.body); } true } -/// Minting says this machine agreed to run these bytes, so it is only said -/// about bytes something authenticated. -fn vouch_for(at: &[u8], body: &[u8], from: Provenance) { - if from == Provenance::Unauthenticated { - say(b"[LINUX] installed unvouched: package bytes are not authenticated\n"); - return; - } +/// Minting says this machine agreed to run these bytes. It is only reached +/// with a `Verified` package, so it is only said about authenticated bytes. +fn vouch_for(at: &[u8], body: &[u8]) { if !vouch(at, body) { say(b"[LINUX] installed but unvouched: no enrolled root, or may not mint\n"); } diff --git a/userland/capsule_linux/src/linux/install/run.rs b/userland/capsule_linux/src/linux/install/run.rs index 05b733a57f..4e3e632241 100644 --- a/userland/capsule_linux/src/linux/install/run.rs +++ b/userland/capsule_linux/src/linux/install/run.rs @@ -22,10 +22,10 @@ use alloc::vec::Vec; use nonos_libc::mk_debug; +use super::auth::verified; use super::download::download; use super::index_load::load_index; use super::place::unpack; -use super::provenance::Provenance; pub(super) const HOST: &str = "dl-cdn.alpinelinux.org"; pub(super) const PORT: u16 = 80; @@ -56,15 +56,12 @@ pub fn install(name: &str) -> bool { return false; }; let apk = download(&pkg.name, &pkg.version); - if apk.is_empty() { - say(b"[LINUX] package would not download\n"); + let Some(files) = pkg.checksum.and_then(|sum| verified(&apk, &sum)) else { + say(b"[LINUX] package did not download, or does not match its index record\n"); return false; - } - /* - * Nothing says these are the bytes the distribution - * published: see `provenance`. - */ - unpack(&apk, Provenance::Unauthenticated); + }; + say(b"[LINUX] provenance Verified: index signature and checksums match\n"); + unpack(&files); done.push(next); } true diff --git a/userland/capsule_linux_proofs/Cargo.lock b/userland/capsule_linux_proofs/Cargo.lock index b5322f0561..2ebde457c8 100644 --- a/userland/capsule_linux_proofs/Cargo.lock +++ b/userland/capsule_linux_proofs/Cargo.lock @@ -2,6 +2,379 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "zeroize", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "const-oid", + "crypto-common", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + [[package]] name = "nonos_capsule_linux_proofs" version = "0.1.0" +dependencies = [ + "nonos_hash", + "nonos_inflate", + "rsa", + "sha1", +] + +[[package]] +name = "nonos_hash" +version = "0.1.0" + +[[package]] +name = "nonos_inflate" +version = "0.3.0" + +[[package]] +name = "num-bigint-dig" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +dependencies = [ + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand", + "smallvec", + "zeroize", +] + +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rand" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" + +[[package]] +name = "rsa" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +dependencies = [ + "const-oid", + "digest", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core", + "sha2", + "signature", + "spki", + "subtle", + "zeroize", +] + +[[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest", + "rand_core", +] + +[[package]] +name = "smallvec" +version = "1.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e" + +[[package]] +name = "spin" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "zerocopy" +version = "0.8.59" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6df92bf3d9227be3d53173901ddbffac2babc27ae50f397776ffd6dc33f800cb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.59" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac4f328cf2f05d084e496c3e9c3f33ed0a183656a16e1fcec4d464d8373aec82" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" diff --git a/userland/capsule_linux_proofs/Cargo.toml b/userland/capsule_linux_proofs/Cargo.toml index e1cfcbc01a..38cb818a7b 100644 --- a/userland/capsule_linux_proofs/Cargo.toml +++ b/userland/capsule_linux_proofs/Cargo.toml @@ -17,3 +17,14 @@ authors = ["eK@nonos.systems"] [lib] path = "src/lib.rs" + +[dependencies] +# What the mounted installer files link against in the capsule. +nonos_inflate = { path = "../inflate" } +nonos_hash = { path = "../nonos_hash" } +sha1 = { version = "0.10", default-features = false } + +[dev-dependencies] +# The verifier the crypto service runs, standing in for the IPC call. +rsa = { version = "0.9", default-features = false, features = ["sha2"] } +sha1 = { version = "0.10", default-features = false, features = ["oid"] } diff --git a/userland/capsule_linux_proofs/src/install/auth/mod.rs b/userland/capsule_linux_proofs/src/install/auth/mod.rs new file mode 100644 index 0000000000..12fec2008a --- /dev/null +++ b/userland/capsule_linux_proofs/src/install/auth/mod.rs @@ -0,0 +1,45 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! The installer's package authentication, included from the capsule. The +//! RSA call is the one part left out: it is an IPC to the crypto service, and +//! a test hands the same check in as a closure. + +#[path = "../../../../capsule_linux/src/linux/install/auth/base64.rs"] +pub mod base64; + +#[path = "../../../../capsule_linux/src/linux/install/auth/checksum.rs"] +pub mod checksum; + +#[path = "../../../../capsule_linux/src/linux/install/auth/digest.rs"] +pub mod digest; + +#[path = "../../../../capsule_linux/src/linux/install/auth/keys.rs"] +pub mod keys; + +#[path = "../../../../capsule_linux/src/linux/install/auth/package.rs"] +pub mod package; + +#[path = "../../../../capsule_linux/src/linux/install/auth/pkginfo.rs"] +pub mod pkginfo; + +#[path = "../../../../capsule_linux/src/linux/install/auth/signature.rs"] +pub mod signature; + +#[path = "../../../../capsule_linux/src/linux/install/auth/verified.rs"] +pub mod verified; + +pub use checksum::parse as checksum; diff --git a/userland/capsule_linux_proofs/src/install/mod.rs b/userland/capsule_linux_proofs/src/install/mod.rs index 7985afda7e..30fdc84924 100644 --- a/userland/capsule_linux_proofs/src/install/mod.rs +++ b/userland/capsule_linux_proofs/src/install/mod.rs @@ -17,6 +17,8 @@ //! The installer's pure parsers, included from the capsule. +pub mod auth; + #[path = "../../../capsule_linux/src/linux/install/tar_field.rs"] pub mod tar_field; diff --git a/userland/capsule_linux_proofs/src/tests.rs b/userland/capsule_linux_proofs/src/tests.rs index 094d0526aa..fb3ecc4ec7 100644 --- a/userland/capsule_linux_proofs/src/tests.rs +++ b/userland/capsule_linux_proofs/src/tests.rs @@ -17,6 +17,8 @@ //! Every proof, by the thing it constrains. +mod auth_refusals; +mod auth_tests; mod dirent_tests; mod elf_tests; mod exec_shebang_tests; diff --git a/userland/capsule_linux_proofs/src/tests/auth_refusals.rs b/userland/capsule_linux_proofs/src/tests/auth_refusals.rs new file mode 100644 index 0000000000..98c19c84e6 --- /dev/null +++ b/userland/capsule_linux_proofs/src/tests/auth_refusals.rs @@ -0,0 +1,63 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! What package authentication must refuse. + +use super::auth_tests::{record, rsa, APK, INDEX}; +use crate::install::auth::checksum; +use crate::install::auth::package::verified; +use crate::install::auth::signature::signed_index; + +const UNTRUSTED: &[u8] = include_bytes!("../../vectors/auth/untrusted.tar.gz"); +const SWAPPED: &[u8] = include_bytes!("../../vectors/auth/swapped.apk"); + +fn flipped(bytes: &[u8], at: usize) -> Vec { + let mut out = bytes.to_vec(); + out[at] ^= 1; + out +} + +#[test] +fn a_changed_or_extended_index_is_refused() { + for at in [20, INDEX.len() / 2, INDEX.len() - 12] { + assert!(signed_index(&flipped(INDEX, at), &rsa).is_none(), "byte {at}"); + } + let mut longer = INDEX.to_vec(); + longer.push(0); + assert!(signed_index(&longer, &rsa).is_none(), "a trailing byte rode along"); +} + +#[test] +fn an_index_signed_under_a_key_not_trusted_here_is_refused() { + assert!(signed_index(UNTRUSTED, &|_: &[u8], _: &[u8], _: u8, _: &[u8]| true).is_none()); +} + +#[test] +fn a_package_is_refused_on_any_mismatch() { + let sum = record(); + assert!(verified(APK, &flipped(&sum, 0).try_into().unwrap()).is_none()); + assert!(verified(&flipped(APK, APK.len() - 12), &sum).is_none()); + // Honest control, other data: only the datahash can catch this one. + assert!(verified(SWAPPED, &sum).is_none()); +} + +#[test] +fn a_checksum_is_q1_and_twenty_bytes_of_base64() { + assert!(checksum("Q1VBuPqTmRFkXS59UyXcV3OwNgKi4=").is_some()); + assert!(checksum("VBuPqTmRFkXS59UyXcV3OwNgKi4=").is_none()); + assert!(checksum("Q1VBuPqTmRFkXS59UyXcV3OwNg==").is_none()); + assert!(checksum("Q1VBuP*TmRFkXS59UyXcV3OwNgKi4=").is_none()); +} diff --git a/userland/capsule_linux_proofs/src/tests/auth_tests.rs b/userland/capsule_linux_proofs/src/tests/auth_tests.rs new file mode 100644 index 0000000000..aec50bd12d --- /dev/null +++ b/userland/capsule_linux_proofs/src/tests/auth_tests.rs @@ -0,0 +1,73 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! Package authentication, against an index and a package laid out the way +//! Alpine lays them out (see vectors/auth/make_vectors.py). + +use rsa::pkcs8::DecodePublicKey; +use rsa::{Pkcs1v15Sign, RsaPublicKey}; + +use crate::install::auth::keys::spki; +use crate::install::auth::package::verified; +use crate::install::auth::signature::signed_index; +use crate::install::index::Index; +use crate::install::tar::entries; + +pub(super) const INDEX: &[u8] = include_bytes!("../../vectors/auth/APKINDEX.tar.gz"); +pub(super) const APK: &[u8] = include_bytes!("../../vectors/auth/hello.apk"); +const TEST_KEY: &[u8] = include_bytes!("../../vectors/auth/test_key.spki"); +const NAMED: &[u8] = b"alpine-devel@lists.alpinelinux.org-6165ee59.rsa.pub"; + +/// The crypto service's SHA-1 check, with the vectors' signer standing in for +/// Alpine. The key the capsule looked up must still be the one the entry names. +pub(super) fn rsa(key: &[u8], sig: &[u8], hashid: u8, digest: &[u8]) -> bool { + assert_eq!(Some(key.to_vec()), spki(NAMED), "the capsule looked up another key"); + let Ok(test) = RsaPublicKey::from_public_key_der(TEST_KEY) else { + return false; + }; + hashid == 3 && test.verify(Pkcs1v15Sign::new::(), digest, sig).is_ok() +} + +/// The checksum the signed index records for `hello`. +pub(super) fn record() -> [u8; 20] { + let tar = signed_index(INDEX, &rsa).expect("the index must verify"); + let body = entries(&tar).into_iter().find(|e| e.name == b"APKINDEX").expect("APKINDEX"); + let index = Index::parse(&body.body); + index.by_name("hello").and_then(|p| p.checksum).expect("a checksum for hello") +} + +#[test] +fn a_signed_index_opens_and_carries_the_package_checksum() { + let _ = record(); +} + +#[test] +fn a_package_matching_its_record_yields_its_files() { + let files = verified(APK, &record()).expect("the package must verify"); + let found = entries(files.files()); + assert_eq!(found.len(), 1); + assert_eq!(found[0].name, b"usr/bin/hello"); +} + +#[test] +fn every_embedded_alpine_key_decodes_to_a_public_key() { + for (id, len) in [("4a6a0840", 294), ("5261cecb", 294), ("6165ee59", 550)] { + let name = format!("alpine-devel@lists.alpinelinux.org-{id}.rsa.pub"); + let der = spki(name.as_bytes()).expect("the key must decode"); + assert_eq!(der.len(), len, "{id}"); + assert!(RsaPublicKey::from_public_key_der(&der).is_ok(), "{id}"); + } +} diff --git a/userland/capsule_linux_proofs/vectors/auth/APKINDEX.tar.gz b/userland/capsule_linux_proofs/vectors/auth/APKINDEX.tar.gz new file mode 100644 index 0000000000000000000000000000000000000000..6d82d2a6550878f04ba94c944ae9291042b83755 GIT binary patch literal 608 zcmV-m0-yaKiwFP!000021JeukbobK>3U<^>%qhssOVv$DElbUD$jK}&DTWH=WagDt z=;ars>zWywnWmD}78*FA#NqVFU%E${)x<0~ zviwvgr+%ocFkkBDsvi{EZwc001A02mk;800065?bE?( z!cY)};jHgd_yU%>;Tl80MTm+)(bTB86UB&v8c8D9JiTc*1s8Q?O6mV?W|$e6)#>)) zU>ZiVa5A=DMJkRlU$ydBMYR3hf6leGTgGaLRfB6@th4HUmZrr|chB~JRFULG<8a<4 zHw*ue9}UBCx3_%dkEi)>n}_^MTgZPc0`Fa)Mx(61xlY}9Zhveq3mw~KdGQCkKsqY& u_Q~;>s)eWDq?w8Gy_@T-9HSKJ0RR910000000000_3U<^>%qhssOVv$DElbUD$jK}&DTWH=WagDt z=;ars>zWywnWmsU5q;)9crGpVB@>?k6T>cT$<2ZBhb;x_DLkNS1QZ0EKwq~JV)F7 z7)QtoONQR*G3(5ZALBQ^y6#`+6+2}cxefXpfwk__Ii~LapYE>nCnlocpizKLU1`P6 zl3u4b>|YYMKU0x?_Ir`!#N!$N_ts24ee+C!udB{Aqllqcv$Y#H z$fvxv+pPcXliyU91CHO1D{0z2gI7fa^%r)#sRgT*wEF!91rHwdq$v1U7-nW+ip_GqSk2br zjX=U`ebvOHf;9K;mP@dv6gh|wa2iI8X+HM-`0Vdh|Dzmt+XL=HKF!g-b1lYRPN?-vCC000000000000000IJ`H0e`kaMC;$L)uumoc literal 0 HcmV?d00001 diff --git a/userland/capsule_linux_proofs/vectors/auth/make_vectors.py b/userland/capsule_linux_proofs/vectors/auth/make_vectors.py new file mode 100755 index 0000000000..be1dfe4326 --- /dev/null +++ b/userland/capsule_linux_proofs/vectors/auth/make_vectors.py @@ -0,0 +1,82 @@ +#!/usr/bin/env python3 +# NONOS Operating System +# Copyright (C) 2026 NONOS Contributors +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +"""Make the package-authentication vectors in Alpine's own layout. + +An index is two gzip members, a signature tar and the index tar; a package +is three: a signature, a control member holding .PKGINFO, and the data. The +signature and control tars are cut before their end-of-archive blocks, as +abuild-tar --cut leaves them, and each signature is PKCS#1 v1.5 over SHA-1 +of the member it covers. The key is a throwaway, not Alpine's. +""" +import argparse +import base64 +import gzip +import hashlib +import io +import subprocess +import tarfile +from pathlib import Path + +NAMED = "alpine-devel@lists.alpinelinux.org-6165ee59.rsa.pub" + + +def tar(entries, cut): + buf = io.BytesIO() + with tarfile.open(fileobj=buf, mode="w", format=tarfile.USTAR_FORMAT) as t: + for name, data in entries: + info = tarfile.TarInfo(name) + info.size, info.uname, info.gname = len(data), "root", "root" + t.addfile(info, io.BytesIO(data)) + raw = buf.getvalue() + while cut and raw.endswith(b"\0" * 512): + raw = raw[:-512] + return raw + + +def gz(data): + return gzip.compress(data, mtime=0) + + +def signed(key, data, name=NAMED): + sig = subprocess.run(["openssl", "dgst", "-sha1", "-sign", str(key)], + input=data, capture_output=True, check=True).stdout + return gz(tar([(".SIGN.RSA." + name, sig)], True)) + data + + +def main(): + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("--key", type=Path, required=True, help="PEM RSA private key") + ap.add_argument("--out", type=Path, default=Path(__file__).parent) + a = ap.parse_args() + data = gz(tar([("usr/bin/hello", b"\x7fELF a test payload\n")], False)) + other = gz(tar([("usr/bin/hello", b"\x7fELF something else\n")], False)) + info = f"pkgname = hello\npkgver = 1.0-r0\ndatahash = {hashlib.sha256(data).hexdigest()}\n" + control = gz(tar([(".PKGINFO", info.encode())], True)) + (a.out / "hello.apk").write_bytes(signed(a.key, control) + data) + (a.out / "swapped.apk").write_bytes(signed(a.key, control) + other) + sum_ = "Q1" + base64.b64encode(hashlib.sha1(control).digest()).decode() + record = f"C:{sum_}\nP:hello\nV:1.0-r0\nA:x86_64\n\n".encode() + index = gz(tar([("DESCRIPTION", b"test index"), ("APKINDEX", record)], False)) + (a.out / "APKINDEX.tar.gz").write_bytes(signed(a.key, index)) + (a.out / "untrusted.tar.gz").write_bytes(signed(a.key, index, "someone-else.rsa.pub")) + der = subprocess.run(["openssl", "rsa", "-in", str(a.key), "-pubout", "-outform", "DER"], + capture_output=True, check=True).stdout + (a.out / "test_key.spki").write_bytes(der) + + +if __name__ == "__main__": + main() diff --git a/userland/capsule_linux_proofs/vectors/auth/swapped.apk b/userland/capsule_linux_proofs/vectors/auth/swapped.apk new file mode 100644 index 0000000000000000000000000000000000000000..5d9168f7031fa80887e018c7a81c0c6926919d3e GIT binary patch literal 710 zcmV;%0y+I3iwFP!000021JeukbobK>3U<^>%qhssOVv$DElbUD$jK}&DTWH=WagDt z=;ars>zWywnWmsU5q;)9crGpVB@>?k6T>cT$<2ZBhb;x_DLkNS1QZ0EKwq~JV)F7 z7)QtoONQR*G3(5ZALBQ^y6#`+6+2}cxefXpfwk__Ii~LapYE>nCnlocpizKLU1`P6 zl3u4b>|YYMKU0x?_Ir`!#N!$N_ts24ee+C!udB{Aqllqcv$Y#H z$fvxv+pPcXliyU91CHO1D{0z2gI7fa^%r)#sRgT*wEF!91rHwdq$v1U7-nW+ip_GqSk2br zjX=U`ebvOHf;9K;mP@dv6gh|wa2iI8X+HM-`0Vdh|Dzmt+XL=HKF!|83szW?<$R%I7$hgLTfUV@TE*vt{<1BDML>YhTq>BT9=p!5mW`^84U@`ggat szp3TXwL1E@O~W|7v~|%I3IG5A0000000000003}#A4)F4^#CXU06m9Gm;e9( literal 0 HcmV?d00001 diff --git a/userland/capsule_linux_proofs/vectors/auth/test_key.spki b/userland/capsule_linux_proofs/vectors/auth/test_key.spki new file mode 100644 index 0000000000000000000000000000000000000000..f70a42445960b807225f799e64950e6942c2175c GIT binary patch literal 294 zcmV+>0ondAf&n5h4F(A+hDe6@4FLfG1potr0S^E$f&mHwf&l>lvx)~=s265ub1!+q zVg@iBCascwwm6crBYbXmn$x}kE!$0Qufoe@qnf!w6|;eTcOxsQXG;yx7&5fHrui?}1Z-~4&UKpRyHP)NT6aIcK45jCbm!3OPir;|XC@lJ%s-89 zptt7THa$q9El8fQj`B>_OR&0eI>09bFx?mR4Tx`Cgcfe>M-s~|B5M>k-B0*f-q0s{d60TGpi?*IS* literal 0 HcmV?d00001 diff --git a/userland/capsule_linux_proofs/vectors/auth/untrusted.tar.gz b/userland/capsule_linux_proofs/vectors/auth/untrusted.tar.gz new file mode 100644 index 0000000000000000000000000000000000000000..680312397b90ac14aab9481089020f26733a95ba GIT binary patch literal 583 zcmV-N0=WGjiwFP!000021JeukbobK>3U<^h&d*KF&r8)!%_&aRD=JRZD=1B3pb9Vm z0y7g6FbxFgw1Ei{8!T>UVrFj6pkTm22T)pEl2`<^z9>Jx1fM!W@0A(6KxBvtI03VA80000000IN;)4^)OP!NUTtnX9!0+zYq8biQEh>Ahc)Tp=< z#fX9$Ng~)hy=gZE7jHlqJm>HPW>GtAa8b-5lGPYhtDvmKo|=aNZ_23;&QG4a0G_w|wP~r}=N2hx|)h$bT&Y z?_HlpqpZKVPThEJe{3%c9ouDj@dvv=Ix6z^$?=$~g{R-7nThhfo9nC`qZH`@00000 V000000002^eRtri#rOay004qo9BKdn literal 0 HcmV?d00001 diff --git a/userland/nonos_tls/src/lib.rs b/userland/nonos_tls/src/lib.rs index 855dd46ddd..201ab37443 100644 --- a/userland/nonos_tls/src/lib.rs +++ b/userland/nonos_tls/src/lib.rs @@ -117,6 +117,7 @@ pub use client_flight::client_flight; pub use handshake_alert::handshake_alert; pub use handshake_fault::handshake_fault; pub use rtc_now::rtc_now; +pub use verify_rsa::verify_rsa; pub use server_complete::{server_complete, server_complete_unauthenticated, ServerComplete}; pub use server_finished_flight_ready::server_finished_flight_ready; pub use session::{exchange, Io, SessionError}; From 80318407adfe7ba9f7e372169a98fac9343d8302 Mon Sep 17 00:00:00 2001 From: eKisNonos Date: Sat, 26 Sep 2026 14:18:35 +0000 Subject: [PATCH 016/244] market: exempt a release from shipping a proof by namespace, not by arch A release naming x86_64-linux counted as having its attestation, so any release could claim the exemption for itself. It now needs the linux. namespace too, which is where the store routes it: to the installer that authenticates the bytes before the machine mints their proof. --- .github/workflows/verify.yml | 1 + .../src/install_ready/checks.rs | 18 +++-- .../server/handlers/install_ready/handle.rs | 2 +- .../src/server/handlers/list_apps/handle.rs | 8 ++- userland/market_proofs/Cargo.lock | 14 ++++ userland/market_proofs/Cargo.toml | 18 +++++ .../market_proofs/src/install_ready/mod.rs | 24 +++++++ userland/market_proofs/src/lib.rs | 25 +++++++ userland/market_proofs/src/readiness_tests.rs | 68 +++++++++++++++++++ 9 files changed, 171 insertions(+), 7 deletions(-) create mode 100644 userland/market_proofs/Cargo.lock create mode 100644 userland/market_proofs/Cargo.toml create mode 100644 userland/market_proofs/src/install_ready/mod.rs create mode 100644 userland/market_proofs/src/lib.rs create mode 100644 userland/market_proofs/src/readiness_tests.rs diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml index 53b1bea09a..5846d829b1 100644 --- a/.github/workflows/verify.yml +++ b/.github/workflows/verify.yml @@ -344,6 +344,7 @@ jobs: - capsule_crypto_proofs - aes_proofs - capsule_linux_proofs + - market_proofs steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v4.2.2 with: diff --git a/userland/capsule_market/src/install_ready/checks.rs b/userland/capsule_market/src/install_ready/checks.rs index e16dba2982..d65c6755da 100644 --- a/userland/capsule_market/src/install_ready/checks.rs +++ b/userland/capsule_market/src/install_ready/checks.rs @@ -20,12 +20,14 @@ use super::arch::{HOSTED_ARCH, RUNNING_ARCH}; pub const RUNNING_KERNEL_ABI: u32 = 1; -/// Releases carrying this arch are distribution packages the personality -/// hosts. -const LOCAL_ARCH: &str = HOSTED_ARCH; +/// Listings under this namespace are distribution packages. The store sends +/// them to the Linux installer, which authenticates the bytes against the +/// distribution's own signatures and has the machine mint their proof. +const HOSTED_NAMESPACE: &str = "linux."; pub fn evaluate( signature_verified: bool, + listing_id: &str, release: &CapsuleRelease, publisher_signature_verified: bool, ) -> InstallReadiness { @@ -39,8 +41,14 @@ pub fn evaluate( }; let arch_match = release.supported_arches.iter().any(runs_here); let kernel_abi_compatible = release.kernel_abi_min <= RUNNING_KERNEL_ABI; - // Everything above this line is somebody's word. - let minted_locally = release.supported_arches.iter().any(|a| a.as_str() == LOCAL_ARCH); + /* + * Exempting a release from shipping a proof because it names an arch let + * any release exempt itself. The exemption now follows the namespace the + * store routes on, so a release earns it only by going where the proof + * is minted after its bytes are authenticated. + */ + let minted_locally = listing_id.starts_with(HOSTED_NAMESPACE) + && release.supported_arches.iter().any(|a| a.as_str() == HOSTED_ARCH); let ships_proof = release.zk_trailer_hash.iter().any(|&b| b != 0); let attestation_present = ships_proof || minted_locally; diff --git a/userland/capsule_market/src/server/handlers/install_ready/handle.rs b/userland/capsule_market/src/server/handlers/install_ready/handle.rs index 99be95d450..fc8c937087 100644 --- a/userland/capsule_market/src/server/handlers/install_ready/handle.rs +++ b/userland/capsule_market/src/server/handlers/install_ready/handle.rs @@ -38,7 +38,7 @@ pub(crate) fn handle(store: &Store, body: &[u8], req: &Request, tx: &mut [u8]) { None => return reply_status(tx, req, E_NODATA), }; let publisher_ok = accepted.publisher_signature_verified(entry_index, release_index); - let verdict = evaluate(accepted.signature_verified, release, publisher_ok); + let verdict = evaluate(accepted.signature_verified, listing_id, release, publisher_ok); let slot = match body_slot(tx, READINESS_LEN) { Some(s) => s, None => return reply_status(tx, req, E_INVAL), diff --git a/userland/capsule_market/src/server/handlers/list_apps/handle.rs b/userland/capsule_market/src/server/handlers/list_apps/handle.rs index 14892b22b9..f141c8f2e6 100644 --- a/userland/capsule_market/src/server/handlers/list_apps/handle.rs +++ b/userland/capsule_market/src/server/handlers/list_apps/handle.rs @@ -36,7 +36,13 @@ pub(crate) fn handle(store: &Store, req: &Request, tx: &mut [u8]) { for (entry_index, entry) in accepted.index.entries.iter().enumerate() { let any_ready = entry.releases.iter().enumerate().any(|(release_index, rel)| { let publisher_ok = accepted.publisher_signature_verified(entry_index, release_index); - install_ready::evaluate(accepted.signature_verified, rel, publisher_ok).install_ready + install_ready::evaluate( + accepted.signature_verified, + &entry.listing_id, + rel, + publisher_ok, + ) + .install_ready }); write_lp_string(&mut body, &entry.listing_id); body.extend_from_slice(&entry.capsule_id); diff --git a/userland/market_proofs/Cargo.lock b/userland/market_proofs/Cargo.lock new file mode 100644 index 0000000000..6a2485e080 --- /dev/null +++ b/userland/market_proofs/Cargo.lock @@ -0,0 +1,14 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "nonos_market_proofs" +version = "0.1.0" +dependencies = [ + "nonos_marketplace_abi", +] + +[[package]] +name = "nonos_marketplace_abi" +version = "0.3.0" diff --git a/userland/market_proofs/Cargo.toml b/userland/market_proofs/Cargo.toml new file mode 100644 index 0000000000..7cf44503ae --- /dev/null +++ b/userland/market_proofs/Cargo.toml @@ -0,0 +1,18 @@ +# NØNOS userland: market_proofs +# +# Host proofs for the market capsule's install readiness gate. The shipped +# source is included through #[path], so a proof here constrains the code +# that runs and not a copy of it. + +[package] +name = "nonos_market_proofs" +version = "0.1.0" +edition = "2021" +publish = false +license = "AGPL-3.0" + +[lib] +path = "src/lib.rs" + +[dependencies] +nonos_marketplace_abi = { path = "../marketplace_abi" } diff --git a/userland/market_proofs/src/install_ready/mod.rs b/userland/market_proofs/src/install_ready/mod.rs new file mode 100644 index 0000000000..f1c04ebeda --- /dev/null +++ b/userland/market_proofs/src/install_ready/mod.rs @@ -0,0 +1,24 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + + +//! The gate's two files, mounted where the capsule keeps them. + +#[path = "../../../capsule_market/src/install_ready/arch.rs"] +pub mod arch; + +#[path = "../../../capsule_market/src/install_ready/checks.rs"] +pub mod checks; diff --git a/userland/market_proofs/src/lib.rs b/userland/market_proofs/src/lib.rs new file mode 100644 index 0000000000..87363f7f1f --- /dev/null +++ b/userland/market_proofs/src/lib.rs @@ -0,0 +1,25 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + + +//! The market capsule's readiness gate, included and exercised. + +extern crate alloc; + +pub mod install_ready; + +#[cfg(test)] +mod readiness_tests; diff --git a/userland/market_proofs/src/readiness_tests.rs b/userland/market_proofs/src/readiness_tests.rs new file mode 100644 index 0000000000..49db4f8d4c --- /dev/null +++ b/userland/market_proofs/src/readiness_tests.rs @@ -0,0 +1,68 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + + +//! Which releases the gate offers, and which it holds back. + +use alloc::string::String; +use alloc::vec; + +use nonos_marketplace_abi::{CapsuleRelease, ValidationReport, ValidationStatus}; + +use crate::install_ready::checks::evaluate; + +fn release(arch: &str, trailer: u8) -> CapsuleRelease { + CapsuleRelease { + release_id: String::from("pkg@1"), + manifest_hash: [1; 32], + package_hash: [2; 32], + package_url: String::from("http://mirror/pkg-1.apk"), + publisher_signature: vec![0; 64], + supported_arches: vec![String::from(arch)], + kernel_abi_min: 1, + required_capabilities: vec![], + zk_trailer_hash: [trailer; 32], + validation: ValidationReport { + status: ValidationStatus::Validated, + note: String::new(), + validator_id: String::from("v"), + validated_at_ms: 1, + }, + } +} + +#[test] +fn a_distribution_package_is_ready_without_shipping_a_proof() { + assert!(evaluate(true, "linux.pkg", &release("x86_64-linux", 0), true).install_ready); +} + +#[test] +fn naming_the_hosted_arch_does_not_exempt_a_capsule_from_its_proof() { + let r = evaluate(true, "nonos.app.pkg", &release("x86_64-linux", 0), true); + assert!(!r.install_ready && !r.attestation_present); +} + +#[test] +fn a_capsule_that_ships_a_proof_is_ready() { + assert!(evaluate(true, "nonos.app.pkg", &release("x86_64-nonos", 9), true).install_ready); +} + +#[test] +fn no_signature_no_readiness() { + let rel = release("x86_64-linux", 0); + assert!(!evaluate(false, "linux.pkg", &rel, true).install_ready); + assert!(!evaluate(true, "linux.pkg", &rel, false).install_ready); +} From 73c677c315c5db9847917f8f4397354e6be6d4d8 Mon Sep 17 00:00:00 2001 From: eKisNonos Date: Sat, 26 Sep 2026 14:24:19 +0000 Subject: [PATCH 017/244] init: raise the drain from a syscall only with interrupts off The scheduler takes every ready process's priority lock from the timer interrupt. wake.rs and boost_init_for_drain took init's with interrupts on from syscall context, so a tick inside either spun forever on one CPU. The install queue now raises through the guarded setter the window queue uses. --- src/syscall/microkernel/spawn_instance.rs | 18 ------ src/userspace/init/install_queue.rs | 9 ++- src/userspace/init/instance_spawn/mod.rs | 1 + src/userspace/init/instance_spawn/priority.rs | 16 +++++- src/userspace/init/instance_spawn/queue.rs | 18 +++--- src/userspace/init/mod.rs | 5 +- src/userspace/init/supervisor/loop_impl.rs | 54 +++++++++++------- src/userspace/init/wake.rs | 56 ------------------- 8 files changed, 68 insertions(+), 109 deletions(-) delete mode 100644 src/userspace/init/wake.rs diff --git a/src/syscall/microkernel/spawn_instance.rs b/src/syscall/microkernel/spawn_instance.rs index 9731b46aec..eebcda44f1 100644 --- a/src/syscall/microkernel/spawn_instance.rs +++ b/src/syscall/microkernel/spawn_instance.rs @@ -45,9 +45,6 @@ pub fn sys_spawn_instance(name_ptr: u64, name_len: u64) -> i64 { Err(_) => return ERRNO_INVAL, }; let result = queue_by_name(name); - if result >= 0 { - boost_init_for_drain(); - } // Land every request in the boot log so the on-demand path is observable. crate::sys::serial::print(b"[SPAWN-INSTANCE] queued "); crate::sys::serial::print(name.as_bytes()); @@ -55,21 +52,6 @@ pub fn sys_spawn_instance(name_ptr: u64, name_len: u64) -> i64 { result } -// The queued window spawn is drained by init, which runs at Priority::Low so an -// idle desktop leaves its cycles to the apps. A busy-yielding app with a fetch -// in flight can then starve a low-priority init off a single CPU, and the drain -// never runs, so the second window never opens. This syscall runs in the -// scheduled caller (the shell), so lift init to Normal here: init cannot boost -// itself once starved, but the click that needs the window can. Init drops back -// to Low from its own loop once the queue empties. Init is pid 1, the first -// process the kernel creates, before any capsule. -fn boost_init_for_drain() { - const INIT_PID: u32 = 1; - if let Some(pcb) = crate::process::core::PROCESS_TABLE.find_by_pid(INIT_PID) { - *pcb.priority.lock() = crate::process::core::Priority::Normal; - } -} - // Map a handle to an app that declares instance endpoints, and queue it. // An unknown handle is rejected; a full queue asks the caller to retry. fn queue_by_name(name: &str) -> i64 { diff --git a/src/userspace/init/install_queue.rs b/src/userspace/init/install_queue.rs index 86b7258af4..9da40f29a7 100644 --- a/src/userspace/init/install_queue.rs +++ b/src/userspace/init/install_queue.rs @@ -37,10 +37,17 @@ pub(crate) fn request(package: String) -> bool { return false; } q.push(package); - super::wake::nudge(); + drop(q); + super::instance_spawn::raise_drain(); true } +/// Whether an install is waiting. A contended lock is a push in flight, which +/// counts as waiting rather than risking a missed boost. +pub(crate) fn has_pending() -> bool { + PENDING.try_lock().map_or(true, |q| !q.is_empty()) +} + /// Perform every queued install. pub(crate) fn service() { let taken: Vec = core::mem::take(&mut *PENDING.lock()); diff --git a/src/userspace/init/instance_spawn/mod.rs b/src/userspace/init/instance_spawn/mod.rs index 6330fd0d46..b9c23d55e8 100644 --- a/src/userspace/init/instance_spawn/mod.rs +++ b/src/userspace/init/instance_spawn/mod.rs @@ -37,6 +37,7 @@ mod request; mod service; pub(super) use priority::adopt as adopt_drain_pid; +pub(super) use priority::{raise as raise_drain, set as set_drain_priority}; pub(crate) use queue::has_pending; pub use queue::PendingApp; pub use request::request; diff --git a/src/userspace/init/instance_spawn/priority.rs b/src/userspace/init/instance_spawn/priority.rs index 57b6ff8829..eda3cd2339 100644 --- a/src/userspace/init/instance_spawn/priority.rs +++ b/src/userspace/init/instance_spawn/priority.rs @@ -42,9 +42,14 @@ pub(in crate::userspace::init) fn adopt(pid: u32) { } /// Lift the drain out of the band the scheduler reaches only when nothing -/// else is ready. Called with the queue lock held, right after a push. -pub(super) fn raise() { +/// else is ready, and wake it if it is parked between passes. Called right +/// after a push, from the syscall that queued the work. +pub(in crate::userspace::init) fn raise() { set(Priority::Normal); + let pid = INIT_PID.load(Ordering::Relaxed); + if pid != 0 { + crate::sched::wake_process(pid); + } } /// Hand the CPU back to the capsules. Called with the queue lock held, @@ -53,7 +58,12 @@ pub(super) fn restore() { set(Priority::Low); } -fn set(prio: Priority) { +/* + * The scheduler takes every ready process's priority lock from the timer + * interrupt, so the lock is only ever held here with interrupts off: taken + * with them on, a tick landing inside it spins forever on one CPU. + */ +pub(in crate::userspace::init) fn set(prio: Priority) { let pid = INIT_PID.load(Ordering::Relaxed); if pid == 0 { return; diff --git a/src/userspace/init/instance_spawn/queue.rs b/src/userspace/init/instance_spawn/queue.rs index 86dd56dd0a..c245e2cd21 100644 --- a/src/userspace/init/instance_spawn/queue.rs +++ b/src/userspace/init/instance_spawn/queue.rs @@ -41,7 +41,9 @@ pub enum PendingApp { impl PendingApp { /// The capsule name behind this request, for the one place it matters: a - /// spawn that was refused. + /// spawn that was refused. The drain used to report the error alone, so a + /// dock icon that had quietly stopped opening looked identical on the wire + /// to one that had never been clicked. pub(super) fn name(self) -> &'static [u8] { match self { PendingApp::Terminal => b"app.terminal", @@ -71,10 +73,6 @@ pub(super) static PENDING: Mutex> = Mutex::new(Vec::new()); /// Record a spawn request. Returns false only when the queue is saturated, /// which the caller treats as "try again", never as a hard failure. pub(super) fn push(app: PendingApp) -> bool { - // Raising init is part of queueing, not a separate courtesy: work left in - // a queue nobody is scheduled to drain is work that never happens, and the - // caller was told it was accepted. - super::super::wake::nudge(); let mut q = PENDING.lock(); if q.len() >= MAX_PENDING { return false; @@ -84,7 +82,10 @@ pub(super) fn push(app: PendingApp) -> bool { true } -/// Return init to its idle band once nothing is left to spawn. +/// Return init to its idle band once nothing is left to spawn. The check and +/// the demotion happen under the queue lock, the same lock `push` raises +/// under, so a click landing here can never be left queued behind a +/// demotion it raced. pub(super) fn settle() { let Some(q) = PENDING.try_lock() else { return; @@ -106,7 +107,10 @@ pub(super) fn take() -> Vec { core::mem::take(&mut *q) } -/// Whether any window-instance request is waiting to be drained. +/// Whether any window-instance request is waiting to be drained. The init loop +/// reads this to raise its priority only while there is deferred window work. A +/// contended lock means a push is in flight, which is itself pending work, so it +/// counts as pending rather than risking a missed boost. pub(crate) fn has_pending() -> bool { match PENDING.try_lock() { Some(q) => !q.is_empty(), diff --git a/src/userspace/init/mod.rs b/src/userspace/init/mod.rs index 43652ef307..4e8455352b 100644 --- a/src/userspace/init/mod.rs +++ b/src/userspace/init/mod.rs @@ -17,15 +17,14 @@ mod capsule_boot; mod entry; mod install_queue; -mod wake; - -pub(crate) use wake::{nudge as nudge_init, owns_the_queues, settle as settle_priority}; mod instance_spawn; +use instance_spawn::set_drain_priority as set_init_priority; mod spawn_plan; mod supervisor; pub use entry::run_init; pub(crate) use install_queue::request as request_install; +pub(crate) use install_queue::has_pending as installs_pending; pub(crate) use install_queue::service as service_installs; pub(crate) use instance_spawn::has_pending as instance_spawns_pending; pub(crate) use instance_spawn::service as service_instance_spawns; diff --git a/src/userspace/init/supervisor/loop_impl.rs b/src/userspace/init/supervisor/loop_impl.rs index 6417d4b3e1..faa594b189 100644 --- a/src/userspace/init/supervisor/loop_impl.rs +++ b/src/userspace/init/supervisor/loop_impl.rs @@ -14,7 +14,11 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -//! Init's residual loop after every capsule has been spawned. +//! Init's residual loop after every capsule has been spawned. Walks +//! the lifecycle registry once per second; any capsule that exited is +//! observed `Dead` on its next IPC. The kernel does not actively +//! probe capsules — liveness arrives through the existing process +//! state machine. use crate::process::core::Priority; @@ -22,13 +26,10 @@ const TICK_INTERVAL_MS: u64 = 1000; const PARK_SLICE_MS: u64 = 20; pub(crate) fn init_loop() -> ! { - // Tell the queue side which process drains it. - if let Some(pid) = crate::process::current_pid() { - crate::userspace::init::owns_the_queues(pid); - } let mut last_tick = 0u64; #[cfg(feature = "microkernel-setup-wizard")] let mut desktop_started = false; + let mut boosted = false; loop { let now = crate::time::timestamp_millis(); if now >= last_tick + TICK_INTERVAL_MS { @@ -40,21 +41,36 @@ pub(crate) fn init_loop() -> ! { super::super::spawn_plan::spawn_post_wizard(); desktop_started = true; } - // Perform any window-instance spawns the shell requested. + // Init runs at Priority::Low so an idle system spends its cycles on the + // apps, but the window-instance drain below (and the focus-frame + // delivery inside it) must not be starved: a busy-yielding app with a + // network fetch in flight would otherwise keep a low-priority init off + // the single CPU, so a dock click never opened its second window. Raise + // to Normal while there is queued window work and drop back to Low when + // idle, so the drain runs promptly without making an idle init costly. + let want = crate::userspace::init::instance_spawns_pending() + || crate::userspace::init::installs_pending(); + if want != boosted { + set_init_priority(if want { Priority::Normal } else { Priority::Low }); + boosted = want; + } + // Perform any window-instance spawns the shell requested. Running + // them here, in init's context, keeps the heavy spawn out of the + // calling capsule's syscall, which is what stopped the caller from + // resuming (it faulted on its own code under the wrong page tables). crate::userspace::init::service_instance_spawns(); crate::userspace::init::service_installs(); - // Back to Low now the queues are empty. Raising is the - // producer's job; only this loop can know when to stop. - if !crate::userspace::init::instance_spawns_pending() { - crate::userspace::init::settle_priority(); - } park(); } } -// A bare yield left init permanently runnable, so `select_next_process` never -// came up empty and the scheduler's `sti; hlt` idle path was unreachable: the -// vCPU spun at full load with an idle desktop. +// A bare yield left init permanently runnable, so `select_next_process` +// never came up empty and the scheduler's `sti; hlt` idle path was +// unreachable: the vCPU spun at full load with an idle desktop. Sleeping +// on a short deadline takes init off the run queue between passes, which +// lets the CPU actually halt, while still draining the shell's window +// spawn requests inside one compositor frame. Falling back to the yield +// keeps the loop live if init runs before its pid is current. fn park() { let Some(pid) = crate::process::current_pid() else { crate::sched::yield_now(); @@ -65,12 +81,8 @@ fn park() { crate::sched::yield_now(); } -// Set init's own scheduling priority. +// Set init's own scheduling priority, through the one setter that holds the +// lock with interrupts off. fn set_init_priority(p: Priority) { - use crate::process::core::{CURRENT_PID, PROCESS_TABLE}; - use core::sync::atomic::Ordering; - let pid = CURRENT_PID.load(Ordering::Relaxed); - if let Some(pcb) = PROCESS_TABLE.find_by_pid(pid) { - *pcb.priority.lock() = p; - } + super::super::set_init_priority(p); } diff --git a/src/userspace/init/wake.rs b/src/userspace/init/wake.rs deleted file mode 100644 index 7d1b0bac16..0000000000 --- a/src/userspace/init/wake.rs +++ /dev/null @@ -1,56 +0,0 @@ -// NONOS Operating System -// Copyright (C) 2026 NONOS Contributors -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -//! Raising init when work is queued for it. - -use core::sync::atomic::{AtomicU32, Ordering}; - -use alloc::sync::Arc; - -use crate::process::core::{Priority, ProcessControlBlock, PROCESS_TABLE}; - -/// Recorded by init itself rather than assumed. -static INIT_PID: AtomicU32 = AtomicU32::new(0); - -/// Called once, by init, before it starts draining. -pub(crate) fn owns_the_queues(pid: u32) { - INIT_PID.store(pid, Ordering::Release); -} - -fn init_pcb() -> Option> { - match INIT_PID.load(Ordering::Acquire) { - 0 => None, - pid => PROCESS_TABLE.find_by_pid(pid), - } -} - -/// Promote init so the work just queued is drained promptly, and wake it -/// in case it is asleep between passes. -pub(crate) fn nudge() { - let pid = INIT_PID.load(Ordering::Acquire); - if let Some(pcb) = init_pcb() { - *pcb.priority.lock() = Priority::Normal; - crate::sched::wake_process(pid); - } -} - -/// Drop back once the queues are empty. Called by init, the only place -/// that knows there is nothing left to do. -pub(crate) fn settle() { - if let Some(pcb) = init_pcb() { - *pcb.priority.lock() = Priority::Low; - } -} From df8729c3e2d2e99f3bb026addc9022e3649b9442 Mon Sep 17 00:00:00 2001 From: eKisNonos Date: Sat, 26 Sep 2026 14:31:04 +0000 Subject: [PATCH 018/244] install: re-ask the market, pin its hash, and fetch what a package needs MkAppInstall took a package name and the store's own readiness flag. It now takes a listing and release; init asks the market for readiness and the release's package hash, and the installer refuses bytes of any other BLAKE3. The index keeps each record's D: and p: lines, so dependencies come too. --- abi/syscalls.toml | 2 +- .../market_capsule/client/get_release.rs | 9 ++- .../market_capsule/client/install_ready.rs | 9 ++- src/security/market_capsule/client/mod.rs | 2 + src/syscall/microkernel/app_install.rs | 66 ++++++++++++------- src/syscall/microkernel/dispatch/process.rs | 2 +- src/userspace/capsule_linux/install.rs | 6 +- src/userspace/init/install_queue.rs | 60 +++++++++-------- src/userspace/init/supervisor/loop_impl.rs | 3 +- .../capsule_app_store/src/store/install.rs | 13 ++-- userland/capsule_linux/Cargo.lock | 59 ++++++++++++++++- userland/capsule_linux/Cargo.toml | 1 + .../capsule_linux/src/linux/install/fetch.rs | 43 ++++++++++++ .../capsule_linux/src/linux/install/index.rs | 61 +++++++++-------- .../capsule_linux/src/linux/install/mod.rs | 2 + .../capsule_linux/src/linux/install/pkg.rs | 52 +++++++++++++++ .../capsule_linux/src/linux/install/run.rs | 37 ++++++----- userland/capsule_linux/src/linux/request.rs | 21 ++++-- userland/capsule_linux/src/linux/start.rs | 4 +- .../capsule_linux_proofs/src/install/mod.rs | 3 + userland/capsule_linux_proofs/src/tests.rs | 1 + .../src/tests/index_tests.rs | 31 +++++++++ userland/libc/src/local_sign.rs | 9 +-- 23 files changed, 373 insertions(+), 123 deletions(-) create mode 100644 userland/capsule_linux/src/linux/install/fetch.rs create mode 100644 userland/capsule_linux/src/linux/install/pkg.rs create mode 100644 userland/capsule_linux_proofs/src/tests/index_tests.rs diff --git a/abi/syscalls.toml b/abi/syscalls.toml index 1ded43928e..c39db0951f 100644 --- a/abi/syscalls.toml +++ b/abi/syscalls.toml @@ -686,7 +686,7 @@ ret = {type="i64"} [desc.MAIN] nr = 0x4E49414D caps = ["AppInstall"] -args = [{name="name_ptr",type="u64",dir="in"},{name="name_len",type="u64",dir="in"}] +args = [{name="listing_ptr",type="u64",dir="in"},{name="listing_len",type="u64",dir="in"},{name="release_ptr",type="u64",dir="in"},{name="release_len",type="u64",dir="in"}] ret = {type="i64"} [desc.MDRO] diff --git a/src/security/market_capsule/client/get_release.rs b/src/security/market_capsule/client/get_release.rs index 5d3239d2fb..4feb6eb93d 100644 --- a/src/security/market_capsule/client/get_release.rs +++ b/src/security/market_capsule/client/get_release.rs @@ -39,7 +39,14 @@ pub struct ReleaseSummary { } pub fn get_release(listing_id: &str, release_id: &str) -> Result { - let _caller = gate_call()?; + gate_call()?; + queued_get_release(listing_id, release_id) +} + +/// Without the caller gate, for init's install drain: the request it serves +/// passed `can_app_install` in the syscall that queued it, and init is not +/// the caller the gate is about. +pub(crate) fn queued_get_release(listing_id: &str, release_id: &str) -> Result { let mut body: Vec = Vec::with_capacity(8 + listing_id.len() + release_id.len()); body.extend_from_slice(&(listing_id.len() as u32).to_le_bytes()); body.extend_from_slice(listing_id.as_bytes()); diff --git a/src/security/market_capsule/client/install_ready.rs b/src/security/market_capsule/client/install_ready.rs index c949f1de59..de7fadb652 100644 --- a/src/security/market_capsule/client/install_ready.rs +++ b/src/security/market_capsule/client/install_ready.rs @@ -40,7 +40,14 @@ pub struct InstallReadiness { } pub fn install_ready(listing_id: &str, release_id: &str) -> Result { - let _caller = gate_call()?; + gate_call()?; + queued_install_ready(listing_id, release_id) +} + +/// Without the caller gate, for init's install drain: the request it serves +/// passed `can_app_install` in the syscall that queued it, and init is not +/// the caller the gate is about. +pub(crate) fn queued_install_ready(listing_id: &str, release_id: &str) -> Result { let mut body: Vec = Vec::with_capacity(8 + listing_id.len() + release_id.len()); body.extend_from_slice(&(listing_id.len() as u32).to_le_bytes()); body.extend_from_slice(listing_id.as_bytes()); diff --git a/src/security/market_capsule/client/mod.rs b/src/security/market_capsule/client/mod.rs index 546c196f81..f72a74e177 100644 --- a/src/security/market_capsule/client/mod.rs +++ b/src/security/market_capsule/client/mod.rs @@ -28,7 +28,9 @@ pub(super) use transport::REPLY_INBOX; pub use get_app::{get_app, AppSummary}; pub use get_release::{get_release, ReleaseSummary}; +pub(crate) use get_release::queued_get_release; pub use healthcheck::healthcheck; pub use install_ready::{install_ready, InstallReadiness}; +pub(crate) use install_ready::queued_install_ready; pub use list_apps::list_apps; pub use load_index::load_index; diff --git a/src/syscall/microkernel/app_install.rs b/src/syscall/microkernel/app_install.rs index 9e0d12ec33..04220baf46 100644 --- a/src/syscall/microkernel/app_install.rs +++ b/src/syscall/microkernel/app_install.rs @@ -14,39 +14,57 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -//! `MkAppInstall`: ask for a distribution package to be installed. + +//! `MkAppInstall`: ask for a marketplace listing to be installed. + +use alloc::string::String; use crate::syscall::microkernel::errnos::{ERRNO_BUSY, ERRNO_FAULT, ERRNO_INVAL}; use crate::usercopy::{read_user_bytes, validate_user_read}; -/// Long enough for any real package name and short enough that the -/// argument cannot become a payload. -const MAX_NAME: usize = 64; +/// Long enough for any real listing or release id and short enough that +/// neither argument can become a payload. +const MAX_ID: usize = 96; -/// `MkAppInstall(name_ptr, name_len)`. -pub fn sys_app_install(name_ptr: u64, name_len: u64) -> i64 { - let len = name_len as usize; - if len == 0 || len > MAX_NAME || validate_user_read(name_ptr, len).is_err() { - return ERRNO_INVAL; - } - let Ok(raw) = read_user_bytes(name_ptr, len) else { - return ERRNO_FAULT; +/// The only listings with anything to fetch are distribution packages. +const HOSTED: &str = "linux."; + +/// `MkAppInstall(listing_ptr, listing_len, release_ptr, release_len)`. An +/// empty release asks for the listing's default. Nothing the caller says +/// about readiness is taken: init asks the market before anything runs. +pub fn sys_app_install(listing_ptr: u64, listing_len: u64, release_ptr: u64, release_len: u64) -> i64 { + let listing = match id(listing_ptr, listing_len) { + Ok(Some(s)) => s, + Ok(None) => return ERRNO_INVAL, + Err(e) => return e, }; - /* - * The name reaches a URL and a store path, so it is held to what a package - * name actually is. - */ - if !raw.iter().all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'+' | b'.')) { - return ERRNO_INVAL; - } - if raw.first() == Some(&b'.') { + let release = match id(release_ptr, release_len) { + Ok(s) => s.unwrap_or_default(), + Err(e) => return e, + }; + if !listing.strip_prefix(HOSTED).is_some_and(|name| !name.is_empty() && !name.starts_with('.')) { return ERRNO_INVAL; } - let Ok(name) = alloc::string::String::from_utf8(raw) else { - return ERRNO_INVAL; - }; - match crate::userspace::init::request_install(name) { + match crate::userspace::init::request_install(listing, release) { true => 0, false => ERRNO_BUSY, } } + +/// One id argument. `None` for an empty one. The id reaches a URL and a store +/// path, so it is held to what a package id actually is. +fn id(ptr: u64, len: u64) -> Result, i64> { + let len = usize::try_from(len).map_err(|_| ERRNO_INVAL)?; + if len == 0 { + return Ok(None); + } + if len > MAX_ID || validate_user_read(ptr, len).is_err() { + return Err(ERRNO_INVAL); + } + let raw = read_user_bytes(ptr, len).map_err(|_| ERRNO_FAULT)?; + let allowed = |b: &u8| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'+' | b'.' | b'@'); + if !raw.iter().all(allowed) { + return Err(ERRNO_INVAL); + } + String::from_utf8(raw).map(Some).map_err(|_| ERRNO_INVAL) +} diff --git a/src/syscall/microkernel/dispatch/process.rs b/src/syscall/microkernel/dispatch/process.rs index 2ef1a136e4..176a0726b5 100644 --- a/src/syscall/microkernel/dispatch/process.rs +++ b/src/syscall/microkernel/dispatch/process.rs @@ -97,7 +97,7 @@ pub(super) fn handle(nr: u64, a: Args) -> Option { SYS_FOREIGN_EXEC => sys_foreign_exec(a.a0, a.a1, a.a2), SYS_LOCAL_SIGN => sys_local_sign(a.a0, a.a1, a.a2, a.a3, a.a4), SYS_LOCAL_VERIFY => sys_local_verify(a.a0, a.a1, a.a2, a.a3, a.a4), - SYS_APP_INSTALL => sys_app_install(a.a0, a.a1), + SYS_APP_INSTALL => sys_app_install(a.a0, a.a1, a.a2, a.a3), SYS_DEV_ROOT_LOCAL => sys_dev_root_local(), SYS_DEV_ROOT_REQUEST => sys_dev_root_request(a.a0), SYS_DEV_ROOT_CONFIRM => sys_dev_root_confirm(a.a0), diff --git a/src/userspace/capsule_linux/install.rs b/src/userspace/capsule_linux/install.rs index 829f803dc8..283f5f237a 100644 --- a/src/userspace/capsule_linux/install.rs +++ b/src/userspace/capsule_linux/install.rs @@ -36,7 +36,8 @@ const SERVICE_PORT: u32 = 4938; const REPLY_INBOX: &str = "endpoint.app.linux.install.reply"; const REPLY_PORT: u32 = 4939; -pub fn spawn_install(package: &str) -> Result { +/// Spawn the installer for `package`, which must hash to `pinned`. +pub fn spawn_install(package: &str, pinned: &[u8; 32]) -> Result { let trust_anchor = decode_trust_anchor(BAKED_TRUST_ANCHOR_POLICY) .map_err(|_| SpawnError::NonosIdCertRejected(IdCertVerifyError::TrustAnchorPolicy))?; let spec = CapsuleSpecVerified { @@ -53,7 +54,8 @@ pub fn spawn_install(package: &str) -> Result { debug_tag: b"[LINUX-INSTALL] elf error:", }; let pid = capsule_spawn::spawn_verified(&spec, &trust_anchor, None)?; - let argv = vec![String::from("install"), String::from(package)]; + let hex: String = pinned.iter().map(|b| alloc::format!("{b:02x}")).collect(); + let argv = vec![String::from("install"), String::from(package), hex]; crate::process::with_process(pid, |pcb| *pcb.argv.lock() = argv); Ok(pid) } diff --git a/src/userspace/init/install_queue.rs b/src/userspace/init/install_queue.rs index 9da40f29a7..e4ab4ed751 100644 --- a/src/userspace/init/install_queue.rs +++ b/src/userspace/init/install_queue.rs @@ -14,55 +14,61 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -//! Package installs asked for by a capsule, performed by init. -extern crate alloc; +//! Package installs asked for by a capsule, performed by init once the +//! market says the listing is ready and names the bytes to expect. use alloc::string::String; use alloc::vec::Vec; - use spin::Mutex; -/// Deep enough for a person clicking faster than a download completes, -/// shallow enough that a caller in a loop cannot grow it without bound. +use crate::security::market_capsule::client::{queued_get_release, queued_install_ready}; +use crate::sys::serial::{print, println}; + +/// Deeper than a person clicks, shallower than a caller in a loop can grow. const DEPTH: usize = 8; -static PENDING: Mutex> = Mutex::new(Vec::new()); +/// A listing and the release asked for, which is empty for the default. +static PENDING: Mutex> = Mutex::new(Vec::new()); -/// Record a request. False when the queue is full, which the caller -/// reports as busy rather than silently dropping. -pub(crate) fn request(package: String) -> bool { +/// Record a request. False when full, which the caller reports as busy. +pub(crate) fn request(listing: String, release: String) -> bool { let mut q = PENDING.lock(); - if q.len() >= DEPTH || q.contains(&package) { + if q.len() >= DEPTH || q.iter().any(|(l, _)| *l == listing) { return false; } - q.push(package); + q.push((listing, release)); drop(q); super::instance_spawn::raise_drain(); true } -/// Whether an install is waiting. A contended lock is a push in flight, which -/// counts as waiting rather than risking a missed boost. +/// Whether an install is waiting; a contended lock is a push in flight. pub(crate) fn has_pending() -> bool { PENDING.try_lock().map_or(true, |q| !q.is_empty()) } -/// Perform every queued install. +/// Perform every queued install the market still vouches for. pub(crate) fn service() { - let taken: Vec = core::mem::take(&mut *PENDING.lock()); - for package in taken { - match crate::userspace::capsule_linux::spawn_install(&package) { - Ok(pid) => { - crate::sys::serial::print(b"[LINUX-INSTALL] started pid="); - crate::sys::serial::print_hex(pid as u64); - crate::sys::serial::print(b" "); - crate::sys::serial::println(package.as_bytes()); - } - Err(_) => { - crate::sys::serial::print(b"[LINUX-INSTALL] refused "); - crate::sys::serial::println(package.as_bytes()); - } + let taken = core::mem::take(&mut *PENDING.lock()); + for (listing, release) in taken { + let Some(name) = listing.strip_prefix("linux.") else { continue }; + /* + * The store showed the listing as ready, and that was its word. The + * market's own verdict is asked for again here, and the release's + * package hash goes to the installer, which refuses any other bytes. + */ + let ready = queued_install_ready(&listing, &release).is_ok_and(|r| r.install_ready); + let pinned = queued_get_release(&listing, &release).ok().map(|r| r.package_hash); + let (true, Some(hash)) = (ready, pinned) else { + print(b"[LINUX-INSTALL] not ready, refused "); + println(listing.as_bytes()); + continue; + }; + match crate::userspace::capsule_linux::spawn_install(name, &hash) { + Ok(_) => print(b"[LINUX-INSTALL] started "), + Err(_) => print(b"[LINUX-INSTALL] refused "), } + println(listing.as_bytes()); } } diff --git a/src/userspace/init/supervisor/loop_impl.rs b/src/userspace/init/supervisor/loop_impl.rs index faa594b189..f9bf4fc5c0 100644 --- a/src/userspace/init/supervisor/loop_impl.rs +++ b/src/userspace/init/supervisor/loop_impl.rs @@ -81,8 +81,7 @@ fn park() { crate::sched::yield_now(); } -// Set init's own scheduling priority, through the one setter that holds the -// lock with interrupts off. +// Set init's priority through the one setter that holds the lock with irqs off. fn set_init_priority(p: Priority) { super::super::set_init_priority(p); } diff --git a/userland/capsule_app_store/src/store/install.rs b/userland/capsule_app_store/src/store/install.rs index 6446ab734a..c098ea6e16 100644 --- a/userland/capsule_app_store/src/store/install.rs +++ b/userland/capsule_app_store/src/store/install.rs @@ -44,14 +44,15 @@ pub fn ask(state: &State) -> Asked { let Some(listing) = state.current() else { return Asked::NotInstallable; }; - // Only a distribution package has anything to fetch. - let Some(package) = listing.id.strip_prefix(b"linux.".as_slice()) else { - return Asked::NotInstallable; - }; - if !listing.ready { + /* + * Only a distribution package has anything to fetch. `ready` only saves + * a pointless request: the kernel asks the market again before anything + * is fetched, so a stale or forged flag here decides nothing. + */ + if !listing.id.starts_with(b"linux.") || !listing.ready { return Asked::NotInstallable; } - match mk_app_install(package) { + match mk_app_install(&listing.id, b"") { 0 => Asked::Queued, -16 => Asked::Busy, _ => Asked::Refused, diff --git a/userland/capsule_linux/Cargo.lock b/userland/capsule_linux/Cargo.lock index 71218a8a3c..4cc225e171 100644 --- a/userland/capsule_linux/Cargo.lock +++ b/userland/capsule_linux/Cargo.lock @@ -22,6 +22,25 @@ dependencies = [ "libm", ] +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + +[[package]] +name = "blake3" +version = "1.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d9e454fc11f76977dc803893aff6304ed33d6a26efae8696573bea74baa27ae" +dependencies = [ + "arrayvec", + "cc", + "cfg-if", + "constant_time_eq", + "cpufeatures 0.3.1", +] + [[package]] name = "block-buffer" version = "0.10.4" @@ -31,12 +50,28 @@ dependencies = [ "generic-array", ] +[[package]] +name = "cc" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f360145194ee8e21db5ee7f3fcd4fe52210864c75c985dae33218202c8bbe040" +dependencies = [ + "find-msvc-tools", + "shlex", +] + [[package]] name = "cfg-if" version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" +[[package]] +name = "constant_time_eq" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" + [[package]] name = "core_maths" version = "0.1.1" @@ -55,6 +90,15 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + [[package]] name = "crypto-common" version = "0.1.7" @@ -75,6 +119,12 @@ dependencies = [ "crypto-common", ] +[[package]] +name = "find-msvc-tools" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" + [[package]] name = "generic-array" version = "0.14.7" @@ -127,6 +177,7 @@ dependencies = [ name = "nonos_capsule_linux" version = "0.1.0" dependencies = [ + "blake3", "nonos_app_skeleton", "nonos_hash", "nonos_inflate", @@ -188,10 +239,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest", ] +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + [[package]] name = "spin" version = "0.9.9" diff --git a/userland/capsule_linux/Cargo.toml b/userland/capsule_linux/Cargo.toml index b3b579e712..72d05a4d58 100644 --- a/userland/capsule_linux/Cargo.toml +++ b/userland/capsule_linux/Cargo.toml @@ -27,6 +27,7 @@ nonos_inflate = { path = "../inflate" } nonos_hash = { path = "../nonos_hash" } nonos_tls = { path = "../nonos_tls" } sha1 = { version = "0.10", default-features = false } +blake3 = { version = "1", default-features = false, features = ["pure"] } [profile.release] panic = "abort" diff --git a/userland/capsule_linux/src/linux/install/fetch.rs b/userland/capsule_linux/src/linux/install/fetch.rs new file mode 100644 index 0000000000..66f4a56ecd --- /dev/null +++ b/userland/capsule_linux/src/linux/install/fetch.rs @@ -0,0 +1,43 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! One package, fetched and authenticated. + +use nonos_libc::mk_debug; + +use super::auth::{verified, Verified}; +use super::download::download; +use super::index::Pkg; + +/// The package's files, if its bytes authenticate. `pin` is the market's hash +/// of the package the user chose; what it depends on is held to the signed +/// index alone, which names every one of them by checksum. +pub(super) fn fetch(pkg: &Pkg, pin: Option<&[u8; 32]>) -> Option { + let apk = download(&pkg.name, &pkg.version); + if pin.is_some_and(|want| blake3::hash(&apk).as_bytes() != want) { + say(b"[LINUX] package is not the one the market listed\n"); + return None; + } + let files = pkg.checksum.and_then(|sum| verified(&apk, &sum)); + if files.is_none() { + say(b"[LINUX] package did not download, or does not match its index record\n"); + } + files +} + +fn say(line: &[u8]) { + let _ = mk_debug(line.as_ptr(), line.len()); +} diff --git a/userland/capsule_linux/src/linux/install/index.rs b/userland/capsule_linux/src/linux/install/index.rs index 6a78b2cd37..fa8fe415be 100644 --- a/userland/capsule_linux/src/linux/install/index.rs +++ b/userland/capsule_linux/src/linux/install/index.rs @@ -18,53 +18,58 @@ use alloc::string::String; use alloc::vec::Vec; - -#[derive(Clone)] -pub struct Pkg { - pub name: String, - pub version: String, - /// SHA-1 of the package's control member, from its `C:` line. - pub checksum: Option<[u8; 20]>, -} +use super::pkg::bare; +pub use super::pkg::Pkg; pub struct Index { - /// soname -> package - pub libs: Vec<(String, Pkg)>, - /// package name -> package - pub names: Vec<(String, Pkg)>, + pkgs: Vec, + /// soname, and name or provided name, to the package that has it. + libs: Vec<(String, usize)>, + names: Vec<(String, usize)>, } impl Index { + /// Records are stored at their blank line; `D:` and `p:` follow `V:`. pub fn parse(raw: &[u8]) -> Index { let text = String::from_utf8_lossy(raw); - let (mut libs, mut names) = (Vec::new(), Vec::new()); - let mut cur = Pkg { name: String::new(), version: String::new(), checksum: None }; - for line in text.lines() { + let mut index = Index { pkgs: Vec::new(), libs: Vec::new(), names: Vec::new() }; + let (mut cur, mut provides) = (Pkg::default(), Vec::new()); + for line in text.lines().chain(core::iter::once("")) { let rest = line.get(2..).unwrap_or(""); match line.as_bytes().first() { - None => cur.checksum = None, + None => index.finish(core::mem::take(&mut cur), core::mem::take(&mut provides)), Some(b'C') => cur.checksum = super::auth::checksum(rest), Some(b'P') => cur.name = String::from(rest), - Some(b'V') => { - cur.version = String::from(rest); - names.push((cur.name.clone(), cur.clone())); - } - Some(b'p') => { - for so in rest.split_whitespace().filter_map(|t| t.strip_prefix("so:")) { - libs.push((String::from(so.split('=').next().unwrap_or(so)), cur.clone())); - } - } + Some(b'V') => cur.version = String::from(rest), + Some(b'D') => cur.read_depends(rest), + Some(b'p') => provides = rest.split_whitespace().map(bare).collect(), _ => {} } } - Index { libs, names } + index + } + + fn finish(&mut self, pkg: Pkg, provides: Vec) { + if pkg.name.is_empty() || pkg.version.is_empty() { + return; + } + let at = self.pkgs.len(); + self.names.push((pkg.name.clone(), at)); + for name in provides { + match name.strip_prefix("so:") { + Some(so) => self.libs.push((String::from(so), at)), + None if !name.contains(':') => self.names.push((name, at)), + None => {} + } + } + self.pkgs.push(pkg); } pub fn by_lib(&self, soname: &str) -> Option<&Pkg> { - self.libs.iter().find(|(k, _)| k == soname).map(|(_, v)| v) + self.libs.iter().find(|(k, _)| k == soname).and_then(|(_, i)| self.pkgs.get(*i)) } pub fn by_name(&self, name: &str) -> Option<&Pkg> { - self.names.iter().find(|(k, _)| k == name).map(|(_, v)| v) + self.names.iter().find(|(k, _)| k == name).and_then(|(_, i)| self.pkgs.get(*i)) } } diff --git a/userland/capsule_linux/src/linux/install/mod.rs b/userland/capsule_linux/src/linux/install/mod.rs index 669468e7c5..abbe3a8676 100644 --- a/userland/capsule_linux/src/linux/install/mod.rs +++ b/userland/capsule_linux/src/linux/install/mod.rs @@ -19,9 +19,11 @@ mod auth; mod download; mod enrol; +mod fetch; mod http; mod index; mod index_load; +mod pkg; mod place; mod place_entry; mod run; diff --git a/userland/capsule_linux/src/linux/install/pkg.rs b/userland/capsule_linux/src/linux/install/pkg.rs new file mode 100644 index 0000000000..b84418ddd2 --- /dev/null +++ b/userland/capsule_linux/src/linux/install/pkg.rs @@ -0,0 +1,52 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! One record of the distribution's package index. + +use alloc::string::String; +use alloc::vec::Vec; + +#[derive(Clone, Default)] +pub struct Pkg { + pub name: String, + pub version: String, + /// SHA-1 of the package's control member, from its `C:` line. + pub checksum: Option<[u8; 20]>, + /// What it needs installed with it, from its `D:` line: package names + /// and `so:` libraries, version constraints dropped. + pub depends: Vec, +} + +impl Pkg { + /// Read a `D:` line. A `!` entry is a conflict, not a need; a path is a + /// file some other dependency installs; `cmd:` and `pc:` needs are met by + /// whatever provides the libraries. + pub fn read_depends(&mut self, line: &str) { + let cut = |t: &str| String::from(t.split(['<', '>', '=', '~']).next().unwrap_or(t)); + self.depends = line + .split_whitespace() + .filter(|t| { + !t.starts_with(['!', '/']) && !t.starts_with("cmd:") && !t.starts_with("pc:") + }) + .map(cut) + .collect(); + } +} + +/// A provided name without the version it is provided at. +pub(super) fn bare(token: &str) -> String { + String::from(token.split('=').next().unwrap_or(token)) +} diff --git a/userland/capsule_linux/src/linux/install/run.rs b/userland/capsule_linux/src/linux/install/run.rs index 4e3e632241..fb5f637139 100644 --- a/userland/capsule_linux/src/linux/install/run.rs +++ b/userland/capsule_linux/src/linux/install/run.rs @@ -22,8 +22,7 @@ use alloc::vec::Vec; use nonos_libc::mk_debug; -use super::auth::verified; -use super::download::download; +use super::fetch::fetch; use super::index_load::load_index; use super::place::unpack; @@ -33,36 +32,40 @@ pub(super) const RELEASE: &str = "v3.20"; pub(super) const ARCH: &str = "x86_64"; pub(super) const BRANCHES: [&str; 2] = ["main", "community"]; -/// Rounds of resolution. A closure that has not settled by now is a -/// dependency cycle the index cannot satisfy, and looping would hide it. -const ROUNDS: usize = 12; +/// Packages one install may bring in. A closure larger than this is not a +/// program someone chose; it is an index that names half the distribution. +const MAX_PACKAGES: usize = 96; -pub fn install(name: &str) -> bool { +pub fn install(name: &str, pin: &[u8; 32]) -> bool { let Some(index) = load_index() else { say(b"[LINUX] no package index\n"); return false; }; let mut wanted: Vec = vec![String::from(name)]; let mut done: Vec = Vec::new(); - for _ in 0..ROUNDS { - let Some(next) = wanted.pop() else { - return true; + while let Some(next) = wanted.pop() { + let found = match next.strip_prefix("so:") { + Some(lib) => index.by_lib(lib), + None => index.by_name(&next), }; - if done.contains(&next) { - continue; - } - let Some(pkg) = index.by_name(&next).or_else(|| index.by_lib(&next)) else { + let Some(pkg) = found else { say(b"[LINUX] nothing provides it\n"); return false; }; - let apk = download(&pkg.name, &pkg.version); - let Some(files) = pkg.checksum.and_then(|sum| verified(&apk, &sum)) else { - say(b"[LINUX] package did not download, or does not match its index record\n"); + if done.contains(&pkg.name) { + continue; + } + if done.len() == MAX_PACKAGES { + say(b"[LINUX] more packages than one install resolves\n"); + return false; + } + let Some(files) = fetch(pkg, (pkg.name == name).then_some(pin)) else { return false; }; say(b"[LINUX] provenance Verified: index signature and checksums match\n"); unpack(&files); - done.push(next); + done.push(pkg.name.clone()); + wanted.extend(pkg.depends.iter().cloned()); } true } diff --git a/userland/capsule_linux/src/linux/request.rs b/userland/capsule_linux/src/linux/request.rs index afaa2e9e8c..227a340140 100644 --- a/userland/capsule_linux/src/linux/request.rs +++ b/userland/capsule_linux/src/linux/request.rs @@ -14,7 +14,6 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . - //! Reading what this capsule was asked to do. use alloc::string::String; @@ -24,18 +23,28 @@ use nonos_libc::mk_args; /// Matches the buffer the program path is read into. const MAX_ARGS: usize = 256; -/// Arguments `install` then `` ask this capsule to fetch a package -/// rather than run a program. -pub fn install_request() -> Option { +/// `install ` asks this capsule to fetch a package rather than +/// run a program. The hash is the market's BLAKE3 of the package the user +/// chose, as hex; a request without one is not an install request. +pub fn install_request() -> Option<(String, [u8; 32])> { let mut buf = [0u8; MAX_ARGS]; let n = mk_args(buf.as_mut_ptr(), buf.len()); if n <= 0 { return None; } - let mut parts = buf[..n as usize].split(|b| *b == 0); + let mut parts = buf.get(..n as usize)?.split(|b| *b == 0); if parts.next()? != b"install" { return None; } let name = parts.next().filter(|s| !s.is_empty())?; - Some(String::from(core::str::from_utf8(name).ok()?)) + let hex = parts.next()?; + if hex.len() != 64 { + return None; + } + let mut pin = [0u8; 32]; + for (slot, pair) in pin.iter_mut().zip(hex.chunks(2)) { + let s = core::str::from_utf8(pair).ok()?; + *slot = u8::from_str_radix(s, 16).ok()?; + } + Some((String::from(core::str::from_utf8(name).ok()?), pin)) } diff --git a/userland/capsule_linux/src/linux/start.rs b/userland/capsule_linux/src/linux/start.rs index 971da847d4..f4c8deec39 100644 --- a/userland/capsule_linux/src/linux/start.rs +++ b/userland/capsule_linux/src/linux/start.rs @@ -26,9 +26,9 @@ use super::start_guest::start; pub fn run() -> ! { let _ = heap_init(); say(b"[LINUX] personality up\n"); - if let Some(name) = super::request::install_request() { + if let Some((name, pin)) = super::request::install_request() { say(b"[LINUX] installing\n"); - let ok = super::install::install(&name); + let ok = super::install::install(&name, &pin); say(if ok { b"[LINUX] installed\n" } else { b"[LINUX] install failed\n" }); mk_exit(if ok { 0 } else { 1 }) } diff --git a/userland/capsule_linux_proofs/src/install/mod.rs b/userland/capsule_linux_proofs/src/install/mod.rs index 30fdc84924..19f53fcc2e 100644 --- a/userland/capsule_linux_proofs/src/install/mod.rs +++ b/userland/capsule_linux_proofs/src/install/mod.rs @@ -25,5 +25,8 @@ pub mod tar_field; #[path = "../../../capsule_linux/src/linux/install/tar.rs"] pub mod tar; +#[path = "../../../capsule_linux/src/linux/install/pkg.rs"] +pub mod pkg; + #[path = "../../../capsule_linux/src/linux/install/index.rs"] pub mod index; diff --git a/userland/capsule_linux_proofs/src/tests.rs b/userland/capsule_linux_proofs/src/tests.rs index fb3ecc4ec7..4b9fee263a 100644 --- a/userland/capsule_linux_proofs/src/tests.rs +++ b/userland/capsule_linux_proofs/src/tests.rs @@ -22,6 +22,7 @@ mod auth_tests; mod dirent_tests; mod elf_tests; mod exec_shebang_tests; +mod index_tests; mod key_tests; mod resolve_tests; mod stack_words_tests; diff --git a/userland/capsule_linux_proofs/src/tests/index_tests.rs b/userland/capsule_linux_proofs/src/tests/index_tests.rs new file mode 100644 index 0000000000..fb21d66dd8 --- /dev/null +++ b/userland/capsule_linux_proofs/src/tests/index_tests.rs @@ -0,0 +1,31 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + + +//! The index reader, on a record shaped the way Alpine writes one. + +use crate::install::index::Index; + +#[test] +fn a_record_names_its_dependencies_and_what_it_provides() { + let text = b"C:Q1VBuPqTmRFkXS59UyXcV3OwNgKi4=\nP:foot\nV:1.0-r0\n\ +D:so:libc.musl-x86_64.so.1 fontconfig>=2.14 !foot-old /bin/sh cmd:sh pc:x\np:so:libfoot.so.1=1 foot-term\n\n"; + let index = Index::parse(text); + let pkg = index.by_name("foot").expect("foot"); + assert_eq!(pkg.depends, ["so:libc.musl-x86_64.so.1", "fontconfig"]); + assert!(index.by_lib("libfoot.so.1").is_some()); + assert_eq!(index.by_name("foot-term").map(|p| p.name.as_str()), Some("foot")); +} diff --git a/userland/libc/src/local_sign.rs b/userland/libc/src/local_sign.rs index ad420f1199..50e199a91d 100644 --- a/userland/libc/src/local_sign.rs +++ b/userland/libc/src/local_sign.rs @@ -45,8 +45,9 @@ pub fn mk_local_verify(elf: &[u8], caps: u64, trailer: &[u8]) -> bool { call_raw(N_MK_LOCAL_VERIFY, args) == 0 } -/// Ask for a distribution package to be installed. -pub fn mk_app_install(package: &[u8]) -> i64 { - call_raw(N_MK_APP_INSTALL, [package.as_ptr() as u64, package.len() as u64, 0, 0, 0, 0]) +/// Ask for a marketplace listing to be installed. An empty `release` asks +/// for the listing's default. +pub fn mk_app_install(listing: &[u8], release: &[u8]) -> i64 { + let (l, r) = (listing.as_ptr() as u64, release.as_ptr() as u64); + call_raw(N_MK_APP_INSTALL, [l, listing.len() as u64, r, release.len() as u64, 0, 0]) } - From 5ac351877028d534b8c8162e35bd779c33c3d473 Mon Sep 17 00:00:00 2001 From: eKisNonos Date: Sat, 26 Sep 2026 15:42:40 +0000 Subject: [PATCH 019/244] tpm: keep the kernel's machine-key labels out of CryptoMachineKey's reach CryptoMachineKey derives the machine key for any label a Crypto holder names, so a key the kernel keeps for itself needs a label no syscall can ask for. Kernel labels start with a zero byte, and the syscall refuses any label that does. --- src/security/tpm/machine_key/kernel_label.rs | 45 ++++++++++++++++++++ src/security/tpm/machine_key/mod.rs | 2 + src/syscall/dispatch/crypto/machine_key.rs | 16 +++---- 3 files changed, 55 insertions(+), 8 deletions(-) create mode 100644 src/security/tpm/machine_key/kernel_label.rs diff --git a/src/security/tpm/machine_key/kernel_label.rs b/src/security/tpm/machine_key/kernel_label.rs new file mode 100644 index 0000000000..dab0c55244 --- /dev/null +++ b/src/security/tpm/machine_key/kernel_label.rs @@ -0,0 +1,45 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! Keys the kernel derives for itself. +//! +//! `CryptoMachineKey` hands any capsule holding Crypto the key for a label it +//! names. A key the kernel keeps for itself therefore needs a label no syscall +//! can ask for: every kernel label starts with a zero byte, and a label from a +//! syscall that starts with one is refused. + +extern crate alloc; + +use alloc::vec::Vec; + +use super::consts::DIGEST_LEN; +use super::derive::derive; +use super::error::KeyError; + +const KERNEL_PREFIX: u8 = 0; + +/// The machine key for a name only the kernel uses. +pub fn derive_for_kernel(name: &[u8]) -> Result<[u8; DIGEST_LEN], KeyError> { + let mut label = Vec::with_capacity(1 + name.len()); + label.push(KERNEL_PREFIX); + label.extend_from_slice(name); + derive(&label) +} + +/// True when a caller may ask for the key under `label`. +pub fn is_user_label(label: &[u8]) -> bool { + label.first() != Some(&KERNEL_PREFIX) +} diff --git a/src/security/tpm/machine_key/mod.rs b/src/security/tpm/machine_key/mod.rs index 7d92b2ccf6..b0dd2613bb 100644 --- a/src/security/tpm/machine_key/mod.rs +++ b/src/security/tpm/machine_key/mod.rs @@ -36,6 +36,7 @@ mod derive; mod error; mod flush; mod hmac; +mod kernel_label; mod pcrs; mod policy; mod run; @@ -45,4 +46,5 @@ mod wire; pub use consts::LABEL_MAX; pub use derive::derive; pub use error::KeyError; +pub use kernel_label::{derive_for_kernel, is_user_label}; pub use pcrs::BOUND_PCRS; diff --git a/src/syscall/dispatch/crypto/machine_key.rs b/src/syscall/dispatch/crypto/machine_key.rs index 5cf7c6b62e..60327cf4e9 100644 --- a/src/syscall/dispatch/crypto/machine_key.rs +++ b/src/syscall/dispatch/crypto/machine_key.rs @@ -18,13 +18,12 @@ //! //! The caller names the key with a label and gets the same bytes back every //! boot on this machine, and different bytes on any other machine or under any -//! other kernel. Nothing is stored anywhere to make that so. The volume store -//! wraps its volume key under one of these; the wallet wraps its seed under -//! another. Neither could persist anything across a reboot before this. +//! other kernel. Nothing is stored anywhere to make that so. Labels the kernel +//! keeps for itself are refused here. use crate::capabilities::Capability; use crate::security::tpm::error::TpmError; -use crate::security::tpm::machine_key::{derive, KeyError, LABEL_MAX}; +use crate::security::tpm::machine_key::{derive, is_user_label, KeyError, LABEL_MAX}; use crate::syscall::dispatch::require_capability; use crate::syscall::SyscallResult; @@ -45,6 +44,9 @@ pub fn handle_machine_key(label_ptr: u64, label_len: u64, out_ptr: u64) -> Sysca Ok(v) => v, Err(e) => return e, }; + if !is_user_label(&label) { + return crate::syscall::dispatch::errno(22); + } match derive(&label) { Ok(mut key) => { let written = copy::write(out_ptr, &key); @@ -58,10 +60,8 @@ pub fn handle_machine_key(label_ptr: u64, label_len: u64, out_ptr: u64) -> Sysca } } -/// The errno says which of three very different things went wrong: no TPM to -/// ask, a TPM that refused because the machine is not in the state the key -/// belongs to, or a transport fault. A caller unlocking a volume shows the -/// user a different sentence for each. +/// No TPM, a TPM refusing because the machine is not in the key's state, or a +/// transport fault: a caller unlocking a volume says a different thing for each. fn errno_for(e: KeyError) -> i32 { match e { KeyError::Tpm(TpmError::NotPresent) => 19, From 12cb7922c1edf51578cee6d68ab761af1daf6fe6 Mon Sep 17 00:00:00 2001 From: eKisNonos Date: Sat, 26 Sep 2026 15:43:10 +0000 Subject: [PATCH 020/244] setup: consent once per machine to running what it installs The local signing identity was random each boot, so consent was too. It is now derived from the machine key, and first-boot setup, which alone holds EnrolDevRoot, grants the local root as a named step and keeps a token only this machine can make; later boots restore it. The desktop profile now includes setup and the market, and builds every capsule it embeds. --- Cargo.toml | 1 + abi/syscalls.toml | 14 ++++ mk/20-build.mk | 6 +- src/security/dev_roots/local.rs | 73 +++++++++++++++++++ src/security/dev_roots/mod.rs | 2 + src/security/dev_roots/table.rs | 11 ++- src/security/local_build/consent.rs | 35 +++++++++ src/security/local_build/identity.rs | 33 +++++++-- src/security/local_build/mod.rs | 2 + src/security/local_build/trailer.rs | 2 +- src/syscall/abi/registry/mk.rs | 2 + src/syscall/contract/cap_table/mk.rs | 4 +- .../dispatch/router/microkernel_ops.rs | 2 + src/syscall/microkernel/dispatch/process.rs | 9 ++- src/syscall/microkernel/local_consent.rs | 57 +++++++++++++++ src/syscall/microkernel/mod.rs | 1 + src/syscall/microkernel/numbers.rs | 4 + src/syscall/numbers/defs.rs | 2 + src/userspace/capsule_setup_wizard/spawn.rs | 3 +- userland/capsule_setup_wizard/Capsule.mk | 6 +- userland/capsule_setup_wizard/Cargo.lock | 9 +++ userland/capsule_setup_wizard/Cargo.toml | 1 + userland/capsule_setup_wizard/src/consent.rs | 64 ++++++++++++++++ userland/capsule_setup_wizard/src/main.rs | 4 + .../src/render/screens/local_software.rs | 44 +++++++++++ .../src/render/screens/mod.rs | 7 +- .../src/render/screens/review.rs | 1 + .../capsule_setup_wizard/src/server/step.rs | 2 +- userland/capsule_setup_wizard/src/state.rs | 6 ++ userland/libc/src/consent.rs | 26 ++++++- userland/libc/src/lib.rs | 9 ++- userland/libc/src/syscall/mod.rs | 2 +- userland/libc/src/syscall/numbers/foreign.rs | 2 + 33 files changed, 417 insertions(+), 29 deletions(-) create mode 100644 src/security/dev_roots/local.rs create mode 100644 src/security/local_build/consent.rs create mode 100644 src/syscall/microkernel/local_consent.rs create mode 100644 userland/capsule_setup_wizard/src/consent.rs create mode 100644 userland/capsule_setup_wizard/src/render/screens/local_software.rs diff --git a/Cargo.toml b/Cargo.toml index b1888a7f90..4e16eedc38 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -558,6 +558,7 @@ microkernel-desktop-base = [ "nonos-capsule-install", "nonos-capsule-install-cli", "nonos-capsule-app-store", + "nonos-capsule-market", "nonos-capsule-linux", "nonos-capsule-audio", "nonos-capsule-driver-hda", diff --git a/abi/syscalls.toml b/abi/syscalls.toml index c39db0951f..cd36153278 100644 --- a/abi/syscalls.toml +++ b/abi/syscalls.toml @@ -91,6 +91,8 @@ MLSG = 0x47534C4D MLVF = 0x46564C4D MAIN = 0x4E49414D MDRO = 0x4F52444D +MLCG = 0x47434C4D +MLCR = 0x52434C4D MIRW = 0x5752494D MIRY = 0x5952494D MKAR = 0x52414B4D @@ -695,6 +697,18 @@ caps = ["EnrolDevRoot"] args = [] ret = {type="i64"} +[desc.MLCG] +nr = 0x47434C4D +caps = ["EnrolDevRoot"] +args = [{name="op",type="u64",dir="in"},{name="token_ptr",type="u8*",dir="out"}] +ret = {type="i64"} + +[desc.MLCR] +nr = 0x52434C4D +caps = ["EnrolDevRoot"] +args = [{name="token_ptr",type="u8*",dir="in"}] +ret = {type="i64"} + [desc.MPPT] nr = 0x5450504D caps = ["ForeignExec"] diff --git a/mk/20-build.mk b/mk/20-build.mk index 194ee25ec0..50c4d8a704 100644 --- a/mk/20-build.mk +++ b/mk/20-build.mk @@ -1160,8 +1160,8 @@ DESKTOP_BASE_SLUGS := proof-io ramfs keyring entropy crypto vfs \ driver-virtio-net driver-ps2-input driver-xhci driver-usb-hid \ net-core net-sockets net-nym socks5 policy wallpaper_catalog \ installer input-router compositor wm desktop-shell image-codec \ - clipboard login wallpaper toolkit about install install-cli boot-splash calculator \ - clipboard login wallpaper toolkit about linux boot-splash calculator \ + clipboard login wallpaper toolkit about install install-cli linux boot-splash \ + calculator market app_store setup-wizard \ browser wallet-nonos terminal file-manager text-editor \ settings process-manager attest power \ audio driver-hda audio_player video-player @@ -1184,7 +1184,7 @@ DESKTOP_GUI_CAPSULE_ARTIFACTS := $(DESKTOP_BASE_CAPSULE_ARTIFACTS) \ nonos-mk-desktop-gui-prod: $(DESKTOP_GUI_CAPSULE_ARTIFACTS) \ nonos-mk-verify-desktop-gui-capsules \ nonos-mk-check-deps nonos-mk-ensure-signing-key - $(call nonos_kernel_build,microkernel-desktop-gui + nonos-stark-attest,microkernel-desktop-gui$(_boot_comma)nonos-stark-attest) + $(call nonos_kernel_build,microkernel-setup-wizard + nonos-stark-attest,microkernel-setup-wizard$(_boot_comma)nonos-stark-attest) # nonos-mk-install-prod: the desktop profile with the NVMe driver capsule in # it. The desktop cut leaves NVMe out because a driver whose hardware is absent diff --git a/src/security/dev_roots/local.rs b/src/security/dev_roots/local.rs new file mode 100644 index 0000000000..86aa4dfd97 --- /dev/null +++ b/src/security/dev_roots/local.rs @@ -0,0 +1,73 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! This machine's own build root, enrolled by a person in first-boot setup. +//! +//! Main's route asked a capsule to request a root and a person to type back a +//! code printed on the serial console, which no desktop user ever sees. Setup +//! is the trusted path instead: it alone holds `EnrolDevRoot`, it grants the +//! local root as a named step, and the token it keeps restores that consent on +//! later boots without asking again. + +use super::authority::Authority; +use super::error::EnrolError; +use super::table::TABLE; +use crate::capabilities::Capability; +use crate::security::attest_registry::registry_complete; +use crate::security::local_build::{consent_token, root}; + +/// Enrol the local root and return the token that restores it, if this +/// machine can keep one. +pub fn grant_local_root(caller_caps: u64) -> Result<(Authority, Option<[u8; 32]>), EnrolError> { + if caller_caps & Capability::EnrolDevRoot.bit() == 0 { + return Err(EnrolError::Denied); + } + let authority = enrol()?; + Ok((authority, root().and_then(|r| consent_token(&r)))) +} + +/// Enrol the local root again from a token a grant returned. A token that is +/// not this machine's for this root is refused. +pub fn restore_local_root(token: &[u8; 32]) -> Result { + let want = root().and_then(|r| consent_token(&r)).ok_or(EnrolError::NotConfirmed)?; + let differs = want.iter().zip(token.iter()).fold(0u8, |acc, (a, b)| acc | (a ^ b)); + if differs != 0 { + return Err(EnrolError::NotConfirmed); + } + enrol() +} + +/// Stop running what this machine installs. Narrowing, so it asks only for +/// the same right a grant does, not for a second person. +pub fn revoke_local_root(caller_caps: u64) -> Result<(), EnrolError> { + if caller_caps & Capability::EnrolDevRoot.bit() == 0 { + return Err(EnrolError::Denied); + } + let local = root().ok_or(EnrolError::EmptyRoot)?; + TABLE.lock().remove(&local); + crate::sys::serial::println(b"[DEV-ROOT] local root withdrawn"); + Ok(()) +} + +fn enrol() -> Result { + if !registry_complete() { + return Err(EnrolError::RegistryIncomplete); + } + let local = root().ok_or(EnrolError::EmptyRoot)?; + let slot = TABLE.lock().insert(local).ok_or(EnrolError::NoSlots)?; + crate::sys::serial::println(b"[DEV-ROOT] local root enrolled; installed software may run"); + Ok(Authority::Developer(slot)) +} diff --git a/src/security/dev_roots/mod.rs b/src/security/dev_roots/mod.rs index 806b267674..6f40d41c86 100644 --- a/src/security/dev_roots/mod.rs +++ b/src/security/dev_roots/mod.rs @@ -35,6 +35,7 @@ mod authority; mod consent; mod enrol; mod error; +mod local; mod pending; mod resolve; mod table; @@ -44,5 +45,6 @@ pub use enrol::{ confirm_dev_root, dev_root_count, request_dev_root, request_local_build_root, }; pub use error::EnrolError; +pub use local::{grant_local_root, restore_local_root, revoke_local_root}; pub use resolve::{authority_for, enrolled_roots}; pub use table::MAX_DEV_ROOTS; diff --git a/src/security/dev_roots/table.rs b/src/security/dev_roots/table.rs index 19068f0079..ce3bc430e6 100644 --- a/src/security/dev_roots/table.rs +++ b/src/security/dev_roots/table.rs @@ -61,11 +61,14 @@ impl Table { self.roots.iter().all(|s| s.used) } + pub(super) fn remove(&mut self, root: &[u8; 32]) { + for slot in self.roots.iter_mut().filter(|s| s.used && &s.root == root) { + *slot = DevRoot { root: [0u8; 32], used: false }; + } + } + pub fn find(&self, root: &[u8; 32]) -> Option { - self.roots - .iter() - .position(|s| s.used && &s.root == root) - .map(|i| i as u8) + self.roots.iter().position(|s| s.used && &s.root == root).map(|i| i as u8) } } diff --git a/src/security/local_build/consent.rs b/src/security/local_build/consent.rs new file mode 100644 index 0000000000..19925a6bf6 --- /dev/null +++ b/src/security/local_build/consent.rs @@ -0,0 +1,35 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! The record that a person let this machine run what it installs. +//! +//! A token is an HMAC over the local root under a machine key only the kernel +//! can derive. It is worthless on another machine or under another kernel, +//! and it cannot be made from the disk it is kept on, so keeping it anywhere is +//! safe. Presenting it restores a consent that was already given; it cannot +//! give one. + +use crate::crypto::hash::hmac_sha256; +use crate::security::tpm::machine_key::derive_for_kernel; + +/// The token for `root`, or `None` when this machine cannot keep consent. +pub fn token(root: &[u8; 32]) -> Option<[u8; 32]> { + if !super::identity::persistent() { + return None; + } + let key = derive_for_kernel(b"local_build/consent").ok()?; + Some(hmac_sha256(&key, root)) +} diff --git a/src/security/local_build/identity.rs b/src/security/local_build/identity.rs index 6d42726f33..586ecbca8a 100644 --- a/src/security/local_build/identity.rs +++ b/src/security/local_build/identity.rs @@ -18,6 +18,7 @@ use spin::Mutex; use crate::crypto::rng::get_random_bytes_secure; use crate::crypto::zk_kernel::PedersenCommitment; +use crate::security::tpm::machine_key::derive_for_kernel; use super::tree::root_for; @@ -26,26 +27,44 @@ pub struct LocalIdentity { pub blinding: [u8; 32], pub commitment: [u8; 32], pub root: [u8; 32], + /// Derived from the machine key, so the same on every boot of this + /// machine running this kernel. False when there is no TPM to ask. + pub persistent: bool, } static IDENTITY: Mutex> = Mutex::new(None); -/// Secure rather than best effort: a guessable secret is a tree anyone can -/// mint proofs against. +/* + * The machine key when there is one, so a person consents once per machine. + * Without a TPM a random identity for this boot is the honest fallback, never + * a fixed one: a guessable secret is a tree anyone can mint proofs against. + */ fn mint() -> Option { - let secret = get_random_bytes_secure().ok()?; - let blinding = get_random_bytes_secure().ok()?; + let (secret, blinding, persistent) = match ( + derive_for_kernel(b"local_build/secret"), + derive_for_kernel(b"local_build/blinding"), + ) { + (Ok(s), Ok(b)) => (s, b, true), + _ => { + crate::sys::serial::println(b"[LOCAL-BUILD] no machine key; identity lasts this boot"); + (get_random_bytes_secure().ok()?, get_random_bytes_secure().ok()?, false) + } + }; let commitment = PedersenCommitment::commit(&secret, &blinding).commitment; let root = root_for(&commitment); - Some(LocalIdentity { secret, blinding, commitment, root }) + Some(LocalIdentity { secret, blinding, commitment, root, persistent }) } -/// The root to enrol so this machine will run what it builds. Stable for the -/// life of the boot, so a second build does not invalidate the first consent. +/// The root to enrol so this machine will run what it builds. pub fn root() -> Option<[u8; 32]> { with_identity(|id| id.root) } +/// Whether consent to this identity can outlive the boot. +pub(super) fn persistent() -> bool { + with_identity(|id| id.persistent).unwrap_or(false) +} + pub(super) fn with_identity(f: impl FnOnce(&LocalIdentity) -> T) -> Option { let mut guard = IDENTITY.lock(); if guard.is_none() { diff --git a/src/security/local_build/mod.rs b/src/security/local_build/mod.rs index d85d1493a5..0116acd7cb 100644 --- a/src/security/local_build/mod.rs +++ b/src/security/local_build/mod.rs @@ -23,6 +23,7 @@ //! //! Nothing here enrols. Minting a proof is not consent. +mod consent; mod error; mod identity; mod sign; @@ -30,5 +31,6 @@ mod trailer; mod tree; pub use error::LocalBuildError; +pub use consent::token as consent_token; pub use identity::root; pub use sign::sign; diff --git a/src/security/local_build/trailer.rs b/src/security/local_build/trailer.rs index a4aa74e14d..8b18804926 100644 --- a/src/security/local_build/trailer.rs +++ b/src/security/local_build/trailer.rs @@ -24,7 +24,7 @@ use crate::security::capsule_attest::layout::POLICY_TREE_DEPTH; const TRAILER_MAGIC: &[u8; 8] = b"NZKCAPS2"; /// Magic, four 32-byte fields, the depth, the siblings, the packed directions. -pub const TRAILER_LEN: usize = +pub(super) const TRAILER_LEN: usize = 8 + 4 * 32 + 1 + POLICY_TREE_DEPTH * 32 + POLICY_TREE_DEPTH.div_ceil(8); /// The inverse of `capsule_attest::trailer::parse`, field for field. Written diff --git a/src/syscall/abi/registry/mk.rs b/src/syscall/abi/registry/mk.rs index 171c9ec52e..fcc8894c23 100644 --- a/src/syscall/abi/registry/mk.rs +++ b/src/syscall/abi/registry/mk.rs @@ -107,6 +107,8 @@ pub(super) const ENTRIES: &[AbiEntry] = &[ e(b"MLVF", SyscallNumber::MkLocalVerify, "MkLocalVerify"), e(b"MAIN", SyscallNumber::MkAppInstall, "MkAppInstall"), e(b"MDRO", SyscallNumber::MkDevRootLocal, "MkDevRootLocal"), + e(b"MLCG", SyscallNumber::MkLocalConsent, "MkLocalConsent"), + e(b"MLCR", SyscallNumber::MkLocalRestore, "MkLocalRestore"), e(b"MTRN", SyscallNumber::MkToolRun, "MkToolRun"), e(b"MSOW", SyscallNumber::MkStdoutWrite, "MkStdoutWrite"), e(b"MSWR", SyscallNumber::MkStoreWrite, "MkStoreWrite"), diff --git a/src/syscall/contract/cap_table/mk.rs b/src/syscall/contract/cap_table/mk.rs index e007d63a00..5acaedf25d 100644 --- a/src/syscall/contract/cap_table/mk.rs +++ b/src/syscall/contract/cap_table/mk.rs @@ -64,7 +64,9 @@ pub(super) fn check(caps: &CapabilityToken, number: SyscallNumber) -> Option caps.can_enrol_dev_root(), + | SyscallNumber::MkDevRootLocal + | SyscallNumber::MkLocalConsent + | SyscallNumber::MkLocalRestore => caps.can_enrol_dev_root(), SyscallNumber::MkTimeAdjust => caps.can_set_time(), diff --git a/src/syscall/dispatch/router/microkernel_ops.rs b/src/syscall/dispatch/router/microkernel_ops.rs index a01bb907f7..2fc572075a 100644 --- a/src/syscall/dispatch/router/microkernel_ops.rs +++ b/src/syscall/dispatch/router/microkernel_ops.rs @@ -99,6 +99,8 @@ pub(super) fn matches(nr: SyscallNumber) -> bool { | MkLocalVerify | MkAppInstall | MkDevRootLocal + | MkLocalConsent + | MkLocalRestore | MkToolRun ) } diff --git a/src/syscall/microkernel/dispatch/process.rs b/src/syscall/microkernel/dispatch/process.rs index 176a0726b5..b3f853df8e 100644 --- a/src/syscall/microkernel/dispatch/process.rs +++ b/src/syscall/microkernel/dispatch/process.rs @@ -20,6 +20,7 @@ use crate::process::foreign::{ sys_foreign_thread, sys_foreign_wait, sys_peer_copy, sys_peer_map, sys_peer_protect, sys_peer_tls, sys_peer_unmap, }; +use crate::syscall::microkernel::app_install::sys_app_install; use crate::syscall::microkernel::attest::sys_attest_status; use crate::syscall::microkernel::attest_doc::sys_attest_doc; use crate::syscall::microkernel::attest_entries::sys_attest_entries; @@ -27,13 +28,13 @@ use crate::syscall::microkernel::battery::sys_battery_status; use crate::syscall::microkernel::capsule_load::sys_capsule_load; use crate::syscall::microkernel::capsule_verify::sys_capsule_verify; use crate::syscall::microkernel::enrol_dev_root::{sys_dev_root_confirm, sys_dev_root_request}; +use crate::syscall::microkernel::enrol_local_root::sys_dev_root_local; use crate::syscall::microkernel::futex::{sys_futex_wait, sys_futex_wake}; use crate::syscall::microkernel::install_source::sys_install_source; +use crate::syscall::microkernel::kill::sys_kill; +use crate::syscall::microkernel::local_consent::{sys_local_consent, sys_local_restore}; use crate::syscall::microkernel::local_sign::sys_local_sign; -use crate::syscall::microkernel::app_install::sys_app_install; -use crate::syscall::microkernel::enrol_local_root::sys_dev_root_local; use crate::syscall::microkernel::local_verify::sys_local_verify; -use crate::syscall::microkernel::kill::sys_kill; use crate::syscall::microkernel::memory::{sys_mmap, sys_munmap}; use crate::syscall::microkernel::numbers::*; use crate::syscall::microkernel::proc_output::sys_proc_output; @@ -99,6 +100,8 @@ pub(super) fn handle(nr: u64, a: Args) -> Option { SYS_LOCAL_VERIFY => sys_local_verify(a.a0, a.a1, a.a2, a.a3, a.a4), SYS_APP_INSTALL => sys_app_install(a.a0, a.a1, a.a2, a.a3), SYS_DEV_ROOT_LOCAL => sys_dev_root_local(), + SYS_LOCAL_CONSENT => sys_local_consent(a.a0, a.a1), + SYS_LOCAL_RESTORE => sys_local_restore(a.a0), SYS_DEV_ROOT_REQUEST => sys_dev_root_request(a.a0), SYS_DEV_ROOT_CONFIRM => sys_dev_root_confirm(a.a0), SYS_SPAWN_INSTANCE => sys_spawn_instance(a.a0, a.a1), diff --git a/src/syscall/microkernel/local_consent.rs b/src/syscall/microkernel/local_consent.rs new file mode 100644 index 0000000000..cae892112d --- /dev/null +++ b/src/syscall/microkernel/local_consent.rs @@ -0,0 +1,57 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! `MkLocalConsent` and `MkLocalRestore`: the person's decision that this +//! machine runs what it installs, given once and kept as a token. + +use crate::capabilities::caps_to_bits; +use crate::security::dev_roots::{grant_local_root, restore_local_root, revoke_local_root}; +use crate::syscall::caps::current_caps_or_default; +use crate::syscall::microkernel::errnos::{ERRNO_FAULT, ERRNO_INVAL}; +use crate::usercopy::{copy_from_user, copy_to_user}; + +const GRANT: u64 = 0; +const REVOKE: u64 = 1; + +/// `MkLocalConsent(op, token_ptr)`. A grant writes the 32-byte token and +/// returns 1, or returns 0 when this machine has no key to keep one with, in +/// which case the consent lasts this boot. A revoke returns 0. +pub fn sys_local_consent(op: u64, token_ptr: u64) -> i64 { + let caps = caps_to_bits(¤t_caps_or_default().permissions); + match op { + GRANT => match grant_local_root(caps) { + Ok((_, Some(token))) => match copy_to_user(token_ptr, &token) { + Ok(()) => 1, + Err(_) => ERRNO_FAULT, + }, + Ok((_, None)) => 0, + Err(e) => e.to_errno(), + }, + REVOKE => revoke_local_root(caps).map_or_else(|e| e.to_errno(), |()| 0), + _ => ERRNO_INVAL, + } +} + +/// `MkLocalRestore(token_ptr)`. Re-enrols the local root from a token a grant +/// returned on this machine. It cannot grant anything: a token nobody was +/// given does not verify. +pub fn sys_local_restore(token_ptr: u64) -> i64 { + let mut token = [0u8; 32]; + if copy_from_user(token_ptr, &mut token).is_err() { + return ERRNO_FAULT; + } + restore_local_root(&token).map_or_else(|e| e.to_errno(), |_| 0) +} diff --git a/src/syscall/microkernel/mod.rs b/src/syscall/microkernel/mod.rs index 522fda4617..83238b4916 100644 --- a/src/syscall/microkernel/mod.rs +++ b/src/syscall/microkernel/mod.rs @@ -41,6 +41,7 @@ pub mod kill; pub mod app_install; pub mod enrol_local_root; mod local_image; +pub mod local_consent; pub mod local_sign; pub mod local_verify; pub mod memory; diff --git a/src/syscall/microkernel/numbers.rs b/src/syscall/microkernel/numbers.rs index a724b33a31..86967cc71a 100644 --- a/src/syscall/microkernel/numbers.rs +++ b/src/syscall/microkernel/numbers.rs @@ -98,6 +98,10 @@ pub const SYS_LOCAL_VERIFY: u64 = tag4(b"MLVF"); pub const SYS_APP_INSTALL: u64 = tag4(b"MAIN"); /// Ask to enrol this machine's own build root. pub const SYS_DEV_ROOT_LOCAL: u64 = tag4(b"MDRO"); +/// Grant or withdraw consent to run what this machine installs. +pub const SYS_LOCAL_CONSENT: u64 = tag4(b"MLCG"); +/// Restore that consent, at setup, from the token a grant returned. +pub const SYS_LOCAL_RESTORE: u64 = tag4(b"MLCR"); /// Ask to enrol a signing root so software built here runs here. Prints a /// confirmation code; enrols nothing on its own. pub const SYS_DEV_ROOT_REQUEST: u64 = tag4(b"MDRQ"); diff --git a/src/syscall/numbers/defs.rs b/src/syscall/numbers/defs.rs index 54bdf98363..56a18ece34 100644 --- a/src/syscall/numbers/defs.rs +++ b/src/syscall/numbers/defs.rs @@ -128,4 +128,6 @@ pub enum SyscallNumber { MkLocalVerify = tag4(b"MLVF"), MkAppInstall = tag4(b"MAIN"), MkDevRootLocal = tag4(b"MDRO"), + MkLocalConsent = tag4(b"MLCG"), + MkLocalRestore = tag4(b"MLCR"), } diff --git a/src/userspace/capsule_setup_wizard/spawn.rs b/src/userspace/capsule_setup_wizard/spawn.rs index b8643fcbc3..2bc19d1d39 100644 --- a/src/userspace/capsule_setup_wizard/spawn.rs +++ b/src/userspace/capsule_setup_wizard/spawn.rs @@ -51,7 +51,8 @@ pub fn spawn_setup_wizard_capsule() -> Result<(), SpawnError> { | Capability::IPC.bit() | Capability::Memory.bit() | Capability::GraphicsDisplayQuery.bit() - | Capability::GraphicsSurfaceCreate.bit(), + | Capability::GraphicsSurfaceCreate.bit() + | Capability::EnrolDevRoot.bit(), debug_tag: b"", }; let pid = capsule_spawn::spawn_verified(&spec, &trust_anchor, None)?; diff --git a/userland/capsule_setup_wizard/Capsule.mk b/userland/capsule_setup_wizard/Capsule.mk index 9b5919c852..d5a5640a0b 100644 --- a/userland/capsule_setup_wizard/Capsule.mk +++ b/userland/capsule_setup_wizard/Capsule.mk @@ -1,7 +1,9 @@ # setup_wizard capsule. First-boot setup wizard: attaches a fullscreen # compositor surface, grabs the keyboard, walks the user through setup # (keys/passphrase/wallpaper), then exits so the kernel brings up the -# desktop. Same leaf-renderer capset as input_probe (no SurfaceMap/Present). +# desktop. Same leaf-renderer capset as input_probe (no SurfaceMap/Present), +# plus EnrolDevRoot: setup is where a person lets this machine run what it +# installs, and no app window holds that right. CAPSULE_SLUG := setup-wizard CAPSULE_HANDLE := app.setup_wizard @@ -12,7 +14,7 @@ CAPSULE_FEATURE := nonos-capsule-setup-wizard CAPSULE_NAMESPACE := systems.nonos.app.setup_wizard CAPSULE_SERVICE_ENDPOINT := service:4794:app.setup_wizard CAPSULE_REPLY_ENDPOINT := reply:4795:endpoint.app.setup_wizard.reply -CAPSULE_REQUIRED_CAPS := 0x1819 +CAPSULE_REQUIRED_CAPS := 0x8001819 CAPSULE_KERNEL_MIRROR := src/userspace/capsule_setup_wizard include nonos-mk/capsule.mk diff --git a/userland/capsule_setup_wizard/Cargo.lock b/userland/capsule_setup_wizard/Cargo.lock index 75c2d23c20..ccd50708e0 100644 --- a/userland/capsule_setup_wizard/Cargo.lock +++ b/userland/capsule_setup_wizard/Cargo.lock @@ -55,10 +55,19 @@ dependencies = [ "scopeguard", ] +[[package]] +name = "nonos_app_skeleton" +version = "0.3.0" +dependencies = [ + "nonos_toolkit", + "nonos_userland_libc", +] + [[package]] name = "nonos_capsule_setup_wizard" version = "0.3.0" dependencies = [ + "nonos_app_skeleton", "nonos_policy_proto", "nonos_toolkit", "nonos_userland_libc", diff --git a/userland/capsule_setup_wizard/Cargo.toml b/userland/capsule_setup_wizard/Cargo.toml index 40372aa3c8..d320f3546d 100644 --- a/userland/capsule_setup_wizard/Cargo.toml +++ b/userland/capsule_setup_wizard/Cargo.toml @@ -11,6 +11,7 @@ path = "src/main.rs" [dependencies] nonos_libc = { package = "nonos_userland_libc", path = "../libc" } nonos_toolkit = { package = "nonos_toolkit", path = "../toolkit" } +nonos_app_skeleton = { path = "../app_skeleton", default-features = false } nonos_policy_proto = { path = "../policy_proto" } [features] diff --git a/userland/capsule_setup_wizard/src/consent.rs b/userland/capsule_setup_wizard/src/consent.rs new file mode 100644 index 0000000000..c0e4912f2f --- /dev/null +++ b/userland/capsule_setup_wizard/src/consent.rs @@ -0,0 +1,64 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! Whether this machine runs what it installs: decided in setup and nowhere +//! else, because widening what a machine executes is not a button in an app. + +use nonos_app_skeleton::clients::vfs; +use nonos_libc::{mk_getpid, mk_local_consent_grant, mk_local_consent_revoke, mk_local_restore}; + +/// Where the token that restores the decision is kept. It opens nothing on +/// another machine or under another kernel, so it may sit on the disk. +const TOKEN: &[u8] = b"/nonos/consent/local.token"; + +/// Restore a decision made on an earlier boot. True when there was one. +pub fn restore() -> bool { + let Ok(raw) = vfs::read_file(mk_getpid(), TOKEN, 32) else { + return false; + }; + let Ok(token) = <[u8; 32]>::try_from(raw.as_slice()) else { + return false; + }; + mk_local_restore(&token) == 0 +} + +/// Apply what the person chose. Withdrawing deletes the token too, so the +/// next boot does not quietly restore what was just taken back. +pub fn apply(allow: bool, was_allowed: bool) { + match (allow, was_allowed) { + (true, false) => grant(), + (false, true) => { + let _ = mk_local_consent_revoke(); + let _ = vfs::store_remove(TOKEN); + let _ = vfs::unlink(mk_getpid(), TOKEN); + } + _ => {} + } +} + +/// A machine with no key to keep consent with gets it for this boot only, +/// and nothing is written that could be mistaken for more. +fn grant() { + let Ok(Some(token)) = mk_local_consent_grant() else { + return; + }; + let pid = mk_getpid(); + let _ = vfs::mkdir(pid, b"/nonos"); + let _ = vfs::mkdir(pid, b"/nonos/consent"); + if vfs::write_file(pid, TOKEN, &token).is_ok() { + let _ = vfs::persist(pid, TOKEN); + } +} diff --git a/userland/capsule_setup_wizard/src/main.rs b/userland/capsule_setup_wizard/src/main.rs index 2475ce2c52..6712376038 100644 --- a/userland/capsule_setup_wizard/src/main.rs +++ b/userland/capsule_setup_wizard/src/main.rs @@ -4,6 +4,7 @@ extern crate alloc; mod clients; +mod consent; mod protocol; mod render; mod server; @@ -21,5 +22,8 @@ pub unsafe extern "C" fn _start() -> ! { Ok(ctx) => ctx, Err(_) => mk_exit(2), }; + let mut ctx = ctx; + ctx.local_was = consent::restore(); + ctx.local_sel = ctx.local_was as u8; server::runner::run(ctx) } diff --git a/userland/capsule_setup_wizard/src/render/screens/local_software.rs b/userland/capsule_setup_wizard/src/render/screens/local_software.rs new file mode 100644 index 0000000000..7a4d7d15e0 --- /dev/null +++ b/userland/capsule_setup_wizard/src/render/screens/local_software.rs @@ -0,0 +1,44 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! The one place a person lets this machine run software it installs. + +use crate::render::{self, widgets::rows}; +use crate::server::step::{default_key, list_nav, Outcome}; +use crate::state::Context; + +const MODES: &[&[u8]] = + &[b"Only software that ships with NONOS", b"Also programs this machine installs and proves"]; + +pub fn draw(ctx: &Context) { + render::frame( + ctx, + b"Installed software", + b"Programs the store installs are proved by this machine. Allow them to run?", + b"ENTER NEXT ESC BACK", + ); + let spx = ctx.stride as usize / 4; + let (w, h) = (ctx.width, ctx.height); + let buf = render::buffer(ctx); + rows::list(buf, spx, w, h, render::content_x(w), 110, MODES, ctx.local_sel as usize); +} + +pub fn on_key(ctx: &mut Context, code: u32) -> Outcome { + if let Some(o) = list_nav(&mut ctx.local_sel, MODES.len() as u8, code) { + return o; + } + default_key(code) +} diff --git a/userland/capsule_setup_wizard/src/render/screens/mod.rs b/userland/capsule_setup_wizard/src/render/screens/mod.rs index 797591b16d..7f1de546de 100644 --- a/userland/capsule_setup_wizard/src/render/screens/mod.rs +++ b/userland/capsule_setup_wizard/src/render/screens/mod.rs @@ -3,6 +3,7 @@ pub mod appearance; pub mod keyboard; pub mod keygen; pub mod language; +pub mod local_software; pub mod network; pub mod passphrase; pub mod persistence; @@ -23,7 +24,8 @@ pub fn draw(ctx: &Context) { 6 => admin::draw(ctx), 7 => privacy::draw(ctx), 8 => appearance::draw(ctx), - 9 => review::draw(ctx), + 9 => local_software::draw(ctx), + 10 => review::draw(ctx), _ => crate::render::frame(ctx, b"Setup", b"", b"ENTER NEXT ESC BACK"), } } @@ -39,7 +41,8 @@ pub fn on_key(ctx: &mut Context, code: u32) -> Outcome { 6 => admin::on_key(ctx, code), 7 => privacy::on_key(ctx, code), 8 => appearance::on_key(ctx, code), - 9 => review::on_key(ctx, code), + 9 => local_software::on_key(ctx, code), + 10 => review::on_key(ctx, code), _ => default_key(code), } } diff --git a/userland/capsule_setup_wizard/src/render/screens/review.rs b/userland/capsule_setup_wizard/src/render/screens/review.rs index 80e19724cc..9ed77ea6f7 100644 --- a/userland/capsule_setup_wizard/src/render/screens/review.rs +++ b/userland/capsule_setup_wizard/src/render/screens/review.rs @@ -32,6 +32,7 @@ fn commit(ctx: &Context) { let _ = policy::set_bool(p, Field::WifiAutoconnect as u32, ctx.net_sel == 1); let _ = policy::set_bool(p, Field::AutoWipe as u32, ctx.privacy & 0b010 != 0); let _ = policy::set_bool(p, Field::NymEnabled as u32, ctx.privacy & 0b001 != 0); + crate::consent::apply(ctx.local_sel == 1, ctx.local_was); if ctx.host_len > 0 { let _ = policy::set_str(p, Field::Hostname as u32, &ctx.host_buf[..ctx.host_len]); } diff --git a/userland/capsule_setup_wizard/src/server/step.rs b/userland/capsule_setup_wizard/src/server/step.rs index a1fd32bc6d..877fae9dff 100644 --- a/userland/capsule_setup_wizard/src/server/step.rs +++ b/userland/capsule_setup_wizard/src/server/step.rs @@ -1,4 +1,4 @@ -pub const DONE: u8 = 10; +pub const DONE: u8 = 11; pub const K_ENTER: u32 = 0x0D; pub const K_ENTER_LF: u32 = 0x0A; diff --git a/userland/capsule_setup_wizard/src/state.rs b/userland/capsule_setup_wizard/src/state.rs index 269c767207..057b5b1da5 100644 --- a/userland/capsule_setup_wizard/src/state.rs +++ b/userland/capsule_setup_wizard/src/state.rs @@ -18,6 +18,10 @@ pub struct Context { pub theme_sel: u8, pub net_sel: u8, pub persist_sel: u8, + /// 1 when installed programs may run. Starts at what an earlier boot + /// decided, so setup shows the standing choice rather than asking again. + pub local_sel: u8, + pub local_was: bool, pub privacy: u16, pub admin_len: usize, pub admin_buf: [u8; 64], @@ -56,6 +60,8 @@ impl Context { theme_sel: 0, net_sel: 0, persist_sel: 0, + local_sel: 0, + local_was: false, privacy: 0b0000_0011, admin_len: 0, admin_buf: [0u8; 64], diff --git a/userland/libc/src/consent.rs b/userland/libc/src/consent.rs index 49801df349..8ab84b13f4 100644 --- a/userland/libc/src/consent.rs +++ b/userland/libc/src/consent.rs @@ -16,7 +16,9 @@ //! Consent to run what this machine builds and fetches. -use crate::syscall::{call_raw, N_MK_DEV_ROOT_CONFIRM, N_MK_DEV_ROOT_LOCAL}; +use crate::syscall::{ + call_raw, N_MK_DEV_ROOT_CONFIRM, N_MK_DEV_ROOT_LOCAL, N_MK_LOCAL_CONSENT, N_MK_LOCAL_RESTORE, +}; /// Ask to enrol this machine's own build root, so what it installs can be /// proved. @@ -28,3 +30,25 @@ pub fn mk_dev_root_local() -> i64 { pub fn mk_dev_root_confirm(code: u32) -> i64 { call_raw(N_MK_DEV_ROOT_CONFIRM, [code as u64, 0, 0, 0, 0, 0]) } + +/// Let this machine run what it installs. `Ok(Some(token))` is consent that +/// lasts: keep the token and restore it on later boots. `Ok(None)` is consent +/// for this boot only, on a machine with no key to keep it with. +pub fn mk_local_consent_grant() -> Result, i64> { + let mut token = [0u8; 32]; + match call_raw(N_MK_LOCAL_CONSENT, [0, token.as_mut_ptr() as u64, 0, 0, 0, 0]) { + 1 => Ok(Some(token)), + 0 => Ok(None), + e => Err(e), + } +} + +/// Stop running what this machine installs. +pub fn mk_local_consent_revoke() -> i64 { + call_raw(N_MK_LOCAL_CONSENT, [1, 0, 0, 0, 0, 0]) +} + +/// Restore consent from the token a grant returned on this machine. +pub fn mk_local_restore(token: &[u8; 32]) -> i64 { + call_raw(N_MK_LOCAL_RESTORE, [token.as_ptr() as u64, 0, 0, 0, 0, 0]) +} diff --git a/userland/libc/src/lib.rs b/userland/libc/src/lib.rs index 2ae1a75650..4ea4be0e45 100644 --- a/userland/libc/src/lib.rs +++ b/userland/libc/src/lib.rs @@ -76,14 +76,19 @@ pub use crypto::{ MACHINE_KEY_NO_TPM, MACHINE_KEY_WRONG_STATE, }; pub use debug::mk_debug; -pub use consent::{mk_dev_root_confirm, mk_dev_root_local}; +pub use consent::{ + mk_dev_root_confirm, mk_dev_root_local, mk_local_consent_grant, mk_local_consent_revoke, + mk_local_restore, +}; pub use foreign::{ mk_foreign_exec, mk_foreign_fork, mk_foreign_reply, mk_foreign_resume, mk_foreign_spawn, mk_foreign_start, mk_foreign_thread, mk_foreign_wait, }; pub use foreign_frame::ForeignFrame; pub use graphics::nonos_display_dimensions; -pub use local_sign::{mk_app_install, mk_local_sign, mk_local_sign_len, mk_local_verify}; +pub use local_sign::{ + mk_app_install, mk_local_sign, mk_local_sign_len, mk_local_verify, +}; #[cfg(feature = "heap")] pub use heap::{init as heap_init, init_sized as heap_init_sized, HeapError}; pub use install_source::{ diff --git a/userland/libc/src/syscall/mod.rs b/userland/libc/src/syscall/mod.rs index ba5262c373..7b0e1504e8 100644 --- a/userland/libc/src/syscall/mod.rs +++ b/userland/libc/src/syscall/mod.rs @@ -28,7 +28,7 @@ pub(crate) use numbers::{ N_MK_ATTEST_DOC, N_MK_ATTEST_ENTRIES, N_MK_ATTEST_STATUS, N_MK_BATTERY_STATUS, N_MK_CAPSULE_LOAD, N_MK_CAPSULE_VERIFY, N_MK_CAP_CHECK, N_MK_CAP_GRANT, N_MK_CAP_REVOKE, N_MK_DEBUG, N_MK_DEVICE_CLAIM, N_MK_DEVICE_LIST, N_MK_DEVICE_RELEASE, N_MK_DISPLAY_VSYNC_WAIT, - N_MK_DMA_MAP, N_MK_DMA_UNMAP, N_MK_EXIT, N_MK_APP_INSTALL, N_MK_DEV_ROOT_CONFIRM, N_MK_DEV_ROOT_LOCAL, N_MK_FOREIGN_EXEC, N_MK_FOREIGN_FORK, N_MK_FOREIGN_REPLY, N_MK_FOREIGN_SPAWN, + N_MK_DMA_MAP, N_MK_DMA_UNMAP, N_MK_EXIT, N_MK_APP_INSTALL, N_MK_DEV_ROOT_CONFIRM, N_MK_DEV_ROOT_LOCAL, N_MK_LOCAL_CONSENT, N_MK_LOCAL_RESTORE, N_MK_FOREIGN_EXEC, N_MK_FOREIGN_FORK, N_MK_FOREIGN_REPLY, N_MK_FOREIGN_SPAWN, N_MK_FOREIGN_START, N_MK_FOREIGN_THREAD, N_MK_FOREIGN_WAIT, N_MK_FUTEX_WAIT, N_MK_GETPID, N_MK_INPUT_EVENT_DRAIN, N_MK_INPUT_EVENT_POST, N_MK_INPUT_EVENT_WAIT, N_MK_INSTALL_SOURCE, N_MK_IPC_CALL, N_MK_LOCAL_SIGN, N_MK_LOCAL_VERIFY, N_MK_IPC_RECV, N_MK_IPC_RECV_FROM, N_MK_IPC_REPLY, N_MK_IPC_SEND, N_MK_IPC_SEND_TO_PID, diff --git a/userland/libc/src/syscall/numbers/foreign.rs b/userland/libc/src/syscall/numbers/foreign.rs index 530eaf2f66..1887c2c2a9 100644 --- a/userland/libc/src/syscall/numbers/foreign.rs +++ b/userland/libc/src/syscall/numbers/foreign.rs @@ -35,4 +35,6 @@ pub(crate) const N_MK_LOCAL_SIGN: i64 = tag4(b"MLSG"); pub(crate) const N_MK_LOCAL_VERIFY: i64 = tag4(b"MLVF"); pub(crate) const N_MK_APP_INSTALL: i64 = tag4(b"MAIN"); pub(crate) const N_MK_DEV_ROOT_LOCAL: i64 = tag4(b"MDRO"); +pub(crate) const N_MK_LOCAL_CONSENT: i64 = tag4(b"MLCG"); +pub(crate) const N_MK_LOCAL_RESTORE: i64 = tag4(b"MLCR"); pub(crate) const N_MK_DEV_ROOT_CONFIRM: i64 = tag4(b"MDRC"); From d878f29faae876d597d2dbe42a757243bbcf240b Mon Sep 17 00:00:00 2001 From: eKisNonos Date: Sat, 26 Sep 2026 15:45:01 +0000 Subject: [PATCH 021/244] store: open what a package installed, and leave consent to setup MkAppLaunch queues a run for init, which spawns the personality to start the program the installer recorded outside /linux. Whether it may start is the exec gate's answer. The store drops its console-code enrolment, which it never held the capability for, and gains an o key to open. --- abi/syscalls.toml | 7 ++ src/syscall/abi/registry/mk.rs | 1 + src/syscall/contract/cap_table/mk.rs | 2 +- .../dispatch/router/microkernel_ops.rs | 1 + src/syscall/microkernel/app_install.rs | 21 ++++-- src/syscall/microkernel/app_launch.rs | 39 ++++++++++ src/syscall/microkernel/dispatch/process.rs | 2 + src/syscall/microkernel/mod.rs | 1 + src/syscall/microkernel/numbers.rs | 2 + src/syscall/numbers/defs.rs | 1 + src/userspace/capsule_linux/install.rs | 34 +++++---- src/userspace/capsule_linux/mod.rs | 3 +- src/userspace/capsule_linux/roles.rs | 43 +++++++++++ src/userspace/init/install_queue.rs | 74 ------------------- .../userspace/init/linux_jobs/mod.rs | 18 ++--- src/userspace/init/linux_jobs/queue.rs | 63 ++++++++++++++++ src/userspace/init/linux_jobs/service.rs | 58 +++++++++++++++ src/userspace/init/mod.rs | 8 +- .../capsule_app_store/src/store/consent.rs | 64 ---------------- .../src/store/event_actions.rs | 22 +++--- .../capsule_app_store/src/store/event_keys.rs | 5 +- .../capsule_app_store/src/store/install.rs | 25 +++++-- userland/capsule_app_store/src/store/mod.rs | 1 - userland/capsule_app_store/src/store/state.rs | 3 - .../capsule_app_store/src/store/state_ops.rs | 1 - .../capsule_app_store/src/store/ui/mod.rs | 1 - .../capsule_app_store/src/store/ui/status.rs | 7 +- .../capsule_linux/src/linux/install/index.rs | 4 +- .../capsule_linux/src/linux/install/mod.rs | 2 + .../capsule_linux/src/linux/install/place.rs | 9 ++- .../src/linux/install/program.rs | 57 ++++++++++++++ .../capsule_linux/src/linux/install/run.rs | 2 +- userland/capsule_linux/src/linux/request.rs | 12 +++ userland/capsule_linux/src/linux/source.rs | 15 +++- userland/capsule_linux/src/linux/start.rs | 5 +- userland/libc/src/lib.rs | 2 +- userland/libc/src/local_sign.rs | 9 ++- userland/libc/src/syscall/mod.rs | 2 +- userland/libc/src/syscall/numbers/foreign.rs | 1 + 39 files changed, 402 insertions(+), 225 deletions(-) create mode 100644 src/syscall/microkernel/app_launch.rs create mode 100644 src/userspace/capsule_linux/roles.rs delete mode 100644 src/userspace/init/install_queue.rs rename userland/capsule_app_store/src/store/ui/consent_text.rs => src/userspace/init/linux_jobs/mod.rs (62%) create mode 100644 src/userspace/init/linux_jobs/queue.rs create mode 100644 src/userspace/init/linux_jobs/service.rs delete mode 100644 userland/capsule_app_store/src/store/consent.rs create mode 100644 userland/capsule_linux/src/linux/install/program.rs diff --git a/abi/syscalls.toml b/abi/syscalls.toml index cd36153278..6213e58a71 100644 --- a/abi/syscalls.toml +++ b/abi/syscalls.toml @@ -93,6 +93,7 @@ MAIN = 0x4E49414D MDRO = 0x4F52444D MLCG = 0x47434C4D MLCR = 0x52434C4D +MAPL = 0x4C50414D MIRW = 0x5752494D MIRY = 0x5952494D MKAR = 0x52414B4D @@ -709,6 +710,12 @@ caps = ["EnrolDevRoot"] args = [{name="token_ptr",type="u8*",dir="in"}] ret = {type="i64"} +[desc.MAPL] +nr = 0x4C50414D +caps = ["AppInstall"] +args = [{name="listing_ptr",type="u64",dir="in"},{name="listing_len",type="u64",dir="in"}] +ret = {type="i64"} + [desc.MPPT] nr = 0x5450504D caps = ["ForeignExec"] diff --git a/src/syscall/abi/registry/mk.rs b/src/syscall/abi/registry/mk.rs index fcc8894c23..c03bc56035 100644 --- a/src/syscall/abi/registry/mk.rs +++ b/src/syscall/abi/registry/mk.rs @@ -109,6 +109,7 @@ pub(super) const ENTRIES: &[AbiEntry] = &[ e(b"MDRO", SyscallNumber::MkDevRootLocal, "MkDevRootLocal"), e(b"MLCG", SyscallNumber::MkLocalConsent, "MkLocalConsent"), e(b"MLCR", SyscallNumber::MkLocalRestore, "MkLocalRestore"), + e(b"MAPL", SyscallNumber::MkAppLaunch, "MkAppLaunch"), e(b"MTRN", SyscallNumber::MkToolRun, "MkToolRun"), e(b"MSOW", SyscallNumber::MkStdoutWrite, "MkStdoutWrite"), e(b"MSWR", SyscallNumber::MkStoreWrite, "MkStoreWrite"), diff --git a/src/syscall/contract/cap_table/mk.rs b/src/syscall/contract/cap_table/mk.rs index 5acaedf25d..e4f59b89fa 100644 --- a/src/syscall/contract/cap_table/mk.rs +++ b/src/syscall/contract/cap_table/mk.rs @@ -163,7 +163,7 @@ pub(super) fn check(caps: &CapabilityToken, number: SyscallNumber) -> Option caps.can_app_install(), + SyscallNumber::MkAppInstall | SyscallNumber::MkAppLaunch => caps.can_app_install(), SyscallNumber::MkSurfaceRegister | SyscallNumber::MkSurfaceShare diff --git a/src/syscall/dispatch/router/microkernel_ops.rs b/src/syscall/dispatch/router/microkernel_ops.rs index 2fc572075a..f303fc8137 100644 --- a/src/syscall/dispatch/router/microkernel_ops.rs +++ b/src/syscall/dispatch/router/microkernel_ops.rs @@ -101,6 +101,7 @@ pub(super) fn matches(nr: SyscallNumber) -> bool { | MkDevRootLocal | MkLocalConsent | MkLocalRestore + | MkAppLaunch | MkToolRun ) } diff --git a/src/syscall/microkernel/app_install.rs b/src/syscall/microkernel/app_install.rs index 04220baf46..782b45232b 100644 --- a/src/syscall/microkernel/app_install.rs +++ b/src/syscall/microkernel/app_install.rs @@ -14,7 +14,6 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . - //! `MkAppInstall`: ask for a marketplace listing to be installed. use alloc::string::String; @@ -22,17 +21,21 @@ use alloc::string::String; use crate::syscall::microkernel::errnos::{ERRNO_BUSY, ERRNO_FAULT, ERRNO_INVAL}; use crate::usercopy::{read_user_bytes, validate_user_read}; -/// Long enough for any real listing or release id and short enough that -/// neither argument can become a payload. +/// Long enough for any real id, short enough not to become a payload. const MAX_ID: usize = 96; -/// The only listings with anything to fetch are distribution packages. +/// Only distribution packages have anything to fetch. const HOSTED: &str = "linux."; /// `MkAppInstall(listing_ptr, listing_len, release_ptr, release_len)`. An /// empty release asks for the listing's default. Nothing the caller says /// about readiness is taken: init asks the market before anything runs. -pub fn sys_app_install(listing_ptr: u64, listing_len: u64, release_ptr: u64, release_len: u64) -> i64 { +pub fn sys_app_install( + listing_ptr: u64, + listing_len: u64, + release_ptr: u64, + release_len: u64, +) -> i64 { let listing = match id(listing_ptr, listing_len) { Ok(Some(s)) => s, Ok(None) => return ERRNO_INVAL, @@ -42,7 +45,8 @@ pub fn sys_app_install(listing_ptr: u64, listing_len: u64, release_ptr: u64, rel Ok(s) => s.unwrap_or_default(), Err(e) => return e, }; - if !listing.strip_prefix(HOSTED).is_some_and(|name| !name.is_empty() && !name.starts_with('.')) { + if !listing.strip_prefix(HOSTED).is_some_and(|name| !name.is_empty() && !name.starts_with('.')) + { return ERRNO_INVAL; } match crate::userspace::init::request_install(listing, release) { @@ -53,7 +57,7 @@ pub fn sys_app_install(listing_ptr: u64, listing_len: u64, release_ptr: u64, rel /// One id argument. `None` for an empty one. The id reaches a URL and a store /// path, so it is held to what a package id actually is. -fn id(ptr: u64, len: u64) -> Result, i64> { +pub(super) fn id(ptr: u64, len: u64) -> Result, i64> { let len = usize::try_from(len).map_err(|_| ERRNO_INVAL)?; if len == 0 { return Ok(None); @@ -62,7 +66,8 @@ fn id(ptr: u64, len: u64) -> Result, i64> { return Err(ERRNO_INVAL); } let raw = read_user_bytes(ptr, len).map_err(|_| ERRNO_FAULT)?; - let allowed = |b: &u8| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'+' | b'.' | b'@'); + let allowed = + |b: &u8| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'+' | b'.' | b'@'); if !raw.iter().all(allowed) { return Err(ERRNO_INVAL); } diff --git a/src/syscall/microkernel/app_launch.rs b/src/syscall/microkernel/app_launch.rs new file mode 100644 index 0000000000..395ab1dbca --- /dev/null +++ b/src/syscall/microkernel/app_launch.rs @@ -0,0 +1,39 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! `MkAppLaunch`: start the program a distribution package installed. + +use crate::syscall::microkernel::errnos::{ERRNO_BUSY, ERRNO_INVAL}; + +use super::app_install::id; + +/// `MkAppLaunch(listing_ptr, listing_len)`. Queues the run for init; whether +/// the program may start is the exec gate's answer, which checks the trailer +/// the machine minted when it installed the package. +pub fn sys_app_launch(listing_ptr: u64, listing_len: u64) -> i64 { + let listing = match id(listing_ptr, listing_len) { + Ok(Some(s)) => s, + Ok(None) => return ERRNO_INVAL, + Err(e) => return e, + }; + let Some(name) = listing.strip_prefix("linux.").filter(|n| !n.is_empty()) else { + return ERRNO_INVAL; + }; + match crate::userspace::init::request_run(alloc::string::String::from(name)) { + true => 0, + false => ERRNO_BUSY, + } +} diff --git a/src/syscall/microkernel/dispatch/process.rs b/src/syscall/microkernel/dispatch/process.rs index b3f853df8e..e8895e5d8f 100644 --- a/src/syscall/microkernel/dispatch/process.rs +++ b/src/syscall/microkernel/dispatch/process.rs @@ -21,6 +21,7 @@ use crate::process::foreign::{ sys_peer_tls, sys_peer_unmap, }; use crate::syscall::microkernel::app_install::sys_app_install; +use crate::syscall::microkernel::app_launch::sys_app_launch; use crate::syscall::microkernel::attest::sys_attest_status; use crate::syscall::microkernel::attest_doc::sys_attest_doc; use crate::syscall::microkernel::attest_entries::sys_attest_entries; @@ -102,6 +103,7 @@ pub(super) fn handle(nr: u64, a: Args) -> Option { SYS_DEV_ROOT_LOCAL => sys_dev_root_local(), SYS_LOCAL_CONSENT => sys_local_consent(a.a0, a.a1), SYS_LOCAL_RESTORE => sys_local_restore(a.a0), + SYS_APP_LAUNCH => sys_app_launch(a.a0, a.a1), SYS_DEV_ROOT_REQUEST => sys_dev_root_request(a.a0), SYS_DEV_ROOT_CONFIRM => sys_dev_root_confirm(a.a0), SYS_SPAWN_INSTANCE => sys_spawn_instance(a.a0, a.a1), diff --git a/src/syscall/microkernel/mod.rs b/src/syscall/microkernel/mod.rs index 83238b4916..590edd7d65 100644 --- a/src/syscall/microkernel/mod.rs +++ b/src/syscall/microkernel/mod.rs @@ -39,6 +39,7 @@ pub mod ipc; pub mod irq; pub mod kill; pub mod app_install; +pub mod app_launch; pub mod enrol_local_root; mod local_image; pub mod local_consent; diff --git a/src/syscall/microkernel/numbers.rs b/src/syscall/microkernel/numbers.rs index 86967cc71a..24a50199b2 100644 --- a/src/syscall/microkernel/numbers.rs +++ b/src/syscall/microkernel/numbers.rs @@ -102,6 +102,8 @@ pub const SYS_DEV_ROOT_LOCAL: u64 = tag4(b"MDRO"); pub const SYS_LOCAL_CONSENT: u64 = tag4(b"MLCG"); /// Restore that consent, at setup, from the token a grant returned. pub const SYS_LOCAL_RESTORE: u64 = tag4(b"MLCR"); +/// Start the program a distribution package installed. +pub const SYS_APP_LAUNCH: u64 = tag4(b"MAPL"); /// Ask to enrol a signing root so software built here runs here. Prints a /// confirmation code; enrols nothing on its own. pub const SYS_DEV_ROOT_REQUEST: u64 = tag4(b"MDRQ"); diff --git a/src/syscall/numbers/defs.rs b/src/syscall/numbers/defs.rs index 56a18ece34..7a68a99375 100644 --- a/src/syscall/numbers/defs.rs +++ b/src/syscall/numbers/defs.rs @@ -130,4 +130,5 @@ pub enum SyscallNumber { MkDevRootLocal = tag4(b"MDRO"), MkLocalConsent = tag4(b"MLCG"), MkLocalRestore = tag4(b"MLCR"), + MkAppLaunch = tag4(b"MAPL"), } diff --git a/src/userspace/capsule_linux/install.rs b/src/userspace/capsule_linux/install.rs index 283f5f237a..2e955683e6 100644 --- a/src/userspace/capsule_linux/install.rs +++ b/src/userspace/capsule_linux/install.rs @@ -14,14 +14,17 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -//! The personality, spawned to install a package rather than host one. +//! The personality, spawned to install a package or to run one, rather than +//! to host the built-in program. use alloc::string::String; use alloc::vec; +use alloc::vec::Vec; use super::embed::{ LINUX_ATTESTATION_BYTES, LINUX_ELF, LINUX_MANIFEST_BYTES, LINUX_NONOS_ID_CERT_BYTES, }; +use super::roles::{Role, INSTALL, RUN}; use super::spawn::LINUX_CAPS; use crate::kernel_core::process_spawn::capsule_spawn::{self, CapsuleSpecVerified, SpawnError}; use crate::security::nonos_id_cert::IdCertVerifyError; @@ -29,33 +32,34 @@ use crate::security::nonos_trust_anchor::{ decode as decode_trust_anchor, BAKED_TRUST_ANCHOR_POLICY, }; -// A second service name, because the installer is a second live process and -// two of them announcing one endpoint is a race over which answers. -const SERVICE_NAME: &str = "app.linux.install"; -const SERVICE_PORT: u32 = 4938; -const REPLY_INBOX: &str = "endpoint.app.linux.install.reply"; -const REPLY_PORT: u32 = 4939; - /// Spawn the installer for `package`, which must hash to `pinned`. pub fn spawn_install(package: &str, pinned: &[u8; 32]) -> Result { + let hex: String = pinned.iter().map(|b| alloc::format!("{b:02x}")).collect(); + spawn(&INSTALL, vec![String::from("install"), String::from(package), hex]) +} + +/// Spawn the personality to run the program `package` installed. +pub fn spawn_run(package: &str) -> Result { + spawn(&RUN, vec![String::from("run"), String::from(package)]) +} + +fn spawn(role: &Role, argv: Vec) -> Result { let trust_anchor = decode_trust_anchor(BAKED_TRUST_ANCHOR_POLICY) .map_err(|_| SpawnError::NonosIdCertRejected(IdCertVerifyError::TrustAnchorPolicy))?; let spec = CapsuleSpecVerified { - name: SERVICE_NAME, - service_port: SERVICE_PORT, - reply_inbox: REPLY_INBOX, - reply_port: REPLY_PORT, + name: role.name, + service_port: role.port, + reply_inbox: role.inbox, + reply_port: role.reply_port, elf: LINUX_ELF, nonos_id_cert_bytes: LINUX_NONOS_ID_CERT_BYTES, manifest_bytes: LINUX_MANIFEST_BYTES, attestation_trailer: LINUX_ATTESTATION_BYTES, target_triple: env!("NONOS_USER_TARGET"), requested_caps: LINUX_CAPS, - debug_tag: b"[LINUX-INSTALL] elf error:", + debug_tag: role.tag, }; let pid = capsule_spawn::spawn_verified(&spec, &trust_anchor, None)?; - let hex: String = pinned.iter().map(|b| alloc::format!("{b:02x}")).collect(); - let argv = vec![String::from("install"), String::from(package), hex]; crate::process::with_process(pid, |pcb| *pcb.argv.lock() = argv); Ok(pid) } diff --git a/src/userspace/capsule_linux/mod.rs b/src/userspace/capsule_linux/mod.rs index a4f0158a5b..c0676cb4b1 100644 --- a/src/userspace/capsule_linux/mod.rs +++ b/src/userspace/capsule_linux/mod.rs @@ -19,9 +19,10 @@ mod embed; mod install; +mod roles; mod spawn; mod state; -pub use install::spawn_install; +pub use install::{spawn_install, spawn_run}; pub use spawn::{spawn_linux_capsule, LINUX_CAPS}; pub use state::shared_state; diff --git a/src/userspace/capsule_linux/roles.rs b/src/userspace/capsule_linux/roles.rs new file mode 100644 index 0000000000..4b5e33efab --- /dev/null +++ b/src/userspace/capsule_linux/roles.rs @@ -0,0 +1,43 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! The endpoints the personality answers on in each role it is spawned for. + +/// Each role is its own live process with its own endpoints: two of them +/// announcing one endpoint is a race over which answers. +pub(super) struct Role { + pub name: &'static str, + pub port: u32, + pub inbox: &'static str, + pub reply_port: u32, + pub tag: &'static [u8], +} + +pub(super) const INSTALL: Role = Role { + name: "app.linux.install", + port: 4938, + inbox: "endpoint.app.linux.install.reply", + reply_port: 4939, + tag: b"[LINUX-INSTALL] elf error:", +}; + +pub(super) const RUN: Role = Role { + name: "app.linux.run", + port: 4942, + inbox: "endpoint.app.linux.run.reply", + reply_port: 4943, + tag: b"[LINUX-RUN] elf error:", +}; diff --git a/src/userspace/init/install_queue.rs b/src/userspace/init/install_queue.rs deleted file mode 100644 index e4ab4ed751..0000000000 --- a/src/userspace/init/install_queue.rs +++ /dev/null @@ -1,74 +0,0 @@ -// NONOS Operating System -// Copyright (C) 2026 NONOS Contributors -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - - -//! Package installs asked for by a capsule, performed by init once the -//! market says the listing is ready and names the bytes to expect. - -use alloc::string::String; -use alloc::vec::Vec; -use spin::Mutex; - -use crate::security::market_capsule::client::{queued_get_release, queued_install_ready}; -use crate::sys::serial::{print, println}; - -/// Deeper than a person clicks, shallower than a caller in a loop can grow. -const DEPTH: usize = 8; - -/// A listing and the release asked for, which is empty for the default. -static PENDING: Mutex> = Mutex::new(Vec::new()); - -/// Record a request. False when full, which the caller reports as busy. -pub(crate) fn request(listing: String, release: String) -> bool { - let mut q = PENDING.lock(); - if q.len() >= DEPTH || q.iter().any(|(l, _)| *l == listing) { - return false; - } - q.push((listing, release)); - drop(q); - super::instance_spawn::raise_drain(); - true -} - -/// Whether an install is waiting; a contended lock is a push in flight. -pub(crate) fn has_pending() -> bool { - PENDING.try_lock().map_or(true, |q| !q.is_empty()) -} - -/// Perform every queued install the market still vouches for. -pub(crate) fn service() { - let taken = core::mem::take(&mut *PENDING.lock()); - for (listing, release) in taken { - let Some(name) = listing.strip_prefix("linux.") else { continue }; - /* - * The store showed the listing as ready, and that was its word. The - * market's own verdict is asked for again here, and the release's - * package hash goes to the installer, which refuses any other bytes. - */ - let ready = queued_install_ready(&listing, &release).is_ok_and(|r| r.install_ready); - let pinned = queued_get_release(&listing, &release).ok().map(|r| r.package_hash); - let (true, Some(hash)) = (ready, pinned) else { - print(b"[LINUX-INSTALL] not ready, refused "); - println(listing.as_bytes()); - continue; - }; - match crate::userspace::capsule_linux::spawn_install(name, &hash) { - Ok(_) => print(b"[LINUX-INSTALL] started "), - Err(_) => print(b"[LINUX-INSTALL] refused "), - } - println(listing.as_bytes()); - } -} diff --git a/userland/capsule_app_store/src/store/ui/consent_text.rs b/src/userspace/init/linux_jobs/mod.rs similarity index 62% rename from userland/capsule_app_store/src/store/ui/consent_text.rs rename to src/userspace/init/linux_jobs/mod.rs index ec9e594184..23c0eae8d9 100644 --- a/userland/capsule_app_store/src/store/ui/consent_text.rs +++ b/src/userspace/init/linux_jobs/mod.rs @@ -14,16 +14,12 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -//! How each stage of enrolment reads to the user. -use crate::store::consent::Consent; +//! Work a capsule asks the Linux personality to do, performed by init: an +//! install once the market vouches for it, or a run of what was installed. -pub fn label(c: Consent) -> &'static [u8] { - match c { - Consent::Idle => b"", - Consent::Typing(_, 0) => b"code is on the console; type it", - Consent::Typing(..) => b"typing code, Enter to confirm", - Consent::Granted => b"this machine will run what it installs", - Consent::Refused => b"enrolment refused", - } -} +mod queue; +mod service; + +pub(crate) use queue::{has_pending, request_install, request_run}; +pub(crate) use service::service; diff --git a/src/userspace/init/linux_jobs/queue.rs b/src/userspace/init/linux_jobs/queue.rs new file mode 100644 index 0000000000..5dd93f717b --- /dev/null +++ b/src/userspace/init/linux_jobs/queue.rs @@ -0,0 +1,63 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +use alloc::string::String; +use alloc::vec::Vec; +use spin::Mutex; + +/// Deeper than a person clicks, shallower than a caller in a loop can grow. +const DEPTH: usize = 8; + +#[derive(PartialEq, Eq)] +pub(super) enum Job { + /// A listing and the release asked for, which is empty for the default. + Install(String, String), + /// A package whose program should start. + Run(String), +} + +static PENDING: Mutex> = Mutex::new(Vec::new()); + +/// Queue an install. False when full or already queued, which the caller +/// reports as busy. +pub(crate) fn request_install(listing: String, release: String) -> bool { + push(Job::Install(listing, release)) +} + +/// Queue a run. False when full or already queued. +pub(crate) fn request_run(package: String) -> bool { + push(Job::Run(package)) +} + +fn push(job: Job) -> bool { + let mut q = PENDING.lock(); + if q.len() >= DEPTH || q.contains(&job) { + return false; + } + q.push(job); + drop(q); + super::super::instance_spawn::raise_drain(); + true +} + +/// Whether a job is waiting; a contended lock is a push in flight. +pub(crate) fn has_pending() -> bool { + PENDING.try_lock().map_or(true, |q| !q.is_empty()) +} + +pub(super) fn take() -> Vec { + core::mem::take(&mut *PENDING.lock()) +} diff --git a/src/userspace/init/linux_jobs/service.rs b/src/userspace/init/linux_jobs/service.rs new file mode 100644 index 0000000000..3e07f74350 --- /dev/null +++ b/src/userspace/init/linux_jobs/service.rs @@ -0,0 +1,58 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +use crate::security::market_capsule::client::{queued_get_release, queued_install_ready}; +use crate::sys::serial::{print, println}; +use crate::userspace::capsule_linux::{spawn_install, spawn_run}; + +use super::queue::{take, Job}; + +/// Perform every queued job. +pub(crate) fn service() { + for job in take() { + match job { + Job::Install(listing, release) => install(&listing, &release), + Job::Run(package) => { + let said: &[u8] = match spawn_run(&package) { + Ok(_) => b"[LINUX-RUN] started ", + Err(_) => b"[LINUX-RUN] refused ", + }; + print(said); + println(package.as_bytes()); + } + } + } +} + +fn install(listing: &str, release: &str) { + let Some(name) = listing.strip_prefix("linux.") else { return }; + /* + * The store showed the listing as ready, and that was its word. The + * market's own verdict is asked for again here, and the release's + * package hash goes to the installer, which refuses any other bytes. + */ + let ready = queued_install_ready(listing, release).is_ok_and(|r| r.install_ready); + let pinned = queued_get_release(listing, release).ok().map(|r| r.package_hash); + let said: &[u8] = match (ready, pinned) { + (true, Some(hash)) => match spawn_install(name, &hash) { + Ok(_) => b"[LINUX-INSTALL] started ", + Err(_) => b"[LINUX-INSTALL] refused ", + }, + _ => b"[LINUX-INSTALL] not ready, refused ", + }; + print(said); + println(listing.as_bytes()); +} diff --git a/src/userspace/init/mod.rs b/src/userspace/init/mod.rs index 4e8455352b..fdb1a0d564 100644 --- a/src/userspace/init/mod.rs +++ b/src/userspace/init/mod.rs @@ -16,16 +16,16 @@ mod capsule_boot; mod entry; -mod install_queue; mod instance_spawn; +mod linux_jobs; use instance_spawn::set_drain_priority as set_init_priority; mod spawn_plan; mod supervisor; pub use entry::run_init; -pub(crate) use install_queue::request as request_install; -pub(crate) use install_queue::has_pending as installs_pending; -pub(crate) use install_queue::service as service_installs; +pub(crate) use linux_jobs::{request_install, request_run}; +pub(crate) use linux_jobs::has_pending as installs_pending; +pub(crate) use linux_jobs::service as service_installs; pub(crate) use instance_spawn::has_pending as instance_spawns_pending; pub(crate) use instance_spawn::service as service_instance_spawns; pub use instance_spawn::{request as request_instance, PendingApp}; diff --git a/userland/capsule_app_store/src/store/consent.rs b/userland/capsule_app_store/src/store/consent.rs deleted file mode 100644 index 1fe1cbad7d..0000000000 --- a/userland/capsule_app_store/src/store/consent.rs +++ /dev/null @@ -1,64 +0,0 @@ -// NONOS Operating System -// Copyright (C) 2026 NONOS Contributors -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -//! Consenting to run what this machine installs. - -use nonos_libc::{mk_dev_root_confirm, mk_dev_root_local}; - -/// The challenge is a 32-bit number, so ten digits is every value it -/// can take and one more would be a typo rather than a longer code. -const MAX_DIGITS: usize = 10; - -#[derive(Clone, Copy, PartialEq, Eq)] -pub enum Consent { - /// Nothing asked for yet. - Idle, - /// A code is on the console and these are the digits typed so far. - Typing(u32, u8), - Granted, - Refused, -} - -impl Consent { - /// The code goes to the console, not to the return value. - pub fn begin() -> Consent { - match mk_dev_root_local() { - 0 => Consent::Typing(0, 0), - _ => Consent::Refused, - } - } - - pub fn digit(self, d: u32) -> Consent { - match self { - Consent::Typing(v, n) if (n as usize) < MAX_DIGITS => { - Consent::Typing(v.wrapping_mul(10).wrapping_add(d), n + 1) - } - other => other, - } - } - - pub fn submit(self) -> Consent { - let Consent::Typing(code, n) = self else { return self }; - if n == 0 { - return self; - } - match mk_dev_root_confirm(code) { - n if n < 0 => Consent::Refused, - _ => Consent::Granted, - } - } - -} diff --git a/userland/capsule_app_store/src/store/event_actions.rs b/userland/capsule_app_store/src/store/event_actions.rs index 3c09f5ac3c..c42faab1a2 100644 --- a/userland/capsule_app_store/src/store/event_actions.rs +++ b/userland/capsule_app_store/src/store/event_actions.rs @@ -18,29 +18,25 @@ use nonos_app_skeleton::{EventOutcome, KEY_ENTER}; -use super::consent::Consent; use super::install; use super::state::State; -const KEY_E: u32 = b'e' as u32; +const KEY_O: u32 = b'o' as u32; const KEY_R: u32 = b'r' as u32; pub(super) fn act(state: &mut State, code: u32) -> EventOutcome { let changed = match code { - KEY_E => { - state.consent = Consent::begin(); - true - } - // Enter confirms a typed code, installs otherwise. KEY_ENTER => { - match state.consent { - Consent::Typing(..) => state.consent = state.consent.submit(), - _ => state.asked = Some(install::ask(state)), - } + state.asked = Some(install::ask(state)); true } - d if (b'0' as u32..=b'9' as u32).contains(&d) => { - state.consent = state.consent.digit(d - b'0' as u32); + /* + * Whether the program may start is not this window's answer: the + * kernel queues the run, and the exec gate checks the trailer the + * machine minted at install under the consent given in setup. + */ + KEY_O => { + state.asked = Some(install::open(state)); true } KEY_R => { diff --git a/userland/capsule_app_store/src/store/event_keys.rs b/userland/capsule_app_store/src/store/event_keys.rs index f714134dd2..06b6a1ce3e 100644 --- a/userland/capsule_app_store/src/store/event_keys.rs +++ b/userland/capsule_app_store/src/store/event_keys.rs @@ -30,10 +30,7 @@ use super::state::State; const KEY_SLASH: u32 = b'/' as u32; pub fn on_key(state: &mut State, code: u32) -> EventOutcome { - /* - * The field takes the keyboard while open, or a name with a digit in it - * types into the consent code. - */ + // The field takes the keyboard while open. if state.search.active { if let Some(outcome) = typing(state, code) { return outcome; diff --git a/userland/capsule_app_store/src/store/install.rs b/userland/capsule_app_store/src/store/install.rs index c098ea6e16..81bf8e7bab 100644 --- a/userland/capsule_app_store/src/store/install.rs +++ b/userland/capsule_app_store/src/store/install.rs @@ -16,7 +16,7 @@ //! Asking for the selected listing to be installed. -use nonos_libc::mk_app_install; +use nonos_libc::{mk_app_install, mk_app_launch}; use super::state::State; @@ -24,6 +24,7 @@ use super::state::State; #[derive(Clone, Copy, PartialEq, Eq)] pub enum Asked { Queued, + Opening, Busy, Refused, NotInstallable, @@ -33,6 +34,7 @@ impl Asked { pub fn label(self) -> &'static [u8] { match self { Asked::Queued => b"install requested", + Asked::Opening => b"starting", Asked::Busy => b"too many installs already queued", Asked::Refused => b"the system refused the request", Asked::NotInstallable => b"nothing to fetch for this listing", @@ -44,11 +46,7 @@ pub fn ask(state: &State) -> Asked { let Some(listing) = state.current() else { return Asked::NotInstallable; }; - /* - * Only a distribution package has anything to fetch. `ready` only saves - * a pointless request: the kernel asks the market again before anything - * is fetched, so a stale or forged flag here decides nothing. - */ + // `ready` only saves a request: the kernel asks the market again. if !listing.id.starts_with(b"linux.") || !listing.ready { return Asked::NotInstallable; } @@ -58,3 +56,18 @@ pub fn ask(state: &State) -> Asked { _ => Asked::Refused, } } + +/// Ask for the selected listing's program to start. +pub fn open(state: &State) -> Asked { + let Some(listing) = state.current() else { + return Asked::NotInstallable; + }; + if !listing.id.starts_with(b"linux.") { + return Asked::NotInstallable; + } + match mk_app_launch(&listing.id) { + 0 => Asked::Opening, + -16 => Asked::Busy, + _ => Asked::Refused, + } +} diff --git a/userland/capsule_app_store/src/store/mod.rs b/userland/capsule_app_store/src/store/mod.rs index 1e8387bc0a..1cd7f35c7c 100644 --- a/userland/capsule_app_store/src/store/mod.rs +++ b/userland/capsule_app_store/src/store/mod.rs @@ -20,7 +20,6 @@ mod app; mod event; -mod consent; mod event_actions; mod event_click; mod event_keys; diff --git a/userland/capsule_app_store/src/store/state.rs b/userland/capsule_app_store/src/store/state.rs index 595c4a50d8..4dbbf57a54 100644 --- a/userland/capsule_app_store/src/store/state.rs +++ b/userland/capsule_app_store/src/store/state.rs @@ -23,7 +23,6 @@ use alloc::vec::Vec; use super::listing::Listing; use super::market; - pub struct State { pub listings: Vec, pub tab: Tab, @@ -38,8 +37,6 @@ pub struct State { /// What the last install request was answered with, shown until the next /// one. pub asked: Option, - /// Where enrolment has got to. - pub consent: super::consent::Consent, /// Description and publisher for the selected listing, fetched once per /// selection. pub search: super::search::Search, diff --git a/userland/capsule_app_store/src/store/state_ops.rs b/userland/capsule_app_store/src/store/state_ops.rs index 09de3ca200..99b0b504dd 100644 --- a/userland/capsule_app_store/src/store/state_ops.rs +++ b/userland/capsule_app_store/src/store/state_ops.rs @@ -33,7 +33,6 @@ impl State { trouble: None, ready: None, asked: None, - consent: super::consent::Consent::Idle, search: super::search::Search::default(), detail: None, }; diff --git a/userland/capsule_app_store/src/store/ui/mod.rs b/userland/capsule_app_store/src/store/ui/mod.rs index 9112fe8750..a3a0868115 100644 --- a/userland/capsule_app_store/src/store/ui/mod.rs +++ b/userland/capsule_app_store/src/store/ui/mod.rs @@ -18,7 +18,6 @@ pub mod chrome; mod card; -mod consent_text; mod counter; mod detail; mod frame; diff --git a/userland/capsule_app_store/src/store/ui/status.rs b/userland/capsule_app_store/src/store/ui/status.rs index 672f84d7ba..a64d6f963a 100644 --- a/userland/capsule_app_store/src/store/ui/status.rs +++ b/userland/capsule_app_store/src/store/ui/status.rs @@ -30,14 +30,11 @@ pub fn paint(fb: &mut PaintBuffer, state: &State) { fb.fill_rect(0, y, state.fb_w, STATUS_H, STATUS_BG); fb.fill_rect(0, y, state.fb_w, 1, RULE); let top = text::top_of(y as i32, STATUS_H, SMALL_PX); - let keys: &[u8] = b"up/down select Enter install e enrol r refresh Esc close"; + let keys: &[u8] = b"up/down select Enter install o open r refresh Esc close"; text::line(fb, STATUS_PAD_X, top, keys, MUTED, SMALL_PX); // The answer to the last request sits opposite the keys. let right = state.fb_w.saturating_sub(STATUS_PAD_X); - let consent = super::consent_text::label(state.consent); - if !consent.is_empty() { - text::right(fb, right, top, consent, ACCENT, SMALL_PX); - } else if let Some(asked) = state.asked { + if let Some(asked) = state.asked { text::right(fb, right, top, asked.label(), ACCENT, SMALL_PX); } } diff --git a/userland/capsule_linux/src/linux/install/index.rs b/userland/capsule_linux/src/linux/install/index.rs index fa8fe415be..7b91a592da 100644 --- a/userland/capsule_linux/src/linux/install/index.rs +++ b/userland/capsule_linux/src/linux/install/index.rs @@ -16,10 +16,10 @@ //! The distribution's package index, as this capsule needs it. -use alloc::string::String; -use alloc::vec::Vec; use super::pkg::bare; pub use super::pkg::Pkg; +use alloc::string::String; +use alloc::vec::Vec; pub struct Index { pkgs: Vec, diff --git a/userland/capsule_linux/src/linux/install/mod.rs b/userland/capsule_linux/src/linux/install/mod.rs index abbe3a8676..d9e541b066 100644 --- a/userland/capsule_linux/src/linux/install/mod.rs +++ b/userland/capsule_linux/src/linux/install/mod.rs @@ -26,8 +26,10 @@ mod index_load; mod pkg; mod place; mod place_entry; +mod program; mod run; mod tar; mod tar_field; +pub use program::recorded; pub use run::install; diff --git a/userland/capsule_linux/src/linux/install/place.rs b/userland/capsule_linux/src/linux/install/place.rs index bb9990d62b..973720ae29 100644 --- a/userland/capsule_linux/src/linux/install/place.rs +++ b/userland/capsule_linux/src/linux/install/place.rs @@ -17,10 +17,15 @@ use super::auth::Verified; use super::place_entry::one; +use super::program::record; use super::tar::entries; /// Unpack a package's authenticated files into the store and report how -/// many landed. -pub fn unpack(files: &Verified) -> usize { +/// many landed. `chosen` names the package the person asked for, whose +/// program is recorded so it can be started later. +pub fn unpack(files: &Verified, chosen: Option<&str>) -> usize { + if let Some(name) = chosen { + record(name, files); + } entries(files.files()).iter().filter(|entry| one(entry)).count() } diff --git a/userland/capsule_linux/src/linux/install/program.rs b/userland/capsule_linux/src/linux/install/program.rs new file mode 100644 index 0000000000..69750dbe29 --- /dev/null +++ b/userland/capsule_linux/src/linux/install/program.rs @@ -0,0 +1,57 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! Which program an installed package starts as, recorded for `run`. + +use alloc::vec::Vec; + +use nonos_app_skeleton::clients::vfs; +use nonos_libc::mk_getpid; + +use super::auth::Verified; +use super::tar::entries; + +/// Outside `/linux`, where no guest can rewrite what its package starts as. +const RECORDS: &[u8] = b"/nonos/linux/apps/"; + +/// Record `usr/bin/` if the package has it, else its first program. +pub(super) fn record(name: &str, files: &Verified) { + let all = entries(files.files()); + let mut programs = all + .iter() + .filter(|e| e.name.starts_with(b"usr/bin/") && e.body.starts_with(b"\x7fELF")) + .map(|e| e.name.as_slice()); + let own = [b"usr/bin/".as_slice(), name.as_bytes()].concat(); + let chosen = match all.iter().any(|e| e.name == own) { + true => Some(own.as_slice()), + false => programs.next(), + }; + let Some(path) = chosen else { return }; + let pid = mk_getpid(); + for dir in [b"/nonos".as_slice(), b"/nonos/linux", b"/nonos/linux/apps"] { + let _ = vfs::mkdir(pid, dir); + } + let _ = vfs::write_file(pid, &at(name), &[b"/".as_slice(), path].concat()); +} + +/// The guest-visible path `name` starts as, if it was installed. +pub fn recorded(name: &str) -> Option> { + vfs::read_file(mk_getpid(), &at(name), 256).ok().filter(|p| p.starts_with(b"/")) +} + +fn at(name: &str) -> Vec { + [RECORDS, name.as_bytes()].concat() +} diff --git a/userland/capsule_linux/src/linux/install/run.rs b/userland/capsule_linux/src/linux/install/run.rs index fb5f637139..75883277b1 100644 --- a/userland/capsule_linux/src/linux/install/run.rs +++ b/userland/capsule_linux/src/linux/install/run.rs @@ -63,7 +63,7 @@ pub fn install(name: &str, pin: &[u8; 32]) -> bool { return false; }; say(b"[LINUX] provenance Verified: index signature and checksums match\n"); - unpack(&files); + unpack(&files, (pkg.name == name).then_some(name)); done.push(pkg.name.clone()); wanted.extend(pkg.depends.iter().cloned()); } diff --git a/userland/capsule_linux/src/linux/request.rs b/userland/capsule_linux/src/linux/request.rs index 227a340140..ac698fbf6c 100644 --- a/userland/capsule_linux/src/linux/request.rs +++ b/userland/capsule_linux/src/linux/request.rs @@ -48,3 +48,15 @@ pub fn install_request() -> Option<(String, [u8; 32])> { } Some((String::from(core::str::from_utf8(name).ok()?), pin)) } + +/// `run ` asks this capsule to start what package `name` installed. +pub fn run_request() -> Option { + let mut buf = [0u8; MAX_ARGS]; + let n = mk_args(buf.as_mut_ptr(), buf.len()); + let mut parts = buf.get(..usize::try_from(n).ok()?)?.split(|b| *b == 0); + if parts.next()? != b"run" { + return None; + } + let name = parts.next().filter(|s| !s.is_empty())?; + Some(String::from(core::str::from_utf8(name).ok()?)) +} diff --git a/userland/capsule_linux/src/linux/source.rs b/userland/capsule_linux/src/linux/source.rs index 1ee2ffb305..335d15edd5 100644 --- a/userland/capsule_linux/src/linux/source.rs +++ b/userland/capsule_linux/src/linux/source.rs @@ -31,12 +31,19 @@ static BUILT_IN: &[u8] = include_bytes!("../../guests/busybox.elf"); const MAX_IMAGE: u32 = 64 << 20; const MAX_ARGS: usize = 256; -/// The program's path, its bytes, and where they came from. -pub fn source() -> (Vec, Vec, Origin) { - match named() { +/// The program's path, its bytes, and where they came from. A run of an +/// installed package is its recorded program or nothing: falling back to the +/// built-in program would start something the person did not ask for. +pub fn source() -> Option<(Vec, Vec, Origin)> { + if let Some(name) = super::request::run_request() { + let path = super::install::recorded(&name)?; + let bytes = store_read(&key(&path), MAX_IMAGE).ok()?; + return Some((path, bytes, Origin::Store)); + } + Some(match named() { Some((path, bytes)) => (path, bytes, Origin::Store), None => (b"/bin/busybox".to_vec(), BUILT_IN.to_vec(), Origin::BuiltIn), - } + }) } fn named() -> Option<(Vec, Vec)> { diff --git a/userland/capsule_linux/src/linux/start.rs b/userland/capsule_linux/src/linux/start.rs index f4c8deec39..8dac4a2bb9 100644 --- a/userland/capsule_linux/src/linux/start.rs +++ b/userland/capsule_linux/src/linux/start.rs @@ -32,7 +32,10 @@ pub fn run() -> ! { say(if ok { b"[LINUX] installed\n" } else { b"[LINUX] install failed\n" }); mk_exit(if ok { 0 } else { 1 }) } - let (path, bytes, origin) = source(); + let Some((path, bytes, origin)) = source() else { + say(b"[LINUX] nothing installed under that name\n"); + mk_exit(1) + }; let pid = mk_foreign_spawn(b"linux"); if pid < 0 { say(b"[LINUX] no guest, errno "); diff --git a/userland/libc/src/lib.rs b/userland/libc/src/lib.rs index 4ea4be0e45..71056d0d86 100644 --- a/userland/libc/src/lib.rs +++ b/userland/libc/src/lib.rs @@ -87,7 +87,7 @@ pub use foreign::{ pub use foreign_frame::ForeignFrame; pub use graphics::nonos_display_dimensions; pub use local_sign::{ - mk_app_install, mk_local_sign, mk_local_sign_len, mk_local_verify, + mk_app_install, mk_app_launch, mk_local_sign, mk_local_sign_len, mk_local_verify, }; #[cfg(feature = "heap")] pub use heap::{init as heap_init, init_sized as heap_init_sized, HeapError}; diff --git a/userland/libc/src/local_sign.rs b/userland/libc/src/local_sign.rs index 50e199a91d..99c7d7a6d6 100644 --- a/userland/libc/src/local_sign.rs +++ b/userland/libc/src/local_sign.rs @@ -16,7 +16,9 @@ //! A trailer for something this machine is installing. -use crate::syscall::{call_raw, N_MK_APP_INSTALL, N_MK_LOCAL_SIGN, N_MK_LOCAL_VERIFY}; +use crate::syscall::{ + call_raw, N_MK_APP_INSTALL, N_MK_APP_LAUNCH, N_MK_LOCAL_SIGN, N_MK_LOCAL_VERIFY, +}; /// How many bytes a trailer for `elf` takes, or a negative errno. pub fn mk_local_sign_len(elf: &[u8], caps: u64) -> i64 { @@ -51,3 +53,8 @@ pub fn mk_app_install(listing: &[u8], release: &[u8]) -> i64 { let (l, r) = (listing.as_ptr() as u64, release.as_ptr() as u64); call_raw(N_MK_APP_INSTALL, [l, listing.len() as u64, r, release.len() as u64, 0, 0]) } + +/// Start the program the listing's package installed. +pub fn mk_app_launch(listing: &[u8]) -> i64 { + call_raw(N_MK_APP_LAUNCH, [listing.as_ptr() as u64, listing.len() as u64, 0, 0, 0, 0]) +} diff --git a/userland/libc/src/syscall/mod.rs b/userland/libc/src/syscall/mod.rs index 7b0e1504e8..33b2b87d41 100644 --- a/userland/libc/src/syscall/mod.rs +++ b/userland/libc/src/syscall/mod.rs @@ -28,7 +28,7 @@ pub(crate) use numbers::{ N_MK_ATTEST_DOC, N_MK_ATTEST_ENTRIES, N_MK_ATTEST_STATUS, N_MK_BATTERY_STATUS, N_MK_CAPSULE_LOAD, N_MK_CAPSULE_VERIFY, N_MK_CAP_CHECK, N_MK_CAP_GRANT, N_MK_CAP_REVOKE, N_MK_DEBUG, N_MK_DEVICE_CLAIM, N_MK_DEVICE_LIST, N_MK_DEVICE_RELEASE, N_MK_DISPLAY_VSYNC_WAIT, - N_MK_DMA_MAP, N_MK_DMA_UNMAP, N_MK_EXIT, N_MK_APP_INSTALL, N_MK_DEV_ROOT_CONFIRM, N_MK_DEV_ROOT_LOCAL, N_MK_LOCAL_CONSENT, N_MK_LOCAL_RESTORE, N_MK_FOREIGN_EXEC, N_MK_FOREIGN_FORK, N_MK_FOREIGN_REPLY, N_MK_FOREIGN_SPAWN, + N_MK_DMA_MAP, N_MK_DMA_UNMAP, N_MK_EXIT, N_MK_APP_INSTALL, N_MK_APP_LAUNCH, N_MK_DEV_ROOT_CONFIRM, N_MK_DEV_ROOT_LOCAL, N_MK_LOCAL_CONSENT, N_MK_LOCAL_RESTORE, N_MK_FOREIGN_EXEC, N_MK_FOREIGN_FORK, N_MK_FOREIGN_REPLY, N_MK_FOREIGN_SPAWN, N_MK_FOREIGN_START, N_MK_FOREIGN_THREAD, N_MK_FOREIGN_WAIT, N_MK_FUTEX_WAIT, N_MK_GETPID, N_MK_INPUT_EVENT_DRAIN, N_MK_INPUT_EVENT_POST, N_MK_INPUT_EVENT_WAIT, N_MK_INSTALL_SOURCE, N_MK_IPC_CALL, N_MK_LOCAL_SIGN, N_MK_LOCAL_VERIFY, N_MK_IPC_RECV, N_MK_IPC_RECV_FROM, N_MK_IPC_REPLY, N_MK_IPC_SEND, N_MK_IPC_SEND_TO_PID, diff --git a/userland/libc/src/syscall/numbers/foreign.rs b/userland/libc/src/syscall/numbers/foreign.rs index 1887c2c2a9..74d6224d51 100644 --- a/userland/libc/src/syscall/numbers/foreign.rs +++ b/userland/libc/src/syscall/numbers/foreign.rs @@ -37,4 +37,5 @@ pub(crate) const N_MK_APP_INSTALL: i64 = tag4(b"MAIN"); pub(crate) const N_MK_DEV_ROOT_LOCAL: i64 = tag4(b"MDRO"); pub(crate) const N_MK_LOCAL_CONSENT: i64 = tag4(b"MLCG"); pub(crate) const N_MK_LOCAL_RESTORE: i64 = tag4(b"MLCR"); +pub(crate) const N_MK_APP_LAUNCH: i64 = tag4(b"MAPL"); pub(crate) const N_MK_DEV_ROOT_CONFIRM: i64 = tag4(b"MDRC"); From 8bd45ad8e24e83a3ba6f7c268764284c97486731 Mon Sep 17 00:00:00 2001 From: eKisNonos Date: Sat, 26 Sep 2026 15:47:36 +0000 Subject: [PATCH 022/244] nym: reach the validator by a pinned address, never a clearnet lookup Every boot resolved validator.nymtech.net through net.dns, so the first query of a session went out in the clear naming the service in use. The bootstrap set is pinned by address in this capsule's attested image; the name is kept for the certificate check and never resolved. --- .../src/directory_sync/https.rs | 5 +- .../capsule_net_nym/src/directory_sync/mod.rs | 3 +- .../src/directory_sync/pinned.rs | 33 ++++++++++ .../src/directory_sync/resolve.rs | 63 ------------------- 4 files changed, 36 insertions(+), 68 deletions(-) create mode 100644 userland/capsule_net_nym/src/directory_sync/pinned.rs delete mode 100644 userland/capsule_net_nym/src/directory_sync/resolve.rs diff --git a/userland/capsule_net_nym/src/directory_sync/https.rs b/userland/capsule_net_nym/src/directory_sync/https.rs index f309e36153..28cac7022c 100644 --- a/userland/capsule_net_nym/src/directory_sync/https.rs +++ b/userland/capsule_net_nym/src/directory_sync/https.rs @@ -19,7 +19,7 @@ use alloc::vec::Vec; use nonos_tls::{exchange, rtc_now}; use super::http::parse; -use super::resolve::resolve; +use super::pinned::address; use super::tls_io::TcpIo; use crate::tcp_client; @@ -35,8 +35,7 @@ const MAX_RESPONSE: usize = 512 * 1024; /// this fetch is anonymous: it happens before there is a mixnet to be /// anonymous over. pub fn fetch_tls(tcp_port: u32, host: &str, path: &str) -> Result, u16> { - crate::trace::say(b"fetch: resolving"); - let ip = resolve(host.as_bytes()).ok_or(21u16)?; + let ip = address(host).ok_or(21u16)?; crate::trace::say(b"fetch: connecting"); let stream = tcp_client::connect(tcp_port, ip, HTTPS_PORT)?; tcp_client::wait_established(tcp_port, stream)?; diff --git a/userland/capsule_net_nym/src/directory_sync/mod.rs b/userland/capsule_net_nym/src/directory_sync/mod.rs index 92e0017175..f8d9a3e6b2 100644 --- a/userland/capsule_net_nym/src/directory_sync/mod.rs +++ b/userland/capsule_net_nym/src/directory_sync/mod.rs @@ -22,8 +22,8 @@ mod http; mod https; mod keep; mod live; +mod pinned; mod plain; -mod resolve; mod source; mod stages; mod step; @@ -33,7 +33,6 @@ pub use api::{objects, parse_node}; pub use exit::{fetch_exit, ExitAddress}; pub use http::fetch; pub use https::fetch_tls; -pub use resolve::resolve; pub use source::{parse, DirectorySource}; pub use step::{sync_step, Step}; pub use tls_io::TcpIo; diff --git a/userland/capsule_net_nym/src/directory_sync/pinned.rs b/userland/capsule_net_nym/src/directory_sync/pinned.rs new file mode 100644 index 0000000000..1a7c820be2 --- /dev/null +++ b/userland/capsule_net_nym/src/directory_sync/pinned.rs @@ -0,0 +1,33 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + + +//! Where the validators are, before there is a mixnet to ask through. +//! +//! Resolving the name sent the first query of every session out in the clear, +//! to whatever resolver the network handed out, naming the service this +//! machine was about to use. The bootstrap set is pinned by address instead, +//! in this capsule's image, which the policy root enrols. The name stays for +//! the certificate check and the Host line; it is never resolved. + +/// Host and IPv4 address, as resolved when this list was written. +const PINNED: &[(&str, [u8; 4])] = &[("validator.nymtech.net", [92, 39, 63, 14])]; + +/// The pinned address for `host`, or `None`: a host not in the set is not +/// reached at all, because the only alternative is a clearnet lookup. +pub fn address(host: &str) -> Option<[u8; 4]> { + PINNED.iter().find(|(name, _)| *name == host).map(|(_, ip)| *ip) +} diff --git a/userland/capsule_net_nym/src/directory_sync/resolve.rs b/userland/capsule_net_nym/src/directory_sync/resolve.rs deleted file mode 100644 index e007377f95..0000000000 --- a/userland/capsule_net_nym/src/directory_sync/resolve.rs +++ /dev/null @@ -1,63 +0,0 @@ -// NONOS Operating System -// Copyright (C) 2026 NONOS Contributors -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -use alloc::vec; -use nonos_libc::{mk_ipc_call_timeout, mk_service_lookup}; - -const SERVICE: &[u8] = b"net.dns"; -const MAGIC_NDNS: u32 = 0x4E44_4E53; -const OP_RESOLVE_A: u16 = 2; -const HDR: usize = 20; -/// A lookup runs on the idle directory tick, so it can afford to wait out a -/// full recursive resolve. This must stay above the resolver's own per-query -/// deadline (3s in net.core); at 2s the client gave up first and a cold lookup -/// of the directory host aborted every sync with a false "unresolvable", so the -/// gateway list never installed. -const TIMEOUT_MS: u64 = 6_000; - -/// Resolve `host` to one IPv4 address through `net.dns`. -/// -/// The directory is named rather than pinned to an address, because an -/// address compiled into an image outlives whatever it pointed at and leaves -/// no way to notice. -pub fn resolve(host: &[u8]) -> Option<[u8; 4]> { - let mut port = 0u32; - let mut pid = 0u32; - if mk_service_lookup(SERVICE.as_ptr(), SERVICE.len(), &mut port, &mut pid) < 0 || port == 0 { - return None; - } - let mut tx = vec![0u8; HDR + host.len()]; - tx[0..4].copy_from_slice(&MAGIC_NDNS.to_le_bytes()); - tx[4..6].copy_from_slice(&1u16.to_le_bytes()); - tx[6..8].copy_from_slice(&OP_RESOLVE_A.to_le_bytes()); - tx[12..16].copy_from_slice(&1u32.to_le_bytes()); - tx[16..20].copy_from_slice(&(host.len() as u32).to_le_bytes()); - tx[HDR..].copy_from_slice(host); - - let mut rx = [0u8; HDR + 4]; - let n = mk_ipc_call_timeout( - port as u64, - tx.as_ptr(), - tx.len(), - rx.as_mut_ptr(), - rx.len(), - TIMEOUT_MS, - ); - if n < (HDR + 4) as i64 || u16::from_le_bytes([rx[8], rx[9]]) != 0 { - return None; - } - Some([rx[HDR], rx[HDR + 1], rx[HDR + 2], rx[HDR + 3]]) -} From dc2f94eceb556a41cd98f9315f2e1a63cfa42453 Mon Sep 17 00:00:00 2001 From: eKisNonos Date: Sat, 26 Sep 2026 15:48:37 +0000 Subject: [PATCH 023/244] linux: fetch packages from a mirror by address, never by name The installer connected to dl-cdn.alpinelinux.org, which the socket service resolved in the clear. It now takes a mirror by address, Alpine's CDN by default or NONOS_ALPINE_MIRROR at build, sends Alpine's name as the Host line, and refuses a host that is not a literal. The signatures still decide. --- .../src/linux/install/download.rs | 6 ++-- .../capsule_linux/src/linux/install/http.rs | 12 +++++-- .../src/linux/install/index_load.rs | 6 ++-- .../capsule_linux/src/linux/install/mirror.rs | 36 +++++++++++++++++++ .../capsule_linux/src/linux/install/mod.rs | 1 + .../capsule_linux/src/linux/install/run.rs | 2 -- 6 files changed, 54 insertions(+), 9 deletions(-) create mode 100644 userland/capsule_linux/src/linux/install/mirror.rs diff --git a/userland/capsule_linux/src/linux/install/download.rs b/userland/capsule_linux/src/linux/install/download.rs index 96eaf56547..8ee6562d46 100644 --- a/userland/capsule_linux/src/linux/install/download.rs +++ b/userland/capsule_linux/src/linux/install/download.rs @@ -20,13 +20,15 @@ use alloc::format; use alloc::vec::Vec; use super::http::get; -use super::run::{ARCH, BRANCHES, HOST, PORT, RELEASE}; +use super::mirror::mirror; +use super::run::{ARCH, BRANCHES, RELEASE}; /// Either branch may hold it, and the index does not say which. pub(super) fn download(name: &str, version: &str) -> Vec { for branch in BRANCHES { let path = format!("/alpine/{RELEASE}/{branch}/{ARCH}/{name}-{version}.apk"); - if let Some(bytes) = get(HOST, PORT, &path) { + let (ip, port) = mirror(); + if let Some(bytes) = get(ip, port, &path) { return bytes; } } diff --git a/userland/capsule_linux/src/linux/install/http.rs b/userland/capsule_linux/src/linux/install/http.rs index 795fccfe5d..ce78a3bb29 100644 --- a/userland/capsule_linux/src/linux/install/http.rs +++ b/userland/capsule_linux/src/linux/install/http.rs @@ -19,6 +19,7 @@ use alloc::vec::Vec; use alloc::{format, string::String}; +use super::mirror::HOST_LINE; use crate::linux::net::raw::{connect_host, open_stream}; use crate::linux::net::raw_io::{close, recv_all, send_all}; @@ -26,14 +27,19 @@ use crate::linux::net::raw_io::{close, recv_all, send_all}; /// rather than truncated into a half-parsed index. const MAX_BODY: usize = 64 << 20; -pub fn get(host: &str, port: u16, path: &str) -> Option> { +/// A GET to `ip`, which must be a dotted IPv4 address: a name here would be +/// resolved by the socket service, in the clear. +pub fn get(ip: &str, port: u16, path: &str) -> Option> { + if ip.split('.').filter(|o| o.parse::().is_ok()).count() != 4 { + return None; + } let handle = open_stream()?; - if connect_host(handle, host, port).is_none() { + if connect_host(handle, ip, port).is_none() { close(handle); return None; } let req = format!( - "GET {path} HTTP/1.1\r\nHost: {host}\r\nUser-Agent: nonos\r\nConnection: close\r\n\r\n" + "GET {path} HTTP/1.1\r\nHost: {HOST_LINE}\r\nUser-Agent: nonos\r\nConnection: close\r\n\r\n" ); if send_all(handle, req.as_bytes()).is_none() { close(handle); diff --git a/userland/capsule_linux/src/linux/install/index_load.rs b/userland/capsule_linux/src/linux/install/index_load.rs index 4e3f8eb154..2bb03474c4 100644 --- a/userland/capsule_linux/src/linux/install/index_load.rs +++ b/userland/capsule_linux/src/linux/install/index_load.rs @@ -22,14 +22,16 @@ use alloc::vec::Vec; use super::auth::{rsa_verify, signed_index}; use super::http::get; use super::index::Index; -use super::run::{ARCH, BRANCHES, HOST, PORT, RELEASE}; +use super::mirror::mirror; +use super::run::{ARCH, BRANCHES, RELEASE}; use super::tar::entries; pub(super) fn load_index() -> Option { let mut all: Vec = Vec::new(); for branch in BRANCHES { let path = format!("/alpine/{RELEASE}/{branch}/{ARCH}/APKINDEX.tar.gz"); - let raw = get(HOST, PORT, &path)?; + let (ip, port) = mirror(); + let raw = get(ip, port, &path)?; /* * A branch whose signature does not verify refuses the whole index: * resolving against half of it would pick a dependency from whichever diff --git a/userland/capsule_linux/src/linux/install/mirror.rs b/userland/capsule_linux/src/linux/install/mirror.rs new file mode 100644 index 0000000000..96dcef1a79 --- /dev/null +++ b/userland/capsule_linux/src/linux/install/mirror.rs @@ -0,0 +1,36 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + + +//! Where packages come from, by address. +//! +//! The installer never resolves a name, so an install sends nothing to a +//! resolver. The default is the address Alpine's CDN answered from when this +//! was written, reached with Alpine's name as the Host line. A build sets +//! NONOS_ALPINE_MIRROR to a.b.c.d:port to use another mirror; Alpine's own +//! signatures authenticate the bytes whichever mirror serves them. + +const DEFAULT: &str = "151.101.66.132:80"; + +/// The mirror's address, as the socket service takes it, and its port. +pub(super) fn mirror() -> (&'static str, u16) { + let at = option_env!("NONOS_ALPINE_MIRROR").unwrap_or(DEFAULT); + let (ip, port) = at.rsplit_once(':').unwrap_or((at, "80")); + (ip, port.parse().unwrap_or(80)) +} + +/// The name a CDN serves Alpine's tree under. +pub(super) const HOST_LINE: &str = "dl-cdn.alpinelinux.org"; diff --git a/userland/capsule_linux/src/linux/install/mod.rs b/userland/capsule_linux/src/linux/install/mod.rs index d9e541b066..a7b3664ab2 100644 --- a/userland/capsule_linux/src/linux/install/mod.rs +++ b/userland/capsule_linux/src/linux/install/mod.rs @@ -23,6 +23,7 @@ mod fetch; mod http; mod index; mod index_load; +mod mirror; mod pkg; mod place; mod place_entry; diff --git a/userland/capsule_linux/src/linux/install/run.rs b/userland/capsule_linux/src/linux/install/run.rs index 75883277b1..81959c3f8b 100644 --- a/userland/capsule_linux/src/linux/install/run.rs +++ b/userland/capsule_linux/src/linux/install/run.rs @@ -26,8 +26,6 @@ use super::fetch::fetch; use super::index_load::load_index; use super::place::unpack; -pub(super) const HOST: &str = "dl-cdn.alpinelinux.org"; -pub(super) const PORT: u16 = 80; pub(super) const RELEASE: &str = "v3.20"; pub(super) const ARCH: &str = "x86_64"; pub(super) const BRANCHES: [&str; 2] = ["main", "community"]; From b9046616342e89d843458918c05d9c9697abd977 Mon Sep 17 00:00:00 2001 From: eKisNonos Date: Sat, 26 Sep 2026 15:48:37 +0000 Subject: [PATCH 024/244] market: catalogue the Alpine release the installer fetches from The generator hashed packages from v3.21 while the installer downloads from v3.20, so every Linux listing pinned bytes the installer never sees and the kernel's package-hash check would refuse every install. --- tools/nonos-market-catalogue | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tools/nonos-market-catalogue b/tools/nonos-market-catalogue index 1670cc6f1c..fac27ca012 100755 --- a/tools/nonos-market-catalogue +++ b/tools/nonos-market-catalogue @@ -295,7 +295,9 @@ def main() -> int: ap.add_argument("--cache", type=Path, default=Path("target/market-cache")) ap.add_argument("--operator-pubkey", required=True, help="hex Ed25519 key the index will be signed under") - ap.add_argument("--alpine-release", default="v3.21") + # The release the Linux installer fetches from (run.rs RELEASE). A listing + # hashed from another release names bytes the installer never downloads. + ap.add_argument("--alpine-release", default="v3.20") ap.add_argument("--alpine-arch", default="x86_64") ap.add_argument("--linux-package", action="append", default=[], help="repeatable; a package to fetch, hash and list") From 338a46e0e5c3c626a7c236bbef7d5fca517a40c5 Mon Sep 17 00:00:00 2001 From: eKisNonos Date: Sat, 26 Sep 2026 15:51:46 +0000 Subject: [PATCH 025/244] foreign: log the capabilities a guest holds, read back after install The spawn installs an empty token, and nothing on the console said so. The line reads the bits back from the process table rather than printing the value it meant to install, so it is evidence and not a restatement. --- src/process/foreign/spawn.rs | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/process/foreign/spawn.rs b/src/process/foreign/spawn.rs index aa9295006f..847f35d7ec 100644 --- a/src/process/foreign/spawn.rs +++ b/src/process/foreign/spawn.rs @@ -57,14 +57,16 @@ pub(super) fn empty_guest(supervisor: u32, name: &[u8]) -> Result { if allocate_kernel_stack(pid).is_err() { return Err(ERRNO_NOMEM); } - /* - * Every process is born with its parent's capabilities bounded by the - * ambient set, which for a guest of this capsule means core exec, IPC and - * memory. - */ + // Born with the ambient set, core exec, IPC and memory; a guest holds none. if crate::process::caps::install_spawn(pid, 0).is_none() { return Err(ERRNO_PERM); } + // Read back rather than assumed, so the log states what the guest holds. + crate::sys::serial::print(b"[FOREIGN] guest pid="); + crate::sys::serial::print_hex(pid as u64); + crate::sys::serial::print(b" caps="); + crate::sys::serial::print_hex(crate::process::caps::bits(pid).unwrap_or(u64::MAX)); + crate::sys::serial::println(b""); if !super::registry::insert(pid, supervisor) { return Err(ERRNO_EXIST); } From 568ba413b228a0dddd88e45012ce864409dd4179 Mon Sep 17 00:00:00 2001 From: eKisNonos Date: Sat, 26 Sep 2026 15:53:34 +0000 Subject: [PATCH 026/244] desktop_shell: size the launcher table for the store entry #545 added Main's table held thirteen apps and #545 added the marketplace as the fourteenth, so the shell no longer compiled once both were merged. --- userland/capsule_desktop_shell/src/state/apps.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/userland/capsule_desktop_shell/src/state/apps.rs b/userland/capsule_desktop_shell/src/state/apps.rs index 76e593be95..63deaa674b 100644 --- a/userland/capsule_desktop_shell/src/state/apps.rs +++ b/userland/capsule_desktop_shell/src/state/apps.rs @@ -40,7 +40,7 @@ pub struct LauncherApp { pub service: &'static [u8], } -pub const LAUNCHER_APPS: [LauncherApp; 13] = [ +pub const LAUNCHER_APPS: [LauncherApp; 14] = [ LauncherApp { icon: LauncherIcon::Terminal, label: b"Terminal", service: b"app.terminal" }, LauncherApp { icon: LauncherIcon::FileManager, label: b"Files", service: b"app.file_manager" }, LauncherApp { icon: LauncherIcon::TextEditor, label: b"Editor", service: b"app.text_editor" }, From 51ffa1c00f95044a3c9bcf464976b59f3c625e1e Mon Sep 17 00:00:00 2001 From: eKisNonos Date: Sat, 26 Sep 2026 15:55:45 +0000 Subject: [PATCH 027/244] launch: refuse a package name that starts with a dot, as install does MkAppInstall refused one and MkAppLaunch did not, so linux.. named the record directory itself. Only a failed read followed, but the two calls now agree on what a package name is. --- src/syscall/microkernel/app_launch.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/syscall/microkernel/app_launch.rs b/src/syscall/microkernel/app_launch.rs index 395ab1dbca..f663a067ba 100644 --- a/src/syscall/microkernel/app_launch.rs +++ b/src/syscall/microkernel/app_launch.rs @@ -29,7 +29,9 @@ pub fn sys_app_launch(listing_ptr: u64, listing_len: u64) -> i64 { Ok(None) => return ERRNO_INVAL, Err(e) => return e, }; - let Some(name) = listing.strip_prefix("linux.").filter(|n| !n.is_empty()) else { + let Some(name) = + listing.strip_prefix("linux.").filter(|n| !n.is_empty() && !n.starts_with('.')) + else { return ERRNO_INVAL; }; match crate::userspace::init::request_run(alloc::string::String::from(name)) { From b9fc0e0b7412957fa89343c160ea22e8b0abe232 Mon Sep 17 00:00:00 2001 From: eKisNonos Date: Sat, 26 Sep 2026 15:57:43 +0000 Subject: [PATCH 028/244] lockfiles: record what the desktop shell and net_nym already depend on Both locks predate dependencies main added (policy_client, audio_proto, nonos_hash in place of nonos_hd), so every build rewrote them. --- userland/capsule_desktop_shell/Cargo.lock | 19 +++++++++++++++++++ userland/capsule_net_nym/Cargo.lock | 6 +++--- 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/userland/capsule_desktop_shell/Cargo.lock b/userland/capsule_desktop_shell/Cargo.lock index c867c05bab..6cbbb9b99d 100644 --- a/userland/capsule_desktop_shell/Cargo.lock +++ b/userland/capsule_desktop_shell/Cargo.lock @@ -55,14 +55,33 @@ dependencies = [ "scopeguard", ] +[[package]] +name = "nonos_audio_proto" +version = "0.1.0" + [[package]] name = "nonos_desktop_shell" version = "0.3.0" dependencies = [ + "nonos_audio_proto", + "nonos_policy_client", + "nonos_policy_proto", "nonos_toolkit", "nonos_userland_libc", ] +[[package]] +name = "nonos_policy_client" +version = "0.1.0" +dependencies = [ + "nonos_policy_proto", + "nonos_userland_libc", +] + +[[package]] +name = "nonos_policy_proto" +version = "0.3.0" + [[package]] name = "nonos_toolkit" version = "0.3.0" diff --git a/userland/capsule_net_nym/Cargo.lock b/userland/capsule_net_nym/Cargo.lock index d92701838b..e1a961f494 100644 --- a/userland/capsule_net_nym/Cargo.lock +++ b/userland/capsule_net_nym/Cargo.lock @@ -34,13 +34,13 @@ dependencies = [ name = "nonos_ed25519" version = "0.1.0" dependencies = [ - "nonos_hd", + "nonos_hash", "spin", ] [[package]] -name = "nonos_hd" -version = "0.3.0" +name = "nonos_hash" +version = "0.1.0" [[package]] name = "nonos_tls" From 9e1fbc86d3981eb3c92700108363b5cf2019284e Mon Sep 17 00:00:00 2001 From: eKisNonos Date: Sat, 26 Sep 2026 16:07:16 +0000 Subject: [PATCH 029/244] init: spawn the desktop after setup without a second compositor The setup-wizard profile called desktop_fleet::spawn_gui_core, a private module rather than the function, so the profile did not compile. After setup exited it also spawned the whole fleet, compositor and input router included, over the two setup had just been running on. --- src/userspace/init/spawn_plan/desktop_fleet/mod.rs | 4 +++- src/userspace/init/spawn_plan/desktop_fleet/spawn.rs | 9 +++++++++ src/userspace/init/spawn_plan/orchestrator.rs | 2 +- 3 files changed, 13 insertions(+), 2 deletions(-) diff --git a/src/userspace/init/spawn_plan/desktop_fleet/mod.rs b/src/userspace/init/spawn_plan/desktop_fleet/mod.rs index 8c33337819..304aea0ccf 100644 --- a/src/userspace/init/spawn_plan/desktop_fleet/mod.rs +++ b/src/userspace/init/spawn_plan/desktop_fleet/mod.rs @@ -28,5 +28,7 @@ mod spawn_wallpaper; mod spawn_wallpaper_catalog; mod spawn_wm; -pub(super) use spawn::spawn; +pub(super) use spawn::{spawn, spawn_rest}; +#[cfg(feature = "microkernel-setup-wizard")] +pub(super) use spawn_gui_core::spawn_gui_core; pub(super) use spawn_early_display::spawn_early_display; diff --git a/src/userspace/init/spawn_plan/desktop_fleet/spawn.rs b/src/userspace/init/spawn_plan/desktop_fleet/spawn.rs index bf848d84e2..9dec16f4e3 100644 --- a/src/userspace/init/spawn_plan/desktop_fleet/spawn.rs +++ b/src/userspace/init/spawn_plan/desktop_fleet/spawn.rs @@ -27,6 +27,15 @@ pub(crate) fn spawn() { return; } spawn_gui_core(); + spawn_rest(); +} + +/// Everything after the compositor and input router. Setup runs on those two, +/// so the desktop that follows it must not spawn them a second time. +pub(crate) fn spawn_rest() { + if !desktop_enabled() { + return; + } spawn_boot_splash(); spawn_wm(); spawn_wallpaper_catalog(); diff --git a/src/userspace/init/spawn_plan/orchestrator.rs b/src/userspace/init/spawn_plan/orchestrator.rs index f22d12b8fa..bb8487ef5a 100644 --- a/src/userspace/init/spawn_plan/orchestrator.rs +++ b/src/userspace/init/spawn_plan/orchestrator.rs @@ -67,7 +67,7 @@ pub(in crate::userspace::init) fn spawn_desktop() { #[cfg(feature = "microkernel-setup-wizard")] pub(in crate::userspace::init) fn spawn_post_wizard() { - super::desktop_fleet::spawn(); + super::desktop_fleet::spawn_rest(); super::core::spawn_market(); } From 4382fe50a965c1cab8dbfc3fe4adc10964d7519e Mon Sep 17 00:00:00 2001 From: eKisNonos Date: Sat, 26 Sep 2026 16:07:53 +0000 Subject: [PATCH 030/244] lockfiles: record what audio, audio_player and the wallet depend on Stale the same way: each build of the desktop image rewrote them to the dependencies main already declares. --- userland/capsule_audio/Cargo.lock | 5 +++++ userland/capsule_audio_player/Cargo.lock | 13 ++----------- userland/capsule_wallet_nonos/Cargo.lock | 7 +++++++ 3 files changed, 14 insertions(+), 11 deletions(-) diff --git a/userland/capsule_audio/Cargo.lock b/userland/capsule_audio/Cargo.lock index a4aad229c8..e218aba2b1 100644 --- a/userland/capsule_audio/Cargo.lock +++ b/userland/capsule_audio/Cargo.lock @@ -20,10 +20,15 @@ dependencies = [ "scopeguard", ] +[[package]] +name = "nonos_audio_proto" +version = "0.1.0" + [[package]] name = "nonos_capsule_audio" version = "0.3.0" dependencies = [ + "nonos_audio_proto", "nonos_userland_libc", ] diff --git a/userland/capsule_audio_player/Cargo.lock b/userland/capsule_audio_player/Cargo.lock index 439e3c69d5..7830cdd797 100644 --- a/userland/capsule_audio_player/Cargo.lock +++ b/userland/capsule_audio_player/Cargo.lock @@ -75,8 +75,6 @@ dependencies = [ name = "nonos_app_skeleton" version = "0.3.0" dependencies = [ - "nonos_policy_client", - "nonos_policy_proto", "nonos_toolkit", "nonos_userland_libc", ] @@ -87,20 +85,13 @@ version = "0.3.0" dependencies = [ "cc", "nonos_app_skeleton", + "nonos_audio_proto", "nonos_userland_libc", ] [[package]] -name = "nonos_policy_client" +name = "nonos_audio_proto" version = "0.1.0" -dependencies = [ - "nonos_policy_proto", - "nonos_userland_libc", -] - -[[package]] -name = "nonos_policy_proto" -version = "0.3.0" [[package]] name = "nonos_toolkit" diff --git a/userland/capsule_wallet_nonos/Cargo.lock b/userland/capsule_wallet_nonos/Cargo.lock index 7132348c57..9bbba896b4 100644 --- a/userland/capsule_wallet_nonos/Cargo.lock +++ b/userland/capsule_wallet_nonos/Cargo.lock @@ -63,9 +63,16 @@ dependencies = [ "nonos_userland_libc", ] +[[package]] +name = "nonos_hash" +version = "0.1.0" + [[package]] name = "nonos_hd" version = "0.3.0" +dependencies = [ + "nonos_hash", +] [[package]] name = "nonos_qr" From 3a99ae7be0982fae055623157f5bb8bd80eec55f Mon Sep 17 00:00:00 2001 From: eKisNonos Date: Sat, 26 Sep 2026 16:19:32 +0000 Subject: [PATCH 031/244] init: with first-boot setup, start the apps after the desktop it hands to Apps spawned beside setup, before any shell or window manager existed, and every one exited at once, so the desktop after setup had none. They now start from spawn_post_wizard, after the shell and the market. --- src/userspace/init/spawn_plan/app_orchestrator.rs | 7 +++++++ src/userspace/init/spawn_plan/orchestrator.rs | 1 + 2 files changed, 8 insertions(+) diff --git a/src/userspace/init/spawn_plan/app_orchestrator.rs b/src/userspace/init/spawn_plan/app_orchestrator.rs index 7b90141589..1a2385f010 100644 --- a/src/userspace/init/spawn_plan/app_orchestrator.rs +++ b/src/userspace/init/spawn_plan/app_orchestrator.rs @@ -14,6 +14,13 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . +#[cfg(not(feature = "microkernel-setup-wizard"))] pub(in crate::userspace::init) fn spawn_apps() { super::apps::spawn(); } + +/// With first-boot setup the apps wait for the desktop that follows it. +/// Spawned beside setup they found no shell and exited, and they took the +/// keyboard focus setup needed on the way. +#[cfg(feature = "microkernel-setup-wizard")] +pub(in crate::userspace::init) fn spawn_apps() {} diff --git a/src/userspace/init/spawn_plan/orchestrator.rs b/src/userspace/init/spawn_plan/orchestrator.rs index bb8487ef5a..def05ffa85 100644 --- a/src/userspace/init/spawn_plan/orchestrator.rs +++ b/src/userspace/init/spawn_plan/orchestrator.rs @@ -69,6 +69,7 @@ pub(in crate::userspace::init) fn spawn_desktop() { pub(in crate::userspace::init) fn spawn_post_wizard() { super::desktop_fleet::spawn_rest(); super::core::spawn_market(); + super::apps::spawn(); } #[cfg(all(not(feature = "microkernel-input-probe"), not(feature = "microkernel-setup-wizard")))] From ab5dc84f848629599c4fc61a14aa80346f198409 Mon Sep 17 00:00:00 2001 From: eKisNonos Date: Sat, 26 Sep 2026 16:19:32 +0000 Subject: [PATCH 032/244] setup: list the installed-software step, and say whether it has the keys The sidebar named ten steps for eleven screens. Setup also ignored the input router's answers, so a setup that never got the keyboard looked like one waiting for a person; it now says which on the console. --- userland/capsule_setup_wizard/src/render/theme.rs | 1 + userland/capsule_setup_wizard/src/server/runner.rs | 12 ++++++++++-- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/userland/capsule_setup_wizard/src/render/theme.rs b/userland/capsule_setup_wizard/src/render/theme.rs index 2a2a6653e2..e7fe70af4c 100644 --- a/userland/capsule_setup_wizard/src/render/theme.rs +++ b/userland/capsule_setup_wizard/src/render/theme.rs @@ -22,5 +22,6 @@ pub const STEP_LABELS: &[&[u8]] = &[ b"Admin", b"Privacy", b"Appearance", + b"Installed software", b"Review", ]; diff --git a/userland/capsule_setup_wizard/src/server/runner.rs b/userland/capsule_setup_wizard/src/server/runner.rs index 878ec02893..ed7c758ddf 100644 --- a/userland/capsule_setup_wizard/src/server/runner.rs +++ b/userland/capsule_setup_wizard/src/server/runner.rs @@ -10,8 +10,16 @@ use crate::state::Context; use super::step::{self, DONE}; pub fn run(mut ctx: Context) -> ! { - let _ = input_router::subscribe(ctx.router_port, 1); - let _ = input_router::grab_keyboard(ctx.router_port, 2); + // Said on the console: a setup that never gets the keyboard looks like one + // that is waiting for a person. + let heard = input_router::subscribe(ctx.router_port, 1).is_ok(); + let held = input_router::grab_keyboard(ctx.router_port, 2).is_ok(); + let line: &[u8] = match (heard, held) { + (true, true) => b"[SETUP] keyboard held\n", + (true, false) => b"[SETUP] subscribed, but the keyboard grab was refused\n", + _ => b"[SETUP] the input router refused the subscription\n", + }; + let _ = nonos_libc::mk_debug(line.as_ptr(), line.len()); redraw(&ctx); let mut rx = vec![0u8; DELIVERY_LEN.max(64)]; loop { From 5d18ae37f716e9f77f62fe8b7e05eb15c0188d1e Mon Sep 17 00:00:00 2001 From: eKisNonos Date: Sat, 26 Sep 2026 16:50:47 +0000 Subject: [PATCH 033/244] setup: ask for the keyboard until it is held The boot splash keeps the keyboard grab for up to thirty seconds, and setup asked once, early, and took the refusal as final. Keys then went to focus, and before the desktop exists nothing has focus, so setup showed its first screen and never moved. It now asks again every 100 ms until it holds the keyboard. The console lines setup writes need Debug, which it now takes through serial_debug_cap like the other capsules, so a hardened build drops it. --- src/userspace/capsule_setup_wizard/spawn.rs | 3 +- userland/capsule_setup_wizard/Capsule.mk | 2 +- .../capsule_setup_wizard/src/server/runner.rs | 40 ++++++++++++++----- 3 files changed, 32 insertions(+), 13 deletions(-) diff --git a/src/userspace/capsule_setup_wizard/spawn.rs b/src/userspace/capsule_setup_wizard/spawn.rs index 2bc19d1d39..cc870a31de 100644 --- a/src/userspace/capsule_setup_wizard/spawn.rs +++ b/src/userspace/capsule_setup_wizard/spawn.rs @@ -52,7 +52,8 @@ pub fn spawn_setup_wizard_capsule() -> Result<(), SpawnError> { | Capability::Memory.bit() | Capability::GraphicsDisplayQuery.bit() | Capability::GraphicsSurfaceCreate.bit() - | Capability::EnrolDevRoot.bit(), + | Capability::EnrolDevRoot.bit() + | crate::capabilities::serial_debug_cap(), debug_tag: b"", }; let pid = capsule_spawn::spawn_verified(&spec, &trust_anchor, None)?; diff --git a/userland/capsule_setup_wizard/Capsule.mk b/userland/capsule_setup_wizard/Capsule.mk index d5a5640a0b..2d184795b0 100644 --- a/userland/capsule_setup_wizard/Capsule.mk +++ b/userland/capsule_setup_wizard/Capsule.mk @@ -14,7 +14,7 @@ CAPSULE_FEATURE := nonos-capsule-setup-wizard CAPSULE_NAMESPACE := systems.nonos.app.setup_wizard CAPSULE_SERVICE_ENDPOINT := service:4794:app.setup_wizard CAPSULE_REPLY_ENDPOINT := reply:4795:endpoint.app.setup_wizard.reply -CAPSULE_REQUIRED_CAPS := 0x8001819 +CAPSULE_REQUIRED_CAPS := 0x8001919 CAPSULE_KERNEL_MIRROR := src/userspace/capsule_setup_wizard include nonos-mk/capsule.mk diff --git a/userland/capsule_setup_wizard/src/server/runner.rs b/userland/capsule_setup_wizard/src/server/runner.rs index ed7c758ddf..686f7d43c0 100644 --- a/userland/capsule_setup_wizard/src/server/runner.rs +++ b/userland/capsule_setup_wizard/src/server/runner.rs @@ -9,22 +9,34 @@ use crate::state::Context; use super::step::{self, DONE}; +/// How long to wait for input before asking for the keyboard again. +const GRAB_RETRY_MS: u64 = 100; + pub fn run(mut ctx: Context) -> ! { - // Said on the console: a setup that never gets the keyboard looks like one - // that is waiting for a person. - let heard = input_router::subscribe(ctx.router_port, 1).is_ok(); - let held = input_router::grab_keyboard(ctx.router_port, 2).is_ok(); - let line: &[u8] = match (heard, held) { - (true, true) => b"[SETUP] keyboard held\n", - (true, false) => b"[SETUP] subscribed, but the keyboard grab was refused\n", - _ => b"[SETUP] the input router refused the subscription\n", - }; - let _ = nonos_libc::mk_debug(line.as_ptr(), line.len()); + if input_router::subscribe(ctx.router_port, 1).is_err() { + say(b"[SETUP] the input router refused the subscription\n"); + } + /* + * The boot splash holds the keyboard until it hands off, and it may not + * have when setup first asks. Keys sent while nobody holds it go to focus, + * and before the desktop exists there is no focus to take them, so setup + * asks again until it holds the keyboard. + */ + let mut held = false; + let mut rid = 2u32; redraw(&ctx); let mut rx = vec![0u8; DELIVERY_LEN.max(64)]; loop { + if !held { + held = input_router::grab_keyboard(ctx.router_port, rid).is_ok(); + rid = rid.wrapping_add(1).max(2); + if held { + say(b"[SETUP] keyboard held\n"); + } + } + let wait = if held { 0 } else { GRAB_RETRY_MS }; let mut sender = 0u32; - let n = mk_ipc_recv_from(0, rx.as_mut_ptr(), rx.len(), 0, &mut sender); + let n = mk_ipc_recv_from(0, rx.as_mut_ptr(), rx.len(), wait, &mut sender); if n <= 0 { continue; } @@ -48,3 +60,9 @@ fn redraw(ctx: &Context) { screens::draw(ctx); let _ = compositor::damage_commit(ctx.compositor_port, 9, ctx.width, ctx.height); } + +/// Said on the console: a setup that never gets the keyboard looks like one +/// waiting for a person. +fn say(line: &[u8]) { + let _ = nonos_libc::mk_debug(line.as_ptr(), line.len()); +} From ab7d4a80e2304f0e40fea51b488360b3e1e375ae Mon Sep 17 00:00:00 2001 From: eKisNonos Date: Sat, 26 Sep 2026 17:06:36 +0000 Subject: [PATCH 034/244] setup: name the installed-software choice on the review screen Review is where the choice is committed, and it listed everything but this one. The two options were also wider than their boxes; they are shorter now. --- .../src/render/screens/local_software.rs | 4 ++-- .../capsule_setup_wizard/src/render/screens/review.rs | 8 +++++++- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/userland/capsule_setup_wizard/src/render/screens/local_software.rs b/userland/capsule_setup_wizard/src/render/screens/local_software.rs index 7a4d7d15e0..9f14ddd1e5 100644 --- a/userland/capsule_setup_wizard/src/render/screens/local_software.rs +++ b/userland/capsule_setup_wizard/src/render/screens/local_software.rs @@ -20,8 +20,8 @@ use crate::render::{self, widgets::rows}; use crate::server::step::{default_key, list_nav, Outcome}; use crate::state::Context; -const MODES: &[&[u8]] = - &[b"Only software that ships with NONOS", b"Also programs this machine installs and proves"]; +/// Short enough for the list box: the longer wording ran past its edge. +const MODES: &[&[u8]] = &[b"Only NONOS software", b"Also software installed here"]; pub fn draw(ctx: &Context) { render::frame( diff --git a/userland/capsule_setup_wizard/src/render/screens/review.rs b/userland/capsule_setup_wizard/src/render/screens/review.rs index 9ed77ea6f7..cab31d0cbb 100644 --- a/userland/capsule_setup_wizard/src/render/screens/review.rs +++ b/userland/capsule_setup_wizard/src/render/screens/review.rs @@ -10,10 +10,16 @@ pub fn draw(ctx: &Context) { let spx = ctx.stride as usize / 4; let (w, h) = (ctx.width, ctx.height); let buf = render::buffer(ctx); - let lines: [(&[u8], bool); 3] = [ + // Named here too, since this commit is what grants or revokes it. + let local: &[u8] = match ctx.local_sel { + 1 => b"Installed software may run", + _ => b"Only NONOS software runs", + }; + let lines: [(&[u8], bool); 4] = [ (b"Identity keys", ctx.keys_done), (b"Passphrase set", ctx.pass_len > 0), (b"Layout/wallpaper chosen", true), + (local, true), ]; render::widgets::progress::busy(buf, spx, w, h, render::content_x(w), 120, &lines, 0); } From 292d804d313696695e0299a19ed44d3c22d322a7 Mon Sep 17 00:00:00 2001 From: eKisNonos Date: Sat, 26 Sep 2026 17:06:36 +0000 Subject: [PATCH 035/244] store: drop AttestRead from the manifest Required manifest caps are installed whatever the spawn site asks for, and AttestRead only opens MkAttestEntries, which the store never calls. --- userland/capsule_app_store/Capsule.mk | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/userland/capsule_app_store/Capsule.mk b/userland/capsule_app_store/Capsule.mk index ab24e58a19..7332e0c357 100644 --- a/userland/capsule_app_store/Capsule.mk +++ b/userland/capsule_app_store/Capsule.mk @@ -20,7 +20,7 @@ CAPSULE_FEATURE := nonos-capsule-app-store CAPSULE_NAMESPACE := systems.nonos.app.store CAPSULE_SERVICE_ENDPOINT := service:4940:app.store CAPSULE_REPLY_ENDPOINT := reply:4941:endpoint.app.store.reply -CAPSULE_REQUIRED_CAPS := 0xC0001819 +CAPSULE_REQUIRED_CAPS := 0x40001819 CAPSULE_KERNEL_MIRROR := src/userspace/capsule_app_store include nonos-mk/capsule.mk From 12b88616890160274c1ca054ea62f8e6ecbd3d2a Mon Sep 17 00:00:00 2001 From: eKisNonos Date: Sat, 26 Sep 2026 17:11:30 +0000 Subject: [PATCH 036/244] setup: an amnesic machine does not keep consent either The consent token was written to disk whichever persistence mode the person had just picked. Amnesic now means the grant lasts this boot and nothing is written, and the review screen says so. --- userland/capsule_setup_wizard/src/consent.rs | 12 ++++++++---- .../src/render/screens/review.rs | 7 ++++--- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/userland/capsule_setup_wizard/src/consent.rs b/userland/capsule_setup_wizard/src/consent.rs index c0e4912f2f..e1caa4d85e 100644 --- a/userland/capsule_setup_wizard/src/consent.rs +++ b/userland/capsule_setup_wizard/src/consent.rs @@ -36,10 +36,11 @@ pub fn restore() -> bool { } /// Apply what the person chose. Withdrawing deletes the token too, so the -/// next boot does not quietly restore what was just taken back. -pub fn apply(allow: bool, was_allowed: bool) { +/// next boot does not quietly restore what was just taken back. `keep` is +/// the persistence choice: an amnesic machine keeps nothing, this included. +pub fn apply(allow: bool, was_allowed: bool, keep: bool) { match (allow, was_allowed) { - (true, false) => grant(), + (true, false) => grant(keep), (false, true) => { let _ = mk_local_consent_revoke(); let _ = vfs::store_remove(TOKEN); @@ -51,10 +52,13 @@ pub fn apply(allow: bool, was_allowed: bool) { /// A machine with no key to keep consent with gets it for this boot only, /// and nothing is written that could be mistaken for more. -fn grant() { +fn grant(keep: bool) { let Ok(Some(token)) = mk_local_consent_grant() else { return; }; + if !keep { + return; + } let pid = mk_getpid(); let _ = vfs::mkdir(pid, b"/nonos"); let _ = vfs::mkdir(pid, b"/nonos/consent"); diff --git a/userland/capsule_setup_wizard/src/render/screens/review.rs b/userland/capsule_setup_wizard/src/render/screens/review.rs index cab31d0cbb..662e35e241 100644 --- a/userland/capsule_setup_wizard/src/render/screens/review.rs +++ b/userland/capsule_setup_wizard/src/render/screens/review.rs @@ -11,8 +11,9 @@ pub fn draw(ctx: &Context) { let (w, h) = (ctx.width, ctx.height); let buf = render::buffer(ctx); // Named here too, since this commit is what grants or revokes it. - let local: &[u8] = match ctx.local_sel { - 1 => b"Installed software may run", + let local: &[u8] = match (ctx.local_sel, ctx.persist_sel) { + (1, 1) => b"Installed software may run", + (1, _) => b"Installed software may run, this boot", _ => b"Only NONOS software runs", }; let lines: [(&[u8], bool); 4] = [ @@ -38,7 +39,7 @@ fn commit(ctx: &Context) { let _ = policy::set_bool(p, Field::WifiAutoconnect as u32, ctx.net_sel == 1); let _ = policy::set_bool(p, Field::AutoWipe as u32, ctx.privacy & 0b010 != 0); let _ = policy::set_bool(p, Field::NymEnabled as u32, ctx.privacy & 0b001 != 0); - crate::consent::apply(ctx.local_sel == 1, ctx.local_was); + crate::consent::apply(ctx.local_sel == 1, ctx.local_was, ctx.persist_sel == 1); if ctx.host_len > 0 { let _ = policy::set_str(p, Field::Hostname as u32, &ctx.host_buf[..ctx.host_len]); } From 3ef223e60c545658fe4f243d4e0ee1dfcd5c7c1e Mon Sep 17 00:00:00 2001 From: eKisNonos Date: Sat, 26 Sep 2026 17:45:06 +0000 Subject: [PATCH 037/244] services: sleep, not only yield, while a capsule owes the kernel a reply A yield hands the CPU only to something of higher priority. When the capsule ranks below the caller, the scheduler picks the caller again, and the fifty thousand yields round_trip allowed passed in 0 ms. vfs_pool's store writes died that way: the kernel reported TransportFailure, then the block driver wrote the sector with nobody left to hear it. Nothing setup persisted survived. After 64 plain yields, so a capsule that outranks the caller still costs no sleep, the caller sleeps a tick per round, for at most five seconds. Waiting for the transport lock backs off the same way, or a waiter that outranks a sleeping holder would never let it run. --- src/services/lifecycle/mod.rs | 1 + src/services/lifecycle/reply_wait.rs | 60 ++++++++++++++++++++++++++++ src/services/lifecycle/transport.rs | 11 +++-- 3 files changed, 69 insertions(+), 3 deletions(-) create mode 100644 src/services/lifecycle/reply_wait.rs diff --git a/src/services/lifecycle/mod.rs b/src/services/lifecycle/mod.rs index 217b3c08dc..3db95a6f54 100644 --- a/src/services/lifecycle/mod.rs +++ b/src/services/lifecycle/mod.rs @@ -28,6 +28,7 @@ // generation, even if the request_id happens to match. mod registry; +mod reply_wait; mod state; pub mod supervisor; pub mod transport; diff --git a/src/services/lifecycle/reply_wait.rs b/src/services/lifecycle/reply_wait.rs new file mode 100644 index 0000000000..a9e1ce34ff --- /dev/null +++ b/src/services/lifecycle/reply_wait.rs @@ -0,0 +1,60 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! How a kernel caller waits for a capsule's reply. + +/// Rounds of plain yielding first: a capsule that outranks the caller runs +/// on the first of them, so the common case costs no sleep. +const FAST_ROUNDS: u32 = 64; + +/// How long a caller that has fallen back to sleeping keeps waiting. +const SLOW_BUDGET_MS: u64 = 5_000; + +/* + * A yield only hands the CPU to something of higher priority: when the + * capsule ranks below the caller, the scheduler picks the caller again and + * fifty thousand yields pass in no time while the capsule never runs. A + * block write from vfs failed that way, then landed on disk after the + * kernel had already reported it lost. Sleeping takes the caller off the + * run queue, so the capsule runs. The reply goes to a kernel inbox that + * wakes no one, so each sleep is a single tick. + */ +/// Wait one round for a reply. False once the budget is spent. +pub(super) fn pause(round: u32, started_ms: u64) -> bool { + if round >= FAST_ROUNDS + && crate::time::timestamp_millis().saturating_sub(started_ms) >= SLOW_BUDGET_MS + { + return false; + } + rest(round); + true +} + +/// Give the CPU away for one round, by sleeping a tick once plain yields +/// have had their chance. A caller with no process can only yield. +pub(super) fn rest(round: u32) { + let pid = match crate::process::current_pid() { + Some(pid) if round >= FAST_ROUNDS => pid, + _ => { + crate::sched::yield_now(); + return; + } + }; + let token = crate::sched::wake_token(pid); + let wake = crate::time::timestamp_millis().saturating_add(1); + crate::sched::sleep_until_unless_woken(pid, wake, token); + crate::sched::yield_now(); +} diff --git a/src/services/lifecycle/transport.rs b/src/services/lifecycle/transport.rs index 80db8cc428..1299e78ab0 100644 --- a/src/services/lifecycle/transport.rs +++ b/src/services/lifecycle/transport.rs @@ -42,11 +42,13 @@ const RECV_YIELDS: u32 = 50_000; /// can never run to release the lock (the deadlock involuntary preemption now /// makes reachable). On contention, hand the CPU to the holder and retry. pub fn lock_yielding(lock: &'static Mutex<()>) -> MutexGuard<'static, ()> { + let mut round = 0u32; loop { if let Some(guard) = lock.try_lock() { return guard; } - crate::sched::yield_now(); + super::reply_wait::rest(round); + round = round.saturating_add(1); } } @@ -183,7 +185,8 @@ pub fn round_trip( } } - for _ in 0..RECV_YIELDS { + let started_ms = crate::time::timestamp_millis(); + for round in 0..RECV_YIELDS { if !state.is_alive() { return Err(TransportError::Dead); } @@ -200,7 +203,9 @@ pub fn round_trip( } return Ok(ResponseBytes { status: resp.status, body: resp.body.to_vec() }); } - crate::sched::yield_now(); + if !super::reply_wait::pause(round, started_ms) { + break; + } } Err(TransportError::TransportFailure) } From e6d765238c78be53edff1f3af829c278c5df34ae Mon Sep 17 00:00:00 2001 From: eKisNonos Date: Sat, 26 Sep 2026 18:25:05 +0000 Subject: [PATCH 038/244] vfs: stage the disk at device speed, and say when it is done Staging waited for three quiet 250 ms polls before every 8 ms slice, and any request inside that window reset the count. Under TCG a boot staged in ten minutes or not at all; main's own boot never logged it. Nothing persisted came back on the next boot. The quiet gate now applies only to starting a load. Once one is running, the loop polls every millisecond and each idle slot advances it, and a store that is never quiet still gets a slice after a request once a second. OP_STORE_STATUS carries a second word, set once staging has loaded or given up, so a reader can tell a missing file from one not yet loaded. Clients that read only the code are unaffected. The slow-op line moves to its own file to keep the runner in bounds. --- userland/capsule_setup_wizard/src/consent.rs | 68 ------------------- userland/capsule_vfs/src/blk/status.rs | 14 +++- .../src/server/handlers/store_status.rs | 8 ++- userland/capsule_vfs/src/server/mod.rs | 3 + userland/capsule_vfs/src/server/runner.rs | 18 +---- userland/capsule_vfs/src/server/seeder.rs | 13 ++-- .../capsule_vfs/src/server/seeder_busy.rs | 41 +++++++++++ .../capsule_vfs/src/server/seeder_idle.rs | 49 +++---------- .../capsule_vfs/src/server/seeder_step.rs | 68 +++++++++++++++++++ userland/capsule_vfs/src/server/slow_op.rs | 38 +++++++++++ 10 files changed, 189 insertions(+), 131 deletions(-) delete mode 100644 userland/capsule_setup_wizard/src/consent.rs create mode 100644 userland/capsule_vfs/src/server/seeder_busy.rs create mode 100644 userland/capsule_vfs/src/server/seeder_step.rs create mode 100644 userland/capsule_vfs/src/server/slow_op.rs diff --git a/userland/capsule_setup_wizard/src/consent.rs b/userland/capsule_setup_wizard/src/consent.rs deleted file mode 100644 index e1caa4d85e..0000000000 --- a/userland/capsule_setup_wizard/src/consent.rs +++ /dev/null @@ -1,68 +0,0 @@ -// NONOS Operating System -// Copyright (C) 2026 NONOS Contributors -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -//! Whether this machine runs what it installs: decided in setup and nowhere -//! else, because widening what a machine executes is not a button in an app. - -use nonos_app_skeleton::clients::vfs; -use nonos_libc::{mk_getpid, mk_local_consent_grant, mk_local_consent_revoke, mk_local_restore}; - -/// Where the token that restores the decision is kept. It opens nothing on -/// another machine or under another kernel, so it may sit on the disk. -const TOKEN: &[u8] = b"/nonos/consent/local.token"; - -/// Restore a decision made on an earlier boot. True when there was one. -pub fn restore() -> bool { - let Ok(raw) = vfs::read_file(mk_getpid(), TOKEN, 32) else { - return false; - }; - let Ok(token) = <[u8; 32]>::try_from(raw.as_slice()) else { - return false; - }; - mk_local_restore(&token) == 0 -} - -/// Apply what the person chose. Withdrawing deletes the token too, so the -/// next boot does not quietly restore what was just taken back. `keep` is -/// the persistence choice: an amnesic machine keeps nothing, this included. -pub fn apply(allow: bool, was_allowed: bool, keep: bool) { - match (allow, was_allowed) { - (true, false) => grant(keep), - (false, true) => { - let _ = mk_local_consent_revoke(); - let _ = vfs::store_remove(TOKEN); - let _ = vfs::unlink(mk_getpid(), TOKEN); - } - _ => {} - } -} - -/// A machine with no key to keep consent with gets it for this boot only, -/// and nothing is written that could be mistaken for more. -fn grant(keep: bool) { - let Ok(Some(token)) = mk_local_consent_grant() else { - return; - }; - if !keep { - return; - } - let pid = mk_getpid(); - let _ = vfs::mkdir(pid, b"/nonos"); - let _ = vfs::mkdir(pid, b"/nonos/consent"); - if vfs::write_file(pid, TOKEN, &token).is_ok() { - let _ = vfs::persist(pid, TOKEN); - } -} diff --git a/userland/capsule_vfs/src/blk/status.rs b/userland/capsule_vfs/src/blk/status.rs index ce4ab9ade7..333cb99bb0 100644 --- a/userland/capsule_vfs/src/blk/status.rs +++ b/userland/capsule_vfs/src/blk/status.rs @@ -17,12 +17,24 @@ // A store that fails to decode at boot used to become a silently empty // /capsules. The first failure's code is kept here so OP_STORE_STATUS can // report it; later failures never overwrite the original evidence. -use core::sync::atomic::{AtomicU32, Ordering}; +use core::sync::atomic::{AtomicBool, AtomicU32, Ordering}; use super::error::BlkError; static STORE_STATUS: AtomicU32 = AtomicU32::new(0); +/// Set once boot staging has ended, loaded or given up. Until then a file +/// that is not there may simply not be loaded yet. +static SETTLED: AtomicBool = AtomicBool::new(false); + +pub fn settle() { + SETTLED.store(true, Ordering::Release); +} + +pub fn settled() -> bool { + SETTLED.load(Ordering::Acquire) +} + pub fn record(err: &BlkError) { let _ = STORE_STATUS.compare_exchange(0, code(err), Ordering::Relaxed, Ordering::Relaxed); } diff --git a/userland/capsule_vfs/src/server/handlers/store_status.rs b/userland/capsule_vfs/src/server/handlers/store_status.rs index 711b20c816..3717dee751 100644 --- a/userland/capsule_vfs/src/server/handlers/store_status.rs +++ b/userland/capsule_vfs/src/server/handlers/store_status.rs @@ -19,6 +19,10 @@ use alloc::vec::Vec; use crate::protocol::{encode_response, Request, OP_STORE_STATUS}; pub fn store_status(req: Request<'_>) -> Vec { - let code = crate::blk::status::current(); - encode_response(OP_STORE_STATUS, req.flags, req.request_id, 0, &code.to_le_bytes()) + // The settled word follows the code, so a client reading only the code + // is unaffected. + let mut body = [0u8; 8]; + body[..4].copy_from_slice(&crate::blk::status::current().to_le_bytes()); + body[4..].copy_from_slice(&u32::from(crate::blk::status::settled()).to_le_bytes()); + encode_response(OP_STORE_STATUS, req.flags, req.request_id, 0, &body) } diff --git a/userland/capsule_vfs/src/server/mod.rs b/userland/capsule_vfs/src/server/mod.rs index a5e7ad1777..5badb68c41 100644 --- a/userland/capsule_vfs/src/server/mod.rs +++ b/userland/capsule_vfs/src/server/mod.rs @@ -19,6 +19,9 @@ pub mod generation; mod handlers; mod runner; mod seeder; +mod seeder_busy; mod seeder_idle; +mod seeder_step; +mod slow_op; pub use runner::run; diff --git a/userland/capsule_vfs/src/server/runner.rs b/userland/capsule_vfs/src/server/runner.rs index f4f1353b89..6a0152b405 100644 --- a/userland/capsule_vfs/src/server/runner.rs +++ b/userland/capsule_vfs/src/server/runner.rs @@ -60,21 +60,7 @@ pub fn run() -> ! { } else { let _ = mk_ipc_reply(sender_pid, resp.as_ptr(), resp.len()); } - // A handler that outlives its caller's timeout turns every reply into - // a drop and reads as a dead service. Name the op and the cost. - let spent = nonos_libc::mk_uptime_ms().saturating_sub(started); - if spent > 1000 { - let mut line = *b"[VFS] slow op 0000 ms 000000"; - for (i, shift) in [(14usize, 12u32), (15, 8), (16, 4), (17, 0)] { - line[i] = b"0123456789abcdef"[((op as usize) >> shift) & 0xF]; - } - let ms = spent.min(999_999) as u32; - let mut v = ms; - for i in (22..28).rev() { - line[i] = b'0' + (v % 10) as u8; - v /= 10; - } - let _ = mk_debug(line.as_ptr(), line.len()); - } + super::slow_op::report(op, nonos_libc::mk_uptime_ms().saturating_sub(started)); + seeder.on_busy(&mut store); } } diff --git a/userland/capsule_vfs/src/server/seeder.rs b/userland/capsule_vfs/src/server/seeder.rs index 0b98e14e14..f549822f1f 100644 --- a/userland/capsule_vfs/src/server/seeder.rs +++ b/userland/capsule_vfs/src/server/seeder.rs @@ -27,7 +27,7 @@ // The load is resumable now and runs on a time budget, so the receive path gets // control back after a few milliseconds and the longest anyone waits is a single // block request. -use nonos_libc::mk_debug; +use nonos_libc::{mk_debug, mk_uptime_ms}; use crate::blk::load::Load; @@ -49,18 +49,19 @@ pub struct PackageSeeder { /// The load in progress. Held across idle slots, which is the whole point: /// each slot advances it and hands the receive loop back. pub(super) load: Option, + pub(super) last_slice_ms: i64, } impl PackageSeeder { pub fn new() -> Self { - Self { attempts: 0, quiet: 0, done: false, load: None } + Self { attempts: 0, quiet: 0, done: false, load: None, last_slice_ms: mk_uptime_ms() } } pub fn poll_ms(&self) -> u64 { - if self.done { - 0 - } else { - POLL_MS + match (self.done, self.load.is_some()) { + (true, _) => 0, + (false, true) => 1, + (false, false) => POLL_MS, } } diff --git a/userland/capsule_vfs/src/server/seeder_busy.rs b/userland/capsule_vfs/src/server/seeder_busy.rs new file mode 100644 index 0000000000..ab83a2cfbd --- /dev/null +++ b/userland/capsule_vfs/src/server/seeder_busy.rs @@ -0,0 +1,41 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! Staging on a store that is never quiet. + +use crate::store::Store; + +use super::seeder::PackageSeeder; + +/// The longest staging waits for a quiet moment before it takes one anyway. +const STARVE_MS: i64 = 1_000; + +/* + * The idle slot needs several receive timeouts in a row, and any caller + * polling faster than that resets the count. A desktop reading the store + * generation does, so staging never began and nothing written to the disk + * came back on the next boot. A slice is a few milliseconds, so taking one + * after a request, once a second at most, costs callers almost nothing. + */ +impl PackageSeeder { + pub fn on_busy(&mut self, store: &mut Store) { + let now = nonos_libc::mk_uptime_ms(); + if self.done || now.saturating_sub(self.last_slice_ms) < STARVE_MS { + return; + } + self.advance(store); + } +} diff --git a/userland/capsule_vfs/src/server/seeder_idle.rs b/userland/capsule_vfs/src/server/seeder_idle.rs index 7df1f49bcd..facce5e1b9 100644 --- a/userland/capsule_vfs/src/server/seeder_idle.rs +++ b/userland/capsule_vfs/src/server/seeder_idle.rs @@ -16,54 +16,27 @@ //! Advancing the staging load from the receive loop's idle slot. -use crate::blk::load::{Load, Step}; use crate::store::Store; -use super::seeder::{note, PackageSeeder, MAX_ATTEMPTS, QUIET_POLLS, SLICE_MS}; +use super::seeder::{PackageSeeder, QUIET_POLLS}; impl PackageSeeder { pub fn on_idle(&mut self, store: &mut Store) { if self.done { return; } - self.quiet += 1; - if self.quiet < QUIET_POLLS + self.attempts { - return; - } - self.quiet = 0; + /* + * The quiet gate is for starting a load. One in progress takes every + * idle slot, and poll_ms makes those a millisecond apart: gated, a + * slice ran every 750 ms and staging took ten minutes under TCG. + */ if self.load.is_none() { - self.attempts += 1; - match Load::begin() { - Ok(load) => self.load = Some(load), - Err(e) => { - crate::blk::status::record(&e); - if self.attempts >= MAX_ATTEMPTS { - self.done = true; - note(b"[VFSD] packages unavailable\n"); - } - return; - } - } - } - let Some(load) = self.load.as_mut() else { - return; - }; - match load.step_for(SLICE_MS) { - Step::More => {} - Step::Done(staged) => { - store.adopt_staged(staged); - self.load = None; - self.done = true; - note(b"[VFSD] packages staged\n"); - } - Step::Failed(e) => { - crate::blk::status::record(&e); - self.load = None; - if self.attempts >= MAX_ATTEMPTS { - self.done = true; - note(b"[VFSD] packages unavailable\n"); - } + self.quiet += 1; + if self.quiet < QUIET_POLLS + self.attempts { + return; } + self.quiet = 0; } + self.advance(store); } } diff --git a/userland/capsule_vfs/src/server/seeder_step.rs b/userland/capsule_vfs/src/server/seeder_step.rs new file mode 100644 index 0000000000..9ceec339d6 --- /dev/null +++ b/userland/capsule_vfs/src/server/seeder_step.rs @@ -0,0 +1,68 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! One staging slice, and the end of staging. + +use crate::blk::load::{Load, Step}; +use crate::store::Store; + +use super::seeder::{note, PackageSeeder, MAX_ATTEMPTS, SLICE_MS}; + +impl PackageSeeder { + /// One staging slice, from whichever path found it due. + pub(super) fn advance(&mut self, store: &mut Store) { + self.last_slice_ms = nonos_libc::mk_uptime_ms(); + if self.load.is_none() { + self.attempts += 1; + match Load::begin() { + Ok(load) => self.load = Some(load), + Err(e) => { + crate::blk::status::record(&e); + if self.attempts >= MAX_ATTEMPTS { + self.finish(b"[VFSD] packages unavailable\n"); + } + return; + } + } + } + let Some(load) = self.load.as_mut() else { + return; + }; + match load.step_for(SLICE_MS) { + Step::More => {} + Step::Done(staged) => { + store.adopt_staged(staged); + self.load = None; + self.finish(b"[VFSD] packages staged\n"); + } + Step::Failed(e) => { + crate::blk::status::record(&e); + self.load = None; + if self.attempts >= MAX_ATTEMPTS { + self.finish(b"[VFSD] packages unavailable\n"); + } + } + } + } + + /// However staging ends, a reader asking whether a missing file is missing + /// or not yet loaded gets a definite answer from here on. + fn finish(&mut self, line: &[u8]) { + self.done = true; + crate::blk::status::settle(); + note(line); + } +} diff --git a/userland/capsule_vfs/src/server/slow_op.rs b/userland/capsule_vfs/src/server/slow_op.rs new file mode 100644 index 0000000000..d9bd694e0e --- /dev/null +++ b/userland/capsule_vfs/src/server/slow_op.rs @@ -0,0 +1,38 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! The line a slow handler leaves on the console. + +use nonos_libc::mk_debug; + +/// A handler that outlives its caller's timeout turns every reply into a drop +/// and reads as a dead service. Name the op and the cost. +pub(super) fn report(op: u16, spent: i64) { + if spent <= 1000 { + return; + } + let mut line = *b"[VFS] slow op 0000 ms 000000"; + for (i, shift) in [(14usize, 12u32), (15, 8), (16, 4), (17, 0)] { + line[i] = b"0123456789abcdef"[((op as usize) >> shift) & 0xF]; + } + let ms = spent.min(999_999) as u32; + let mut v = ms; + for i in (22..28).rev() { + line[i] = b'0' + (v % 10) as u8; + v /= 10; + } + let _ = mk_debug(line.as_ptr(), line.len()); +} From 1f13bd7da80caebb95ebca6dfe72cd8637dee9ea Mon Sep 17 00:00:00 2001 From: eKisNonos Date: Sat, 26 Sep 2026 18:25:05 +0000 Subject: [PATCH 039/244] app_skeleton: ask vfs whether the store has finished loading store_settled reads the new word in OP_STORE_STATUS. An older vfs that sends only the code reads as settled, which is how it always behaved. --- userland/app_skeleton/src/clients/vfs/mod.rs | 2 +- .../src/clients/vfs/store_status.rs | 17 +++++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/userland/app_skeleton/src/clients/vfs/mod.rs b/userland/app_skeleton/src/clients/vfs/mod.rs index b70f089195..d389bef93a 100644 --- a/userland/app_skeleton/src/clients/vfs/mod.rs +++ b/userland/app_skeleton/src/clients/vfs/mod.rs @@ -59,7 +59,7 @@ pub use stat::stat; pub use stat_full::stat_full; pub use store_install::store_install; pub use store_remove::store_remove; -pub use store_status::store_status; +pub use store_status::{store_settled, store_status}; pub use store_uninstall::store_uninstall; pub use stream::VfsStream; pub use truncate::truncate; diff --git a/userland/app_skeleton/src/clients/vfs/store_status.rs b/userland/app_skeleton/src/clients/vfs/store_status.rs index e99b4d54be..42089c9754 100644 --- a/userland/app_skeleton/src/clients/vfs/store_status.rs +++ b/userland/app_skeleton/src/clients/vfs/store_status.rs @@ -35,3 +35,20 @@ pub fn store_status() -> Result { } read_u32(&rx, HDR_LEN + 4).map_err(|_| ERR_TRANSPORT) } + +/// Whether vfs has finished loading the store from disk, so a file that is +/// not there is really absent rather than not loaded yet. An older vfs that +/// sends only the code reads as settled, which is what it always behaved as. +pub fn store_settled() -> Result { + let port = super::resolve::vfs_port(); + let mut rx = vec![0u8; HDR_LEN + 12]; + let (status, len) = super::call::call(port, super::types::OP_STORE_STATUS, 19, &[], &mut rx) + .map_err(|_| ERR_TRANSPORT)?; + if status != 0 { + return Err(status); + } + if len < HDR_LEN + 12 { + return Ok(true); + } + read_u32(&rx, HDR_LEN + 8).map(|v| v != 0).map_err(|_| ERR_TRANSPORT) +} From c0221ffd8fbfc5b7b34fcbacb971ee535de805ed Mon Sep 17 00:00:00 2001 From: eKisNonos Date: Sat, 26 Sep 2026 18:25:05 +0000 Subject: [PATCH 040/244] setup: wait for the disk before deciding there is no earlier consent Setup read the consent token once, at start, long before vfs had loaded the disk, so consent given on an earlier boot was never found. Restore now tells "absent" from "not loaded yet", and setup asks again every 500 ms until vfs has settled. A restore that lands before the person reaches the step shows the standing choice; after it, their choice holds. consent.rs splits into apply and restore, and the console helper leaves the runner, to keep each file in bounds. --- .../capsule_setup_wizard/src/consent/apply.rs | 54 +++++++++++++++++++ .../capsule_setup_wizard/src/consent/mod.rs | 24 +++++++++ .../src/consent/restore.rs | 47 ++++++++++++++++ userland/capsule_setup_wizard/src/main.rs | 3 +- .../capsule_setup_wizard/src/server/mod.rs | 2 + .../src/server/restore_poll.rs | 44 +++++++++++++++ .../capsule_setup_wizard/src/server/runner.rs | 19 ++++--- .../capsule_setup_wizard/src/server/say.rs | 23 ++++++++ userland/capsule_setup_wizard/src/state.rs | 3 ++ 9 files changed, 210 insertions(+), 9 deletions(-) create mode 100644 userland/capsule_setup_wizard/src/consent/apply.rs create mode 100644 userland/capsule_setup_wizard/src/consent/mod.rs create mode 100644 userland/capsule_setup_wizard/src/consent/restore.rs create mode 100644 userland/capsule_setup_wizard/src/server/restore_poll.rs create mode 100644 userland/capsule_setup_wizard/src/server/say.rs diff --git a/userland/capsule_setup_wizard/src/consent/apply.rs b/userland/capsule_setup_wizard/src/consent/apply.rs new file mode 100644 index 0000000000..17e276cd9e --- /dev/null +++ b/userland/capsule_setup_wizard/src/consent/apply.rs @@ -0,0 +1,54 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! Granting and withdrawing, when the review screen commits. + +use nonos_app_skeleton::clients::vfs; +use nonos_libc::{mk_getpid, mk_local_consent_grant, mk_local_consent_revoke}; + +use super::restore::TOKEN; + +/// Apply what the person chose. Withdrawing deletes the token too, so the +/// next boot does not quietly restore what was just taken back. `keep` is +/// the persistence choice: an amnesic machine keeps nothing, this included. +pub fn apply(allow: bool, was_allowed: bool, keep: bool) { + match (allow, was_allowed) { + (true, false) => grant(keep), + (false, true) => { + let _ = mk_local_consent_revoke(); + let _ = vfs::store_remove(TOKEN); + let _ = vfs::unlink(mk_getpid(), TOKEN); + } + _ => {} + } +} + +/// A machine with no key to keep consent with gets it for this boot only, +/// and nothing is written that could be mistaken for more. +fn grant(keep: bool) { + let Ok(Some(token)) = mk_local_consent_grant() else { + return; + }; + if !keep { + return; + } + let pid = mk_getpid(); + let _ = vfs::mkdir(pid, b"/nonos"); + let _ = vfs::mkdir(pid, b"/nonos/consent"); + if vfs::write_file(pid, TOKEN, &token).is_ok() { + let _ = vfs::persist(pid, TOKEN); + } +} diff --git a/userland/capsule_setup_wizard/src/consent/mod.rs b/userland/capsule_setup_wizard/src/consent/mod.rs new file mode 100644 index 0000000000..88c9e31267 --- /dev/null +++ b/userland/capsule_setup_wizard/src/consent/mod.rs @@ -0,0 +1,24 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! Whether this machine runs what it installs: decided in setup and nowhere +//! else, because widening what a machine executes is not a button in an app. + +mod apply; +mod restore; + +pub use apply::apply; +pub use restore::{restore, Restore}; diff --git a/userland/capsule_setup_wizard/src/consent/restore.rs b/userland/capsule_setup_wizard/src/consent/restore.rs new file mode 100644 index 0000000000..e13eeff17e --- /dev/null +++ b/userland/capsule_setup_wizard/src/consent/restore.rs @@ -0,0 +1,47 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! Restoring a decision made on an earlier boot. + +use nonos_app_skeleton::clients::vfs; +use nonos_libc::{mk_getpid, mk_local_restore}; + +/// Where the token that restores the decision is kept. It opens nothing on +/// another machine or under another kernel, so it may sit on the disk. +pub(super) const TOKEN: &[u8] = b"/nonos/consent/local.token"; + +/// What the disk says about an earlier decision. +pub enum Restore { + /// True when there was one and the kernel took it back. + Known(bool), + /// vfs has not finished loading the disk, so no token yet proves nothing. + NotYet, +} + +/// Settled is read before the file: if staging ends between the two, the +/// read already saw the loaded store, so an absent token is really absent. +pub fn restore() -> Restore { + let settled = !matches!(vfs::store_settled(), Ok(false)); + let raw = match vfs::read_file(mk_getpid(), TOKEN, 32) { + Ok(raw) => raw, + Err(_) if !settled => return Restore::NotYet, + Err(_) => return Restore::Known(false), + }; + let Ok(token) = <[u8; 32]>::try_from(raw.as_slice()) else { + return Restore::Known(false); + }; + Restore::Known(mk_local_restore(&token) == 0) +} diff --git a/userland/capsule_setup_wizard/src/main.rs b/userland/capsule_setup_wizard/src/main.rs index 6712376038..e73e60dcbe 100644 --- a/userland/capsule_setup_wizard/src/main.rs +++ b/userland/capsule_setup_wizard/src/main.rs @@ -23,7 +23,6 @@ pub unsafe extern "C" fn _start() -> ! { Err(_) => mk_exit(2), }; let mut ctx = ctx; - ctx.local_was = consent::restore(); - ctx.local_sel = ctx.local_was as u8; + server::restore_poll::poll(&mut ctx); server::runner::run(ctx) } diff --git a/userland/capsule_setup_wizard/src/server/mod.rs b/userland/capsule_setup_wizard/src/server/mod.rs index ad2e271f31..e9779f20a5 100644 --- a/userland/capsule_setup_wizard/src/server/mod.rs +++ b/userland/capsule_setup_wizard/src/server/mod.rs @@ -1,2 +1,4 @@ +pub mod restore_poll; pub mod runner; +mod say; pub mod step; diff --git a/userland/capsule_setup_wizard/src/server/restore_poll.rs b/userland/capsule_setup_wizard/src/server/restore_poll.rs new file mode 100644 index 0000000000..9ce96a1146 --- /dev/null +++ b/userland/capsule_setup_wizard/src/server/restore_poll.rs @@ -0,0 +1,44 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! Picking up consent given on an earlier boot, once the disk has loaded. + +use crate::consent::{self, Restore}; +use crate::state::Context; + +/// The step that asks. Past it, what the person chose stands. +const LOCAL_STEP: u8 = 9; + +/// Ask the disk once. True when the answer changed what is on screen. +pub fn poll(ctx: &mut Context) -> bool { + match consent::restore() { + Restore::NotYet => { + ctx.local_pending = true; + false + } + Restore::Known(was) => { + ctx.local_pending = false; + ctx.local_was = was; + if was { + super::say::say(b"[SETUP] consent restored from an earlier boot\n"); + } + if was && ctx.step < LOCAL_STEP { + ctx.local_sel = 1; + } + was + } + } +} diff --git a/userland/capsule_setup_wizard/src/server/runner.rs b/userland/capsule_setup_wizard/src/server/runner.rs index 686f7d43c0..06eaf10666 100644 --- a/userland/capsule_setup_wizard/src/server/runner.rs +++ b/userland/capsule_setup_wizard/src/server/runner.rs @@ -7,11 +7,15 @@ use crate::protocol::{parse_delivery, DELIVERY_LEN}; use crate::render::screens; use crate::state::Context; +use super::say::say; use super::step::{self, DONE}; /// How long to wait for input before asking for the keyboard again. const GRAB_RETRY_MS: u64 = 100; +/// How often to ask whether the disk has loaded consent from an earlier boot. +const RESTORE_RETRY_MS: u64 = 500; + pub fn run(mut ctx: Context) -> ! { if input_router::subscribe(ctx.router_port, 1).is_err() { say(b"[SETUP] the input router refused the subscription\n"); @@ -34,9 +38,16 @@ pub fn run(mut ctx: Context) -> ! { say(b"[SETUP] keyboard held\n"); } } - let wait = if held { 0 } else { GRAB_RETRY_MS }; + let wait = match (held, ctx.local_pending) { + (false, _) => GRAB_RETRY_MS, + (true, true) => RESTORE_RETRY_MS, + (true, false) => 0, + }; let mut sender = 0u32; let n = mk_ipc_recv_from(0, rx.as_mut_ptr(), rx.len(), wait, &mut sender); + if ctx.local_pending && super::restore_poll::poll(&mut ctx) { + redraw(&ctx); + } if n <= 0 { continue; } @@ -60,9 +71,3 @@ fn redraw(ctx: &Context) { screens::draw(ctx); let _ = compositor::damage_commit(ctx.compositor_port, 9, ctx.width, ctx.height); } - -/// Said on the console: a setup that never gets the keyboard looks like one -/// waiting for a person. -fn say(line: &[u8]) { - let _ = nonos_libc::mk_debug(line.as_ptr(), line.len()); -} diff --git a/userland/capsule_setup_wizard/src/server/say.rs b/userland/capsule_setup_wizard/src/server/say.rs new file mode 100644 index 0000000000..ea61f10a03 --- /dev/null +++ b/userland/capsule_setup_wizard/src/server/say.rs @@ -0,0 +1,23 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! Setup's lines on the console. + +/// Said on the console: a setup that never gets the keyboard looks like one +/// waiting for a person. +pub fn say(line: &[u8]) { + let _ = nonos_libc::mk_debug(line.as_ptr(), line.len()); +} diff --git a/userland/capsule_setup_wizard/src/state.rs b/userland/capsule_setup_wizard/src/state.rs index 057b5b1da5..6289220bae 100644 --- a/userland/capsule_setup_wizard/src/state.rs +++ b/userland/capsule_setup_wizard/src/state.rs @@ -22,6 +22,8 @@ pub struct Context { /// decided, so setup shows the standing choice rather than asking again. pub local_sel: u8, pub local_was: bool, + /// The disk was still loading when setup asked, so it asks again. + pub local_pending: bool, pub privacy: u16, pub admin_len: usize, pub admin_buf: [u8; 64], @@ -62,6 +64,7 @@ impl Context { persist_sel: 0, local_sel: 0, local_was: false, + local_pending: false, privacy: 0b0000_0011, admin_len: 0, admin_buf: [0u8; 64], From 09c9a02185e154173bb0a45208c7cc6085cb103b Mon Sep 17 00:00:00 2001 From: eKisNonos Date: Sat, 26 Sep 2026 18:40:32 +0000 Subject: [PATCH 041/244] setup: overwrite the consent token, since the disk cannot drop it Withdrawing consent called store_remove, which only the installer may use, so the token stayed on disk and the next boot restored what had just been taken back. Re-granting after a kernel change failed too: the old token loads back owned by nobody, and only a file's owner may persist it. Both now unlink the loaded copy, write the file afresh and persist it over the old record, which the store allows at the same length. A withdrawal writes zeros, which restore treats as no consent. --- .../capsule_setup_wizard/src/consent/apply.rs | 25 +++++++++++++------ .../src/consent/restore.rs | 3 +++ 2 files changed, 20 insertions(+), 8 deletions(-) diff --git a/userland/capsule_setup_wizard/src/consent/apply.rs b/userland/capsule_setup_wizard/src/consent/apply.rs index 17e276cd9e..3b5ee3c921 100644 --- a/userland/capsule_setup_wizard/src/consent/apply.rs +++ b/userland/capsule_setup_wizard/src/consent/apply.rs @@ -21,16 +21,15 @@ use nonos_libc::{mk_getpid, mk_local_consent_grant, mk_local_consent_revoke}; use super::restore::TOKEN; -/// Apply what the person chose. Withdrawing deletes the token too, so the -/// next boot does not quietly restore what was just taken back. `keep` is -/// the persistence choice: an amnesic machine keeps nothing, this included. +/// Apply what the person chose. `keep` is the persistence choice: an amnesic +/// machine keeps nothing, this included. pub fn apply(allow: bool, was_allowed: bool, keep: bool) { match (allow, was_allowed) { (true, false) => grant(keep), (false, true) => { let _ = mk_local_consent_revoke(); - let _ = vfs::store_remove(TOKEN); - let _ = vfs::unlink(mk_getpid(), TOKEN); + // Zeros prove nothing, so the next boot restores nothing. + store(&[0u8; 32]); } _ => {} } @@ -42,13 +41,23 @@ fn grant(keep: bool) { let Ok(Some(token)) = mk_local_consent_grant() else { return; }; - if !keep { - return; + if keep { + store(&token); } +} + +/* + * The disk cannot drop a record, only overwrite one of the same length, and + * only by the file's owner. A token loaded from an earlier boot belongs to + * nobody, so it is unlinked first and written afresh, which makes it this + * capsule's to persist over the old record. + */ +fn store(bytes: &[u8; 32]) { let pid = mk_getpid(); + let _ = vfs::unlink(pid, TOKEN); let _ = vfs::mkdir(pid, b"/nonos"); let _ = vfs::mkdir(pid, b"/nonos/consent"); - if vfs::write_file(pid, TOKEN, &token).is_ok() { + if vfs::write_file(pid, TOKEN, bytes).is_ok() { let _ = vfs::persist(pid, TOKEN); } } diff --git a/userland/capsule_setup_wizard/src/consent/restore.rs b/userland/capsule_setup_wizard/src/consent/restore.rs index e13eeff17e..fd30fdd45e 100644 --- a/userland/capsule_setup_wizard/src/consent/restore.rs +++ b/userland/capsule_setup_wizard/src/consent/restore.rs @@ -43,5 +43,8 @@ pub fn restore() -> Restore { let Ok(token) = <[u8; 32]>::try_from(raw.as_slice()) else { return Restore::Known(false); }; + if token == [0u8; 32] { + return Restore::Known(false); + } Restore::Known(mk_local_restore(&token) == 0) } From feb68d84a08aefcef863f4631094ece2eb5a362a Mon Sep 17 00:00:00 2001 From: eKisNonos Date: Sat, 26 Sep 2026 19:01:33 +0000 Subject: [PATCH 042/244] nonos-mk: take the lockfile that records the marketplace ABI at 0.3.0 --- nonos-mk | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nonos-mk b/nonos-mk index 78dae457e0..c5fdaf8baa 160000 --- a/nonos-mk +++ b/nonos-mk @@ -1 +1 @@ -Subproject commit 78dae457e0ce678e73d934267abb6837a0cb5d71 +Subproject commit c5fdaf8baa1d3aac2557ed54da3d181a07e5dff0 From 66f2fe812e66b6c487cd4db6ebb8c48c113dac3e Mon Sep 17 00:00:00 2001 From: eKisNonos Date: Sat, 26 Sep 2026 23:21:54 +0000 Subject: [PATCH 043/244] linux_guests: guests that attack the personality on purpose MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A stock package never tries to leave its sandbox, so nothing so far has tested whether one could. These are static musl programs, each probing one property and printing a line per attempt, exiting non-zero if any attempt got through: - native: the NØNOS ABI directly (machine key, service lookup, IPC, the debug console, consent restore, attestation entries). - holder and reader: a page with a known pattern, and a sibling trying process_vm_readv, ptrace, a pid probe, /proc//mem and maps, and mapping the same address. - bounds: a TiB mapping, the kernel half, page zero, a TiB brk, and mprotect over the kernel half. Syscalls are raw so a probe can pass exact bytes. On plain Linux the reader and bounds guests report BROKEN, which is the control showing each probe can see an escape. --- userland/linux_guests/Cargo.lock | 7 +++ userland/linux_guests/Cargo.toml | 22 ++++++++ userland/linux_guests/src/arg.rs | 29 ++++++++++ userland/linux_guests/src/bin/bounds.rs | 63 ++++++++++++++++++++++ userland/linux_guests/src/bin/holder.rs | 46 ++++++++++++++++ userland/linux_guests/src/bin/native.rs | 71 ++++++++++++++++++++++++ userland/linux_guests/src/bin/reader.rs | 54 +++++++++++++++++++ userland/linux_guests/src/lib.rs | 23 ++++++++ userland/linux_guests/src/proc_probe.rs | 43 +++++++++++++++ userland/linux_guests/src/report.rs | 65 ++++++++++++++++++++++ userland/linux_guests/src/sys.rs | 72 +++++++++++++++++++++++++ userland/linux_guests/src/vm_probe.rs | 56 +++++++++++++++++++ 12 files changed, 551 insertions(+) create mode 100644 userland/linux_guests/Cargo.lock create mode 100644 userland/linux_guests/Cargo.toml create mode 100644 userland/linux_guests/src/arg.rs create mode 100644 userland/linux_guests/src/bin/bounds.rs create mode 100644 userland/linux_guests/src/bin/holder.rs create mode 100644 userland/linux_guests/src/bin/native.rs create mode 100644 userland/linux_guests/src/bin/reader.rs create mode 100644 userland/linux_guests/src/lib.rs create mode 100644 userland/linux_guests/src/proc_probe.rs create mode 100644 userland/linux_guests/src/report.rs create mode 100644 userland/linux_guests/src/sys.rs create mode 100644 userland/linux_guests/src/vm_probe.rs diff --git a/userland/linux_guests/Cargo.lock b/userland/linux_guests/Cargo.lock new file mode 100644 index 0000000000..2ab48cefc1 --- /dev/null +++ b/userland/linux_guests/Cargo.lock @@ -0,0 +1,7 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "nonos_linux_guests" +version = "0.1.0" diff --git a/userland/linux_guests/Cargo.toml b/userland/linux_guests/Cargo.toml new file mode 100644 index 0000000000..a028af218a --- /dev/null +++ b/userland/linux_guests/Cargo.toml @@ -0,0 +1,22 @@ +# NØNOS userland: linux_guests +# +# Linux programs that attack the personality on purpose. Each probes one +# isolation property, prints one line per attempt, and exits non-zero if any +# attempt got through. A stock package never tries any of this, which is why +# these are written rather than downloaded. + +[package] +name = "nonos_linux_guests" +version = "0.1.0" +edition = "2021" +license = "AGPL-3.0-or-later" +description = "Hostile Linux guests: each tries to break one isolation property and must be refused" +publish = false + +[dependencies] + +[profile.release] +opt-level = "s" +panic = "abort" +strip = true +lto = true diff --git a/userland/linux_guests/src/arg.rs b/userland/linux_guests/src/arg.rs new file mode 100644 index 0000000000..dce36c6258 --- /dev/null +++ b/userland/linux_guests/src/arg.rs @@ -0,0 +1,29 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! Buffers as syscall arguments. + +pub fn p(b: &[u8]) -> u64 { + b.as_ptr() as u64 +} + +pub fn pm(b: &mut [u8]) -> u64 { + b.as_mut_ptr() as u64 +} + +pub fn pu(v: &mut u32) -> u64 { + v as *mut u32 as u64 +} diff --git a/userland/linux_guests/src/bin/bounds.rs b/userland/linux_guests/src/bin/bounds.rs new file mode 100644 index 0000000000..fc23537074 --- /dev/null +++ b/userland/linux_guests/src/bin/bounds.rs @@ -0,0 +1,63 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! A guest asking for more than any plan should give it. +//! +//! Each request is one a bounded address-space plan refuses: honouring it +//! is the failure, not just crashing on it. A refusal must come back as an +//! errno to this process, with the machine still up to print the next line. + +use std::process::ExitCode; + +use nonos_linux_guests::report::{Report, Seen}; +use nonos_linux_guests::sys::{call, BRK, MAP_FIXED, MAP_PRIVATE_ANON, MMAP, MPROTECT, PROT_RW}; + +const TIB: u64 = 1 << 40; +/// The first address of the kernel half on x86_64. +const KERNEL_HALF: u64 = 0xffff_8000_0000_0000; +/// No sane plan maps page zero; a guest that gets it can make null pointers +/// point somewhere. +const PAGE_ZERO: u64 = 0; + +fn main() -> ExitCode { + let mut r = Report::new("bounds"); + let rc = call(MMAP, [0, TIB, PROT_RW, MAP_PRIVATE_ANON, u64::MAX, 0]); + r.check("mmap one TiB", granted(rc, "mapped a TiB")); + let fixed = MAP_PRIVATE_ANON | MAP_FIXED; + let rc = call(MMAP, [KERNEL_HALF, 4096, PROT_RW, fixed, u64::MAX, 0]); + r.check("mmap in the kernel half", granted(rc, "mapped the kernel half")); + let rc = call(MMAP, [PAGE_ZERO, 4096, PROT_RW, fixed, u64::MAX, 0]); + r.check("mmap page zero", granted(rc, "mapped page zero")); + let base = call(BRK, [0; 6]); + let rc = call(BRK, [(base as u64).wrapping_add(TIB), 0, 0, 0, 0, 0]); + // brk reports failure by returning the old break, not an errno. + let seen = match rc > base && base > 0 { + true => Seen::Escaped(format!("break moved by {:#x}", rc - base)), + false => Seen::Refused(-12), + }; + r.check("brk one TiB", seen); + let rc = call(MPROTECT, [KERNEL_HALF, 4096, PROT_RW, 0, 0, 0]); + r.check("mprotect the kernel half", granted(rc, "changed kernel protections")); + r.finish() +} + +/// A mapping call succeeded when it returned an address, not an errno. +fn granted(rc: i64, what: &str) -> Seen { + match rc { + rc if (-4095..0).contains(&rc) => Seen::Refused(rc), + rc => Seen::Escaped(format!("{what} at {rc:#x}")), + } +} diff --git a/userland/linux_guests/src/bin/holder.rs b/userland/linux_guests/src/bin/holder.rs new file mode 100644 index 0000000000..017720358f --- /dev/null +++ b/userland/linux_guests/src/bin/holder.rs @@ -0,0 +1,46 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! The half of the memory pair that holds a secret. +//! +//! It maps a page at an agreed address, fills it with a known pattern, says +//! where, and stays alive long enough for its sibling to go looking. + +use nonos_linux_guests::sys::{ + call, out, GETPID, MAP_FIXED, MAP_PRIVATE_ANON, MMAP, NANOSLEEP, PATTERN, PATTERN_AT, PROT_RW, +}; + +/// Long enough for the reader to run beside it, short enough to be reaped. +const HOLD_SECS: u64 = 60; + +fn main() { + let flags = MAP_PRIVATE_ANON | MAP_FIXED; + let at = call(MMAP, [PATTERN_AT, 4096, PROT_RW, flags, u64::MAX, 0]); + if at != PATTERN_AT as i64 { + out(format!("[GUEST] holder could not map its page: {at}\n").as_bytes()); + return; + } + // SAFETY: the page at PATTERN_AT was just mapped read-write, 4096 bytes. + let page = unsafe { core::slice::from_raw_parts_mut(PATTERN_AT as *mut u8, 4096) }; + for chunk in page.chunks_mut(PATTERN.len()) { + chunk.copy_from_slice(&PATTERN[..chunk.len()]); + } + let pid = call(GETPID, [0; 6]); + out(format!("[GUEST] holder pid={pid} pattern at {PATTERN_AT:#x}\n").as_bytes()); + let ts = [HOLD_SECS, 0u64]; + let _ = call(NANOSLEEP, [ts.as_ptr() as u64, 0, 0, 0, 0, 0]); + out(b"[GUEST] holder done\n"); +} diff --git a/userland/linux_guests/src/bin/native.rs b/userland/linux_guests/src/bin/native.rs new file mode 100644 index 0000000000..31dac11589 --- /dev/null +++ b/userland/linux_guests/src/bin/native.rs @@ -0,0 +1,71 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! A Linux guest reaching for the NØNOS native ABI. +//! +//! The guest holds no capabilities, and its syscalls are meant to reach the +//! personality and nothing else. So each native call here, with arguments +//! that would work for a capsule, must fail. One that returns success means +//! a Linux program can talk to ring 0 as a capsule does. + +use std::process::ExitCode; + +use nonos_linux_guests::arg::{p, pm, pu}; +use nonos_linux_guests::report::{Report, Seen}; +use nonos_linux_guests::sys::call; + +const fn tag4(b: &[u8; 4]) -> u64 { + (b[0] as u64) | ((b[1] as u64) << 8) | ((b[2] as u64) << 16) | ((b[3] as u64) << 24) +} + +fn main() -> ExitCode { + let mut r = Report::new("native"); + let mut key = [0u8; 32]; + let label = b"guest/probe"; + let name = b"vfs_pool"; + let (mut port, mut pid) = (0u32, 0u32); + let msg = [0u8; 16]; + let token = [0u8; 32]; + let mut entries = [0u8; 256]; + let probes: [(&str, u64, [u64; 6]); 6] = [ + ("machine key", tag4(b"CMKY"), [p(label), label.len() as u64, pm(&mut key), 0, 0, 0]), + ( + "service lookup", + tag4(b"MSVL"), + [p(name), name.len() as u64, pu(&mut port), pu(&mut pid), 0, 0], + ), + ("ipc send", tag4(b"MISD"), [1, p(&msg), msg.len() as u64, 0, 0, 0]), + ( + "debug console", + tag4(b"MDBG"), + [p(b"[GUEST] native wrote the console\n"), 34, 0, 0, 0, 0], + ), + ("consent restore", tag4(b"MLCR"), [p(&token), 0, 0, 0, 0, 0]), + ("attest entries", tag4(b"MAEN"), [pm(&mut entries), entries.len() as u64, 0, 0, 0, 0]), + ]; + for (what, nr, args) in probes { + let rc = call(nr, args); + let seen = match rc { + rc if rc < 0 => Seen::Refused(rc), + rc => Seen::Escaped(format!("returned {rc}")), + }; + r.check(what, seen); + } + if key != [0u8; 32] { + r.check("machine key bytes", Seen::Escaped("the buffer was filled".into())); + } + r.finish() +} diff --git a/userland/linux_guests/src/bin/reader.rs b/userland/linux_guests/src/bin/reader.rs new file mode 100644 index 0000000000..efdb684993 --- /dev/null +++ b/userland/linux_guests/src/bin/reader.rs @@ -0,0 +1,54 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! The half of the memory pair that goes looking. +//! +//! Every way Linux offers one process into another's memory, tried against +//! every pid a sibling could plausibly have, then the blunt one: map the same +//! address and see whose page arrives. + +use std::process::ExitCode; + +use nonos_linux_guests::report::{Report, Seen}; +use nonos_linux_guests::sys::{ + call, GETPID, MAP_FIXED, MAP_PRIVATE_ANON, MMAP, PATTERN, PATTERN_AT, PROT_RW, +}; +use nonos_linux_guests::{proc_probe, vm_probe}; + +/// Guest pids are small; a sibling started beside this one sits well inside. +const PID_RANGE: u32 = 256; + +fn main() -> ExitCode { + let mut r = Report::new("reader"); + let me = call(GETPID, [0; 6]) as u32; + let pids: Vec = (1..=PID_RANGE).filter(|p| *p != me).collect(); + vm_probe::scan(&mut r, &pids); + proc_probe::scan(&mut r, &pids); + let flags = MAP_PRIVATE_ANON | MAP_FIXED; + let at = call(MMAP, [PATTERN_AT, 4096, PROT_RW, flags, u64::MAX, 0]); + let seen = if at != PATTERN_AT as i64 { + Seen::Refused(at.min(-1)) + } else { + // SAFETY: mapped read-write just above. + let page = unsafe { core::slice::from_raw_parts(PATTERN_AT as *const u8, 16) }; + match page == PATTERN { + true => Seen::Escaped("the sibling's page came back".into()), + false => Seen::Refused(0), + } + }; + r.check("same address, own page", seen); + r.finish() +} diff --git a/userland/linux_guests/src/lib.rs b/userland/linux_guests/src/lib.rs new file mode 100644 index 0000000000..c11b591b84 --- /dev/null +++ b/userland/linux_guests/src/lib.rs @@ -0,0 +1,23 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! Shared pieces of the hostile guests. + +pub mod arg; +pub mod proc_probe; +pub mod report; +pub mod sys; +pub mod vm_probe; diff --git a/userland/linux_guests/src/proc_probe.rs b/userland/linux_guests/src/proc_probe.rs new file mode 100644 index 0000000000..db5d5d0997 --- /dev/null +++ b/userland/linux_guests/src/proc_probe.rs @@ -0,0 +1,43 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! Reading a sibling through /proc, the way a debugger would. + +use crate::report::{Report, Seen}; +use crate::sys::{call, CLOSE, OPEN, READ}; + +const O_RDONLY: u64 = 0; + +/// /proc//mem and /proc//maps. Opening either for another process +/// is already too much, whatever a read would then return. +pub fn scan(r: &mut Report, pids: &[u32]) { + for leaf in ["mem", "maps"] { + let mut seen = Seen::Refused(-1); + for &pid in pids { + let path = format!("/proc/{pid}/{leaf}\0"); + let fd = call(OPEN, [path.as_ptr() as u64, O_RDONLY, 0, 0, 0, 0]); + if fd >= 0 { + let mut buf = [0u8; 64]; + let n = call(READ, [fd as u64, buf.as_mut_ptr() as u64, 64, 0, 0, 0]); + let _ = call(CLOSE, [fd as u64, 0, 0, 0, 0, 0]); + seen = Seen::Escaped(format!("opened /proc/{pid}/{leaf}, read {n}")); + break; + } + seen = Seen::Refused(fd); + } + r.check(&format!("/proc/pid/{leaf}"), seen); + } +} diff --git a/userland/linux_guests/src/report.rs b/userland/linux_guests/src/report.rs new file mode 100644 index 0000000000..1c7f6eca54 --- /dev/null +++ b/userland/linux_guests/src/report.rs @@ -0,0 +1,65 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! One line per attempt, and a verdict that is the exit status. + +use std::process::ExitCode; + +use crate::sys::out; + +/// What a probe observed. +pub enum Seen { + /// The personality said no; the value is the errno it gave. + Refused(i64), + /// The attempt reached something it should not have. + Escaped(String), +} + +pub struct Report { + guest: &'static str, + escaped: u32, + tried: u32, +} + +impl Report { + pub fn new(guest: &'static str) -> Self { + out(format!("[GUEST] {guest} start\n").as_bytes()); + Report { guest, escaped: 0, tried: 0 } + } + + pub fn check(&mut self, what: &str, seen: Seen) { + self.tried += 1; + let line = match seen { + Seen::Refused(e) => format!("[GUEST] {} refused {what} errno={}\n", self.guest, -e), + Seen::Escaped(how) => { + self.escaped += 1; + format!("[GUEST] {} ESCAPED {what}: {how}\n", self.guest) + } + }; + out(line.as_bytes()); + } + + /// Zero only when every attempt was refused. + pub fn finish(self) -> ExitCode { + let verdict = if self.escaped == 0 { "held" } else { "BROKEN" }; + out(format!( + "[GUEST] {} {verdict}: {} tried, {} escaped\n", + self.guest, self.tried, self.escaped + ) + .as_bytes()); + ExitCode::from(u8::from(self.escaped != 0)) + } +} diff --git a/userland/linux_guests/src/sys.rs b/userland/linux_guests/src/sys.rs new file mode 100644 index 0000000000..78c7a95ba7 --- /dev/null +++ b/userland/linux_guests/src/sys.rs @@ -0,0 +1,72 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! Raw Linux syscalls. The probes pass exact bytes, a path with a NUL in it +//! included, which std refuses to send, so nothing here goes through libc. + +use core::arch::asm; + +pub const READ: u64 = 0; +pub const WRITE: u64 = 1; +pub const OPEN: u64 = 2; +pub const CLOSE: u64 = 3; +pub const MMAP: u64 = 9; +pub const MPROTECT: u64 = 10; +pub const BRK: u64 = 12; +pub const NANOSLEEP: u64 = 35; +pub const GETPID: u64 = 39; +pub const KILL: u64 = 62; +pub const PTRACE: u64 = 101; +pub const SYMLINK: u64 = 88; +pub const OPENAT: u64 = 257; +pub const PROCESS_VM_READV: u64 = 310; + +pub const AT_FDCWD: i64 = -100; + +pub const PROT_RW: u64 = 0x3; +pub const MAP_PRIVATE_ANON: u64 = 0x22; +pub const MAP_FIXED: u64 = 0x10; + +/// Where the memory pair meet. Any address works; both must agree on it. +pub const PATTERN_AT: u64 = 0x5000_0000; +pub const PATTERN: &[u8; 16] = b"NONOS-SIBLING-01"; + +/// One syscall with up to six arguments. The return is the raw value: a +/// negative errno on failure, as the kernel ABI gives it. +pub fn call(nr: u64, a: [u64; 6]) -> i64 { + let ret: i64; + // SAFETY: the syscall instruction clobbers rcx and r11 and reads its + // arguments from the registers named here. Pointers passed in `a` are + // the caller's; a bad one is what a probe is for, and the other side + // either refuses it or faults this process, never the machine. + unsafe { + asm!( + "syscall", + inlateout("rax") nr as i64 => ret, + in("rdi") a[0], in("rsi") a[1], in("rdx") a[2], + in("r10") a[3], in("r8") a[4], in("r9") a[5], + lateout("rcx") _, lateout("r11") _, + options(nostack), + ); + } + ret +} + +/// Write bytes to stdout. The guests have no allocator beyond std's and +/// report through the one channel every personality gives a program. +pub fn out(bytes: &[u8]) { + let _ = call(WRITE, [1, bytes.as_ptr() as u64, bytes.len() as u64, 0, 0, 0]); +} diff --git a/userland/linux_guests/src/vm_probe.rs b/userland/linux_guests/src/vm_probe.rs new file mode 100644 index 0000000000..5281c7d14f --- /dev/null +++ b/userland/linux_guests/src/vm_probe.rs @@ -0,0 +1,56 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! Reading or steering a sibling through the calls built for it. + +use crate::report::{Report, Seen}; +use crate::sys::{call, KILL, PATTERN_AT, PROCESS_VM_READV, PTRACE}; + +const PTRACE_ATTACH: u64 = 16; + +/// process_vm_readv, ptrace and kill against every pid. Each reports once: +/// the first escape it finds, or the errno every pid gave. +pub fn scan(r: &mut Report, pids: &[u32]) { + r.check("process_vm_readv", sweep(pids, read_sibling)); + r.check( + "ptrace attach", + sweep(pids, |pid| call(PTRACE, [PTRACE_ATTACH, pid as u64, 0, 0, 0, 0])), + ); + // Signal 0 delivers nothing and only answers whether the pid exists, so + // a yes is a disclosure; SIGKILL would end the holder and the run. + r.check("kill probe", sweep(pids, |pid| call(KILL, [pid as u64, 0, 0, 0, 0, 0]))); +} + +fn read_sibling(pid: u32) -> i64 { + let mut buf = [0u8; 16]; + let local = [buf.as_mut_ptr() as u64, 16u64]; + let remote = [PATTERN_AT, 16u64]; + // Any read that succeeds is an escape, whatever it brings back. + call(PROCESS_VM_READV, [pid as u64, local.as_ptr() as u64, 1, remote.as_ptr() as u64, 1, 0]) +} + +/// The first pid a call succeeded against, or the last errno if none did. +fn sweep(pids: &[u32], f: impl Fn(u32) -> i64) -> Seen { + let mut last = -1; + for &pid in pids { + let rc = f(pid); + if rc >= 0 { + return Seen::Escaped(format!("pid {pid} answered {rc}")); + } + last = rc; + } + Seen::Refused(last) +} From 39d4452045cdc775d5c74603b44fd557792ec058 Mon Sep 17 00:00:00 2001 From: eKisNonos Date: Sat, 26 Sep 2026 23:32:27 +0000 Subject: [PATCH 044/244] linux_guests: a filesystem escape probe, and one suite for a boot fs aims every path shape Linux accepts at a file that exists outside /linux on every image: absolute, dot-dot from the root, relative and mid-path, an embedded NUL, openat from a root fd, chdir above the root, symlink and a rename across the root. Opening it at all is the escape. suite runs native, bounds, fs and a /proc sweep in one process, each still reporting under its own name, so one boot carries all of them. native and bounds move into the library so both binaries share them. --- userland/linux_guests/src/bin/bounds.rs | 41 +------------- userland/linux_guests/src/bin/fs.rs | 28 ++++++++++ userland/linux_guests/src/bin/native.rs | 51 ++--------------- userland/linux_guests/src/bin/suite.rs | 47 ++++++++++++++++ userland/linux_guests/src/bounds_probe.rs | 59 ++++++++++++++++++++ userland/linux_guests/src/fs_paths.rs | 43 +++++++++++++++ userland/linux_guests/src/fs_probe.rs | 66 ++++++++++++++++++++++ userland/linux_guests/src/lib.rs | 4 ++ userland/linux_guests/src/native_probe.rs | 67 +++++++++++++++++++++++ userland/linux_guests/src/sys.rs | 3 + 10 files changed, 324 insertions(+), 85 deletions(-) create mode 100644 userland/linux_guests/src/bin/fs.rs create mode 100644 userland/linux_guests/src/bin/suite.rs create mode 100644 userland/linux_guests/src/bounds_probe.rs create mode 100644 userland/linux_guests/src/fs_paths.rs create mode 100644 userland/linux_guests/src/fs_probe.rs create mode 100644 userland/linux_guests/src/native_probe.rs diff --git a/userland/linux_guests/src/bin/bounds.rs b/userland/linux_guests/src/bin/bounds.rs index fc23537074..6afb13d63b 100644 --- a/userland/linux_guests/src/bin/bounds.rs +++ b/userland/linux_guests/src/bin/bounds.rs @@ -15,49 +15,14 @@ // along with this program. If not, see . //! A guest asking for more than any plan should give it. -//! -//! Each request is one a bounded address-space plan refuses: honouring it -//! is the failure, not just crashing on it. A refusal must come back as an -//! errno to this process, with the machine still up to print the next line. use std::process::ExitCode; -use nonos_linux_guests::report::{Report, Seen}; -use nonos_linux_guests::sys::{call, BRK, MAP_FIXED, MAP_PRIVATE_ANON, MMAP, MPROTECT, PROT_RW}; - -const TIB: u64 = 1 << 40; -/// The first address of the kernel half on x86_64. -const KERNEL_HALF: u64 = 0xffff_8000_0000_0000; -/// No sane plan maps page zero; a guest that gets it can make null pointers -/// point somewhere. -const PAGE_ZERO: u64 = 0; +use nonos_linux_guests::bounds_probe; +use nonos_linux_guests::report::Report; fn main() -> ExitCode { let mut r = Report::new("bounds"); - let rc = call(MMAP, [0, TIB, PROT_RW, MAP_PRIVATE_ANON, u64::MAX, 0]); - r.check("mmap one TiB", granted(rc, "mapped a TiB")); - let fixed = MAP_PRIVATE_ANON | MAP_FIXED; - let rc = call(MMAP, [KERNEL_HALF, 4096, PROT_RW, fixed, u64::MAX, 0]); - r.check("mmap in the kernel half", granted(rc, "mapped the kernel half")); - let rc = call(MMAP, [PAGE_ZERO, 4096, PROT_RW, fixed, u64::MAX, 0]); - r.check("mmap page zero", granted(rc, "mapped page zero")); - let base = call(BRK, [0; 6]); - let rc = call(BRK, [(base as u64).wrapping_add(TIB), 0, 0, 0, 0, 0]); - // brk reports failure by returning the old break, not an errno. - let seen = match rc > base && base > 0 { - true => Seen::Escaped(format!("break moved by {:#x}", rc - base)), - false => Seen::Refused(-12), - }; - r.check("brk one TiB", seen); - let rc = call(MPROTECT, [KERNEL_HALF, 4096, PROT_RW, 0, 0, 0]); - r.check("mprotect the kernel half", granted(rc, "changed kernel protections")); + bounds_probe::scan(&mut r); r.finish() } - -/// A mapping call succeeded when it returned an address, not an errno. -fn granted(rc: i64, what: &str) -> Seen { - match rc { - rc if (-4095..0).contains(&rc) => Seen::Refused(rc), - rc => Seen::Escaped(format!("{what} at {rc:#x}")), - } -} diff --git a/userland/linux_guests/src/bin/fs.rs b/userland/linux_guests/src/bin/fs.rs new file mode 100644 index 0000000000..1642a4e281 --- /dev/null +++ b/userland/linux_guests/src/bin/fs.rs @@ -0,0 +1,28 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! A guest trying to leave its filesystem tree. + +use std::process::ExitCode; + +use nonos_linux_guests::fs_probe; +use nonos_linux_guests::report::Report; + +fn main() -> ExitCode { + let mut r = Report::new("fs"); + fs_probe::scan(&mut r); + r.finish() +} diff --git a/userland/linux_guests/src/bin/native.rs b/userland/linux_guests/src/bin/native.rs index 31dac11589..2a422efded 100644 --- a/userland/linux_guests/src/bin/native.rs +++ b/userland/linux_guests/src/bin/native.rs @@ -14,58 +14,15 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -//! A Linux guest reaching for the NØNOS native ABI. -//! -//! The guest holds no capabilities, and its syscalls are meant to reach the -//! personality and nothing else. So each native call here, with arguments -//! that would work for a capsule, must fail. One that returns success means -//! a Linux program can talk to ring 0 as a capsule does. +//! A guest reaching for the NØNOS native ABI. use std::process::ExitCode; -use nonos_linux_guests::arg::{p, pm, pu}; -use nonos_linux_guests::report::{Report, Seen}; -use nonos_linux_guests::sys::call; - -const fn tag4(b: &[u8; 4]) -> u64 { - (b[0] as u64) | ((b[1] as u64) << 8) | ((b[2] as u64) << 16) | ((b[3] as u64) << 24) -} +use nonos_linux_guests::native_probe; +use nonos_linux_guests::report::Report; fn main() -> ExitCode { let mut r = Report::new("native"); - let mut key = [0u8; 32]; - let label = b"guest/probe"; - let name = b"vfs_pool"; - let (mut port, mut pid) = (0u32, 0u32); - let msg = [0u8; 16]; - let token = [0u8; 32]; - let mut entries = [0u8; 256]; - let probes: [(&str, u64, [u64; 6]); 6] = [ - ("machine key", tag4(b"CMKY"), [p(label), label.len() as u64, pm(&mut key), 0, 0, 0]), - ( - "service lookup", - tag4(b"MSVL"), - [p(name), name.len() as u64, pu(&mut port), pu(&mut pid), 0, 0], - ), - ("ipc send", tag4(b"MISD"), [1, p(&msg), msg.len() as u64, 0, 0, 0]), - ( - "debug console", - tag4(b"MDBG"), - [p(b"[GUEST] native wrote the console\n"), 34, 0, 0, 0, 0], - ), - ("consent restore", tag4(b"MLCR"), [p(&token), 0, 0, 0, 0, 0]), - ("attest entries", tag4(b"MAEN"), [pm(&mut entries), entries.len() as u64, 0, 0, 0, 0]), - ]; - for (what, nr, args) in probes { - let rc = call(nr, args); - let seen = match rc { - rc if rc < 0 => Seen::Refused(rc), - rc => Seen::Escaped(format!("returned {rc}")), - }; - r.check(what, seen); - } - if key != [0u8; 32] { - r.check("machine key bytes", Seen::Escaped("the buffer was filled".into())); - } + native_probe::scan(&mut r); r.finish() } diff --git a/userland/linux_guests/src/bin/suite.rs b/userland/linux_guests/src/bin/suite.rs new file mode 100644 index 0000000000..64fc38be10 --- /dev/null +++ b/userland/linux_guests/src/bin/suite.rs @@ -0,0 +1,47 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! Every single-process probe, in one run. +//! +//! One boot then carries the evidence for all of them. Each probe still +//! reports under its own name, so a refusal in the log says which property it +//! belongs to. + +use std::process::ExitCode; + +use nonos_linux_guests::report::Report; +use nonos_linux_guests::{bounds_probe, fs_probe, native_probe, proc_probe}; + +fn main() -> ExitCode { + let mut broken = false; + for (guest, scan) in [ + ("native", native_probe::scan as fn(&mut Report)), + ("bounds", bounds_probe::scan), + ("fs", fs_probe::scan), + ("proc", proc_self), + ] { + let mut r = Report::new(guest); + scan(&mut r); + broken |= r.finish() != ExitCode::SUCCESS; + } + ExitCode::from(u8::from(broken)) +} + +/// /proc of other pids, without a sibling: nothing should open at all. +fn proc_self(r: &mut Report) { + let pids: Vec = (1..=256).collect(); + proc_probe::scan(r, &pids); +} diff --git a/userland/linux_guests/src/bounds_probe.rs b/userland/linux_guests/src/bounds_probe.rs new file mode 100644 index 0000000000..1168ff6ed2 --- /dev/null +++ b/userland/linux_guests/src/bounds_probe.rs @@ -0,0 +1,59 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! A guest asking for more than any plan should give it. +//! +//! Each request is one a bounded address-space plan refuses: honouring it +//! is the failure, not just crashing on it. A refusal must come back as an +//! errno to this process, with the machine still up to print the next line. + +use crate::report::{Report, Seen}; +use crate::sys::{call, BRK, MAP_FIXED, MAP_PRIVATE_ANON, MMAP, MPROTECT, PROT_RW}; + +const TIB: u64 = 1 << 40; +/// The first address of the kernel half on x86_64. +const KERNEL_HALF: u64 = 0xffff_8000_0000_0000; +/// No sane plan maps page zero; a guest that gets it can make null pointers +/// point somewhere. +const PAGE_ZERO: u64 = 0; + +pub fn scan(r: &mut Report) { + let rc = call(MMAP, [0, TIB, PROT_RW, MAP_PRIVATE_ANON, u64::MAX, 0]); + r.check("mmap one TiB", granted(rc, "mapped a TiB")); + let fixed = MAP_PRIVATE_ANON | MAP_FIXED; + let rc = call(MMAP, [KERNEL_HALF, 4096, PROT_RW, fixed, u64::MAX, 0]); + r.check("mmap in the kernel half", granted(rc, "mapped the kernel half")); + let rc = call(MMAP, [PAGE_ZERO, 4096, PROT_RW, fixed, u64::MAX, 0]); + r.check("mmap page zero", granted(rc, "mapped page zero")); + let base = call(BRK, [0; 6]); + let rc = call(BRK, [(base as u64).wrapping_add(TIB), 0, 0, 0, 0, 0]); + // brk reports failure by returning the old break, not an errno. + let seen = match rc > base && base > 0 { + true => Seen::Escaped(format!("break moved by {:#x}", rc - base)), + false => Seen::Refused(-12), + }; + r.check("brk one TiB", seen); + let rc = call(MPROTECT, [KERNEL_HALF, 4096, PROT_RW, 0, 0, 0]); + r.check("mprotect the kernel half", granted(rc, "changed kernel protections")); +} + +/// A mapping call succeeded when it returned an address, not an errno. +fn granted(rc: i64, what: &str) -> Seen { + match rc { + rc if (-4095..0).contains(&rc) => Seen::Refused(rc), + rc => Seen::Escaped(format!("{what} at {rc:#x}")), + } +} diff --git a/userland/linux_guests/src/fs_paths.rs b/userland/linux_guests/src/fs_paths.rs new file mode 100644 index 0000000000..65d7bd6de9 --- /dev/null +++ b/userland/linux_guests/src/fs_paths.rs @@ -0,0 +1,43 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! Plain opens of the outside file, one per path shape. + +use crate::fs_probe::{escaped, p, OUTSIDE, O_RDONLY}; +use crate::report::{Report, Seen}; +use crate::sys::{call, OPEN}; + +pub fn opens(r: &mut Report, up: &str) { + for (what, path) in [ + ("absolute", format!("/{OUTSIDE}\0")), + ("dot-dot from root", format!("/{up}{OUTSIDE}\0")), + ("dot-dot relative", format!("{up}{OUTSIDE}\0")), + ("dot-dot inside a path", format!("/bin/{up}{OUTSIDE}\0")), + // The personality reads C strings: whatever follows the NUL must not + // be what gets opened. + ("embedded NUL", format!("/nonexistent\0/{up}{OUTSIDE}\0")), + ] { + r.check(what, opened(call(OPEN, [p(&path), O_RDONLY, 0, 0, 0, 0]))); + } +} + +/// An errno is a refusal; a descriptor or a zero is the call going through. +pub fn opened(rc: i64) -> Seen { + match rc { + rc if rc < 0 => Seen::Refused(rc), + rc => escaped("call", rc), + } +} diff --git a/userland/linux_guests/src/fs_probe.rs b/userland/linux_guests/src/fs_probe.rs new file mode 100644 index 0000000000..d4ad5e7e44 --- /dev/null +++ b/userland/linux_guests/src/fs_probe.rs @@ -0,0 +1,66 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! Leaving the guest's tree by every path shape Linux accepts. +//! +//! The target is a file that exists outside /linux on every image. Opening it +//! at all is the escape; what the personality answers instead only has to be +//! an errno. + +use crate::fs_paths::opened; +use crate::report::{Report, Seen}; +use crate::sys::{call, AT_FDCWD, CHDIR, CLOSE, GETCWD, OPEN, OPENAT, RENAME, SYMLINK}; + +/// Outside the guest's tree on every store image this repo packs. +pub const OUTSIDE: &str = "capsules/std_proof.elf"; + +pub(crate) const O_RDONLY: u64 = 0; +const O_DIRECTORY: u64 = 0o200000; + +pub fn scan(r: &mut Report) { + let up = "../".repeat(16); + crate::fs_paths::opens(r, &up); + let root = call(OPEN, [p("/\0"), O_RDONLY | O_DIRECTORY, 0, 0, 0, 0]); + let rel = format!("{up}{OUTSIDE}\0"); + let rc = match root { + fd if fd >= 0 => call(OPENAT, [fd as u64, p(&rel), O_RDONLY, 0, 0, 0]), + e => e, + }; + r.check("openat from a root fd", opened(rc)); + let _ = call(CLOSE, [root as u64, 0, 0, 0, 0, 0]); + for _ in 0..16 { + let _ = call(CHDIR, [p("..\0"), 0, 0, 0, 0, 0]); + } + let mut cwd = [0u8; 64]; + let n = call(GETCWD, [cwd.as_mut_ptr() as u64, 64, 0, 0, 0, 0]); + let at_root = n > 0 && cwd.starts_with(b"/\0"); + r.check("chdir above root", if at_root { Seen::Refused(0) } else { escaped("cwd", n) }); + let rc = call(OPENAT, [AT_FDCWD as u64, p(&format!("{OUTSIDE}\0")), O_RDONLY, 0, 0, 0]); + r.check("relative after chdir", opened(rc)); + let rc = call(SYMLINK, [p("/\0"), p("/tmp/up\0"), 0, 0, 0, 0]); + r.check("symlink to root", opened(rc)); + let dest = format!("/{up}nonos/linux/apps/pwned\0"); + let rc = call(RENAME, [p("/tmp\0"), p(&dest), 0, 0, 0, 0]); + r.check("rename across the root", opened(rc)); +} + +pub(crate) fn p(s: &str) -> u64 { + s.as_ptr() as u64 +} + +pub(crate) fn escaped(what: &str, rc: i64) -> Seen { + Seen::Escaped(format!("{what} succeeded with {rc}")) +} diff --git a/userland/linux_guests/src/lib.rs b/userland/linux_guests/src/lib.rs index c11b591b84..cf56d26ef5 100644 --- a/userland/linux_guests/src/lib.rs +++ b/userland/linux_guests/src/lib.rs @@ -17,6 +17,10 @@ //! Shared pieces of the hostile guests. pub mod arg; +pub mod bounds_probe; +pub mod fs_paths; +pub mod fs_probe; +pub mod native_probe; pub mod proc_probe; pub mod report; pub mod sys; diff --git a/userland/linux_guests/src/native_probe.rs b/userland/linux_guests/src/native_probe.rs new file mode 100644 index 0000000000..08c87ecc68 --- /dev/null +++ b/userland/linux_guests/src/native_probe.rs @@ -0,0 +1,67 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! A Linux guest reaching for the NØNOS native ABI. +//! +//! The guest holds no capabilities, and its syscalls are meant to reach the +//! personality and nothing else. So each native call here, with arguments +//! that would work for a capsule, must fail. One that returns success means +//! a Linux program can talk to ring 0 as a capsule does. + +use crate::arg::{p, pm, pu}; +use crate::report::{Report, Seen}; +use crate::sys::call; + +const fn tag4(b: &[u8; 4]) -> u64 { + (b[0] as u64) | ((b[1] as u64) << 8) | ((b[2] as u64) << 16) | ((b[3] as u64) << 24) +} + +pub fn scan(r: &mut Report) { + let mut key = [0u8; 32]; + let label = b"guest/probe"; + let name = b"vfs_pool"; + let (mut port, mut pid) = (0u32, 0u32); + let msg = [0u8; 16]; + let token = [0u8; 32]; + let mut entries = [0u8; 256]; + let probes: [(&str, u64, [u64; 6]); 6] = [ + ("machine key", tag4(b"CMKY"), [p(label), label.len() as u64, pm(&mut key), 0, 0, 0]), + ( + "service lookup", + tag4(b"MSVL"), + [p(name), name.len() as u64, pu(&mut port), pu(&mut pid), 0, 0], + ), + ("ipc send", tag4(b"MISD"), [1, p(&msg), msg.len() as u64, 0, 0, 0]), + ( + "debug console", + tag4(b"MDBG"), + [p(b"[GUEST] native wrote the console\n"), 34, 0, 0, 0, 0], + ), + ("consent restore", tag4(b"MLCR"), [p(&token), 0, 0, 0, 0, 0]), + ("attest entries", tag4(b"MAEN"), [pm(&mut entries), entries.len() as u64, 0, 0, 0, 0]), + ]; + for (what, nr, args) in probes { + let rc = call(nr, args); + let seen = match rc { + rc if rc < 0 => Seen::Refused(rc), + rc => Seen::Escaped(format!("returned {rc}")), + }; + r.check(what, seen); + } + if key != [0u8; 32] { + r.check("machine key bytes", Seen::Escaped("the buffer was filled".into())); + } +} diff --git a/userland/linux_guests/src/sys.rs b/userland/linux_guests/src/sys.rs index 78c7a95ba7..cb78bf9021 100644 --- a/userland/linux_guests/src/sys.rs +++ b/userland/linux_guests/src/sys.rs @@ -30,7 +30,10 @@ pub const NANOSLEEP: u64 = 35; pub const GETPID: u64 = 39; pub const KILL: u64 = 62; pub const PTRACE: u64 = 101; +pub const CHDIR: u64 = 80; +pub const RENAME: u64 = 82; pub const SYMLINK: u64 = 88; +pub const GETCWD: u64 = 79; pub const OPENAT: u64 = 257; pub const PROCESS_VM_READV: u64 = 310; From cb412c6320c18932b22bb743898a4dc9d2c4281e Mon Sep 17 00:00:00 2001 From: eKisNonos Date: Sun, 27 Sep 2026 00:11:04 +0000 Subject: [PATCH 045/244] nonos-mk: take CAPSULE_MK_FILE --- nonos-mk | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nonos-mk b/nonos-mk index c5fdaf8baa..c451691557 160000 --- a/nonos-mk +++ b/nonos-mk @@ -1 +1 @@ -Subproject commit c5fdaf8baa1d3aac2557ed54da3d181a07e5dff0 +Subproject commit c451691557f9fcd9ff34aaa197518a827e4154bb From fdfe224cf266a0a7333001f5a9357cdef585ffa5 Mon Sep 17 00:00:00 2001 From: eKisNonos Date: Sun, 27 Sep 2026 00:11:04 +0000 Subject: [PATCH 046/244] store-pack: refuse a store vfs would refuse vfs loads at most 64 entries and 16 MiB of payload, and refuses the whole store past either, so the only sign at boot was an empty tree and "packages unavailable". A test image with guests and the sample films came to 18.5 MB and ran nothing it carried. The packer now checks both limits and writes nothing when either is crossed. --- tools/nonos-store-pack | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/tools/nonos-store-pack b/tools/nonos-store-pack index 4b6a25b233..ebd2c6cf4f 100755 --- a/tools/nonos-store-pack +++ b/tools/nonos-store-pack @@ -40,6 +40,12 @@ def parse_entries(specs): entries.append((raw, f.read())) return entries +# userland/capsule_vfs/src/blk/store_header.rs MAX_ENTRIES and +# store_toc.rs MAX_TOTAL_BYTES. +MAX_ENTRIES = 64 +MAX_PAYLOAD = 16 * 1024 * 1024 + + def main(): ap = argparse.ArgumentParser(description="pack the NONOS capsule store into a disk image") ap.add_argument("--image", required=True) @@ -51,6 +57,13 @@ def main(): if not os.path.isfile(args.image): die("image not found: %s" % args.image) base, entries = args.lba * SEC, parse_entries(args.entry) + # vfs refuses a whole store over either limit, and the only symptom at boot + # is an empty tree, so an image it would refuse is not written at all. + payload = sum(len(data) for _, data in entries) + if len(entries) > MAX_ENTRIES: + die("%d entries; vfs reads at most %d" % (len(entries), MAX_ENTRIES)) + if payload > MAX_PAYLOAD: + die("%d payload bytes; vfs loads at most %d" % (payload, MAX_PAYLOAD)) offs, cur = [], align(HDR + TOC * len(entries)) for _, data in entries: offs.append(cur) From 3201c77281f9ba2ba192c54c823b9ceda3dba598 Mon Sep 17 00:00:00 2001 From: eKisNonos Date: Sun, 27 Sep 2026 00:11:05 +0000 Subject: [PATCH 047/244] linux: log a path clamped at the root, and let the image name a guest Clamping `..` at /linux is what keeps a guest inside its tree, and it was silent: the path resolved, just not where the guest aimed. The first 16 clamps of a run are logged with the path the guest gave. With no argument, app.linux ran the built-in busybox and nothing else, so an image had no way to start a program of its own at boot. It now runs the program named in /linux/etc/nonos-boot-guest when the store has one. That grants nothing: the program is read from the store like any other and must carry a proof that verifies. --- .../capsule_linux/src/linux/boot_guest.rs | 44 +++++++++++++++++++ .../capsule_linux/src/linux/file/clamp.rs | 44 +++++++++++++++++++ userland/capsule_linux/src/linux/file/mod.rs | 1 + .../capsule_linux/src/linux/file/resolve.rs | 8 ++-- userland/capsule_linux/src/linux/mod.rs | 1 + userland/capsule_linux/src/linux/source.rs | 2 +- 6 files changed, 96 insertions(+), 4 deletions(-) create mode 100644 userland/capsule_linux/src/linux/boot_guest.rs create mode 100644 userland/capsule_linux/src/linux/file/clamp.rs diff --git a/userland/capsule_linux/src/linux/boot_guest.rs b/userland/capsule_linux/src/linux/boot_guest.rs new file mode 100644 index 0000000000..b841a88d12 --- /dev/null +++ b/userland/capsule_linux/src/linux/boot_guest.rs @@ -0,0 +1,44 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! A program the store image names to run when nothing else was asked for. +//! +//! A test image packs its guest and a one-line file naming it, so a boot runs +//! that guest rather than the built-in busybox. Naming a program grants +//! nothing: it is read from the store like any other, so it must carry a +//! proof that verifies, and a file naming an unproven one runs nothing. + +use alloc::vec::Vec; + +use crate::linux::file::{key, store_read, visible}; + +/// Guest-visible, so it lives under /linux like the program it names. +const BOOT_GUEST: &[u8] = b"/etc/nonos-boot-guest"; + +const MAX_NAME: u32 = 256; + +/// The path the image names and the program's bytes, or None when the image +/// names nothing. +pub(super) fn boot_guest(max_image: u32) -> Option<(Vec, Vec)> { + let named = store_read(&key(BOOT_GUEST), MAX_NAME).ok()?; + let path = named.split(|b| *b == b'\n' || *b == 0).next()?; + if path.first() != Some(&b'/') { + return None; + } + let at = visible(b"/", path); + let bytes = store_read(&key(&at), max_image).ok()?; + Some((at, bytes)) +} diff --git a/userland/capsule_linux/src/linux/file/clamp.rs b/userland/capsule_linux/src/linux/file/clamp.rs new file mode 100644 index 0000000000..fc848750aa --- /dev/null +++ b/userland/capsule_linux/src/linux/file/clamp.rs @@ -0,0 +1,44 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! Saying so when a guest's `..` meets the root. +//! +//! Clamping is what keeps the guest inside /linux, and it is silent by +//! nature: the path resolves, just not where the guest aimed. A program that +//! climbs above its root is either confused or trying to leave, and either +//! way the refusal belongs in the log. + +use core::sync::atomic::{AtomicU32, Ordering}; + +use nonos_libc::mk_debug; + +/// Enough to show an attempt; a guest looping on it cannot flood the console. +const LOGGED: u32 = 16; + +static SEEN: AtomicU32 = AtomicU32::new(0); + +pub(super) fn note(path: &[u8]) { + if SEEN.fetch_add(1, Ordering::Relaxed) >= LOGGED { + return; + } + let mut line = [0u8; 128]; + let head = b"[LINUX] refused: path above the root, clamped: "; + line[..head.len()].copy_from_slice(head); + let n = path.len().min(line.len() - head.len() - 1); + line[head.len()..head.len() + n].copy_from_slice(&path[..n]); + line[head.len() + n] = b'\n'; + let _ = mk_debug(line.as_ptr(), head.len() + n + 1); +} diff --git a/userland/capsule_linux/src/linux/file/mod.rs b/userland/capsule_linux/src/linux/file/mod.rs index 33c21506ea..fd16e766f8 100644 --- a/userland/capsule_linux/src/linux/file/mod.rs +++ b/userland/capsule_linux/src/linux/file/mod.rs @@ -17,6 +17,7 @@ //! The filesystem a guest sees. mod at; +mod clamp; pub(super) mod close; mod cstr; mod dir; diff --git a/userland/capsule_linux/src/linux/file/resolve.rs b/userland/capsule_linux/src/linux/file/resolve.rs index f8d2a71c59..b28689bb36 100644 --- a/userland/capsule_linux/src/linux/file/resolve.rs +++ b/userland/capsule_linux/src/linux/file/resolve.rs @@ -36,15 +36,17 @@ pub fn visible(cwd: &[u8], path: &[u8]) -> Vec { joined.extend_from_slice(path); let mut parts: Vec<&[u8]> = Vec::new(); + let mut clamped = false; for part in joined.split(|b| *b == b'/') { match part { b"" | b"." => {} - b".." => { - parts.pop(); - } + b".." => clamped |= parts.pop().is_none(), name => parts.push(name), } } + if clamped { + super::clamp::note(path); + } let mut out: Vec = Vec::new(); for part in parts { diff --git a/userland/capsule_linux/src/linux/mod.rs b/userland/capsule_linux/src/linux/mod.rs index cde2e2126d..d257525c52 100644 --- a/userland/capsule_linux/src/linux/mod.rs +++ b/userland/capsule_linux/src/linux/mod.rs @@ -22,6 +22,7 @@ mod attest; mod attest_local; mod attest_paths; mod attest_publisher; +mod boot_guest; mod call; mod env; mod file; diff --git a/userland/capsule_linux/src/linux/source.rs b/userland/capsule_linux/src/linux/source.rs index 335d15edd5..3e425b64b9 100644 --- a/userland/capsule_linux/src/linux/source.rs +++ b/userland/capsule_linux/src/linux/source.rs @@ -40,7 +40,7 @@ pub fn source() -> Option<(Vec, Vec, Origin)> { let bytes = store_read(&key(&path), MAX_IMAGE).ok()?; return Some((path, bytes, Origin::Store)); } - Some(match named() { + Some(match named().or_else(|| super::boot_guest::boot_guest(MAX_IMAGE)) { Some((path, bytes)) => (path, bytes, Origin::Store), None => (b"/bin/busybox".to_vec(), BUILT_IN.to_vec(), Origin::BuiltIn), }) From 508dc8dbb7ba761eecbb6ce3821c03de69a4c410 Mon Sep 17 00:00:00 2001 From: eKisNonos Date: Sun, 27 Sep 2026 00:11:31 +0000 Subject: [PATCH 048/244] linux_guests: sign, enrol and pack the guests for a test image MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NONOS_LINUX_GUESTS=1 builds each guest for musl and declares it through the capsule template, so it gets a NØNOS-ID certificate, a manifest and a place under the enrolled policy root. The personality then verifies a guest as it would any NØNOS-built Linux program; no check is weakened to let a test through. Each goes under /linux/bin with its proof beside it, and /linux/etc/nonos-boot-guest names the one the boot runs. Scratch trust only: publisher keys are minted on first use, and the makefile refuses to load without NONOS_DEV=1. The films are left out of such an image, since the store cannot hold both. bounds counts a fixed mapping as an escape only where it asked to land. On NØNOS, MAP_FIXED at page zero landed at 0x2000b000 instead: the guest did not get page zero, but MAP_FIXED's contract is exact address or failure, so the probe says so on its own line. --- mk/20-build.mk | 5 ++ mk/40-run.mk | 23 +++++-- userland/linux_guests/Guests.mk | 83 +++++++++++++++++++++++ userland/linux_guests/src/bounds_probe.rs | 20 +++++- 4 files changed, 121 insertions(+), 10 deletions(-) create mode 100644 userland/linux_guests/Guests.mk diff --git a/mk/20-build.mk b/mk/20-build.mk index 50c4d8a704..d367f56338 100644 --- a/mk/20-build.mk +++ b/mk/20-build.mk @@ -619,6 +619,11 @@ include userland/capsule_wallpaper/Capsule.mk include userland/capsule_attest/Capsule.mk include userland/capsule_power/Capsule.mk +# Hostile Linux guests, enrolled beside the capsules, for test images only. +ifeq ($(NONOS_LINUX_GUESTS),1) +include userland/linux_guests/Guests.mk +endif + # Orchestration helper: union of every verified capsule's artifact # triple. Smoke and test targets that need proof_io plus another # capsule depend on `$(proof-io_ARTIFACTS)` directly. diff --git a/mk/40-run.mk b/mk/40-run.mk index 4c67d22fa0..fff5a2e1e5 100644 --- a/mk/40-run.mk +++ b/mk/40-run.mk @@ -33,7 +33,20 @@ QEMU_BLK_STORE_STAMP := $(QEMU_BLK_IMG).store.stamp NONOS_MEDIA_DIR := media/samples NONOS_MEDIA_FILES := $(wildcard $(NONOS_MEDIA_DIR)/*) -$(QEMU_BLK_STORE_STAMP): $(std-proof_ARTIFACTS) $(gui_demo_ARTIFACTS) $(game_2048_ARTIFACTS) $(egui_proof_ARTIFACTS) tools/nonos-store-pack $(NONOS_MEDIA_FILES) | $(QEMU_BLK_IMG) +# The sample films fill most of the 16 MiB vfs loads. A guest-test image +# carries its guests instead, since the store cannot hold both. +ifneq ($(NONOS_LINUX_GUESTS),1) +NONOS_STORE_MEDIA_ENTRIES := \ + --entry /Movies/big_buck_bunny.avi=$(NONOS_MEDIA_DIR)/big_buck_bunny.avi \ + --entry /Movies/blender_reel_2013.mp4=$(NONOS_MEDIA_DIR)/blender_reel_2013.mp4 \ + --entry /Movies/caminandes_llamigos.avi=$(NONOS_MEDIA_DIR)/caminandes_llamigos.avi \ + --entry /Movies/elephants_dream.avi=$(NONOS_MEDIA_DIR)/elephants_dream.avi \ + --entry /Movies/sintel.avi=$(NONOS_MEDIA_DIR)/sintel.avi \ + --entry /Movies/tears_of_steel.avi=$(NONOS_MEDIA_DIR)/tears_of_steel.avi +endif + +# LINUX_GUEST_STORE_* are empty unless NONOS_LINUX_GUESTS=1 (userland/linux_guests/Guests.mk). +$(QEMU_BLK_STORE_STAMP): $(std-proof_ARTIFACTS) $(gui_demo_ARTIFACTS) $(game_2048_ARTIFACTS) $(egui_proof_ARTIFACTS) $(LINUX_GUEST_STORE_DEPS) tools/nonos-store-pack $(NONOS_MEDIA_FILES) | $(QEMU_BLK_IMG) @$(NONOS_PYTHON) tools/nonos-store-pack --image $(QEMU_BLK_IMG) --lba 256 \ --entry /capsules/std_proof.elf=$(std-proof_BIN) \ --entry /capsules/std_proof.nonos_id_cert.bin=$(std-proof_CERT) \ @@ -51,12 +64,8 @@ $(QEMU_BLK_STORE_STAMP): $(std-proof_ARTIFACTS) $(gui_demo_ARTIFACTS) $(game_204 --entry /capsules/egui_proof.nonos_id_cert.bin=$(egui_proof_CERT) \ --entry /capsules/egui_proof.manifest.bin=$(egui_proof_MANIFEST) \ --entry /capsules/egui_proof.zk_trailer.bin=$(egui_proof_ATTESTATION) \ - --entry /Movies/big_buck_bunny.avi=$(NONOS_MEDIA_DIR)/big_buck_bunny.avi \ - --entry /Movies/blender_reel_2013.mp4=$(NONOS_MEDIA_DIR)/blender_reel_2013.mp4 \ - --entry /Movies/caminandes_llamigos.avi=$(NONOS_MEDIA_DIR)/caminandes_llamigos.avi \ - --entry /Movies/elephants_dream.avi=$(NONOS_MEDIA_DIR)/elephants_dream.avi \ - --entry /Movies/sintel.avi=$(NONOS_MEDIA_DIR)/sintel.avi \ - --entry /Movies/tears_of_steel.avi=$(NONOS_MEDIA_DIR)/tears_of_steel.avi + $(NONOS_STORE_MEDIA_ENTRIES) \ + $(LINUX_GUEST_STORE_ENTRIES) @touch $@ # Declared in mk/20-build.mk; this only extends its prerequisites. diff --git a/userland/linux_guests/Guests.mk b/userland/linux_guests/Guests.mk new file mode 100644 index 0000000000..bca22916cd --- /dev/null +++ b/userland/linux_guests/Guests.mk @@ -0,0 +1,83 @@ +# Hostile Linux guests, signed and enrolled like capsules, for test images. +# +# NONOS_LINUX_GUESTS=1 builds each guest for musl, gives it a NØNOS-ID +# certificate and manifest through the same template a capsule uses, and so +# puts it under the enrolled policy root. The personality then verifies a +# guest exactly as it would any NØNOS-built Linux program: nothing here +# weakens a check to let a test through. +# +# Test images only: publisher keys are minted on first use, so this needs a +# scratch trust tree (NONOS_DEV=1). A guest holds no capabilities; its two +# endpoints are what a manifest must declare, and no guest registers them. + +ifneq ($(NONOS_DEV),1) +$(error NONOS_LINUX_GUESTS=1 mints scratch publisher keys and needs NONOS_DEV=1) +endif + +LINUX_GUESTS_DIR := userland/linux_guests +LINUX_GUESTS_TRIPLE := x86_64-unknown-linux-musl +LINUX_GUESTS_OUT := $(LINUX_GUESTS_DIR)/target/$(LINUX_GUESTS_TRIPLE)/release +LINUX_GUESTS_SRCS := $(shell find $(LINUX_GUESTS_DIR)/src -name '*.rs') \ + $(LINUX_GUESTS_DIR)/Cargo.toml $(LINUX_GUESTS_DIR)/Cargo.lock + +# Static and non-PIE, like the busybox the personality already runs. +$(LINUX_GUESTS_OUT)/%: $(LINUX_GUESTS_SRCS) + @echo "Building Linux guest $*..." + @cd $(LINUX_GUESTS_DIR) && RUSTUP_TOOLCHAIN=$(TOOLCHAIN) \ + RUSTFLAGS="-C target-feature=+crt-static -C relocation-model=static" \ + cargo build --release --target $(LINUX_GUESTS_TRIPLE) --bin $* + +# A publisher key per guest, minted into the scratch tree when missing. +$(NONOS_BAKED_TRUST_DIR)/keys/guest_%_publisher_ed25519.pub \ +$(NONOS_BAKED_TRUST_DIR)/keys/guest_%_publisher_mldsa65.pub: | $(CAPSULE_SIGN_BIN) + @mkdir -p .keys $(NONOS_BAKED_TRUST_DIR)/keys + @for alg in ed25519 mldsa65; do \ + $(CAPSULE_SIGN_BIN) keygen --alg $$alg --out .keys/guest_$*_publisher_$$alg && \ + chmod 600 .keys/guest_$*_publisher_$$alg.seed && \ + mv .keys/guest_$*_publisher_$$alg.pub $(NONOS_BAKED_TRUST_DIR)/keys/; \ + done + +# name, service port, reply port. The enrolled copy is named guest_, +# so its certificate and trailer cannot collide with a capsule's. +define LINUX_GUEST +CAPSULE_SLUG := linux-guest-$(1) +CAPSULE_HANDLE := linux.guest.$(1) +CAPSULE_DIR := $(LINUX_GUESTS_DIR) +CAPSULE_BIN_NAME := guest_$(1) +CAPSULE_DOMAIN := systems.nonos +CAPSULE_NAMESPACE := systems.nonos.linux.guest.$(1) +CAPSULE_TARGET := $(LINUX_GUESTS_TRIPLE) +CAPSULE_SERVICE_ENDPOINT := service:$(2):linux.guest.$(1) +CAPSULE_REPLY_ENDPOINT := reply:$(3):endpoint.linux.guest.$(1).reply +CAPSULE_REQUIRED_CAPS := 0x0 +CAPSULE_PREBUILT_BIN := $(LINUX_GUESTS_OUT)/$(1) +CAPSULE_MK_FILE := $(LINUX_GUESTS_DIR)/Guests.mk +CAPSULE_METADATA := NØNOS hostile Linux guest $(1) +include nonos-mk/capsule.mk +endef + +$(eval $(call LINUX_GUEST,suite,4950,4951)) +$(eval $(call LINUX_GUEST,holder,4952,4953)) +$(eval $(call LINUX_GUEST,reader,4954,4955)) + +LINUX_GUEST_SLUGS := linux-guest-suite linux-guest-holder linux-guest-reader +# The template checks keys exist; this makes the check wait for the mint. +$(foreach g,suite holder reader,$(eval nonos-mk-check-linux-guest-$(g)-keys: \ + $(NONOS_BAKED_TRUST_DIR)/keys/guest_$(g)_publisher_ed25519.pub \ + $(NONOS_BAKED_TRUST_DIR)/keys/guest_$(g)_publisher_mldsa65.pub)) + +# What the store image carries: each guest under /linux/bin with its proof +# beside it, and the file naming the one the boot instance runs. +LINUX_GUEST_BOOT ?= suite +LINUX_GUEST_BOOT_FILE := $(TARGET_DIR)/linux-guests/boot-$(LINUX_GUEST_BOOT) +$(LINUX_GUEST_BOOT_FILE): + @mkdir -p $(@D) && printf '/bin/%s\n' '$(LINUX_GUEST_BOOT)' > $@ + +LINUX_GUEST_STORE_ENTRIES := --entry /linux/etc/nonos-boot-guest=$(LINUX_GUEST_BOOT_FILE) \ + $(foreach s,$(LINUX_GUEST_SLUGS),\ + --entry /linux/bin/$(patsubst guest_%,%,$($(s)_BIN_NAME))=$($(s)_BIN) \ + --entry /linux/bin/$(patsubst guest_%,%,$($(s)_BIN_NAME)).nonos_id_cert.bin=$($(s)_CERT) \ + --entry /linux/bin/$(patsubst guest_%,%,$($(s)_BIN_NAME)).manifest.bin=$($(s)_MANIFEST) \ + --entry /linux/bin/$(patsubst guest_%,%,$($(s)_BIN_NAME)).zk_trailer.bin=$($(s)_ATTESTATION)) +LINUX_GUEST_STORE_DEPS := $(LINUX_GUEST_BOOT_FILE) \ + $(foreach s,$(LINUX_GUEST_SLUGS),$($(s)_ARTIFACTS) $($(s)_ATTESTATION)) diff --git a/userland/linux_guests/src/bounds_probe.rs b/userland/linux_guests/src/bounds_probe.rs index 1168ff6ed2..9e3c985737 100644 --- a/userland/linux_guests/src/bounds_probe.rs +++ b/userland/linux_guests/src/bounds_probe.rs @@ -21,7 +21,7 @@ //! errno to this process, with the machine still up to print the next line. use crate::report::{Report, Seen}; -use crate::sys::{call, BRK, MAP_FIXED, MAP_PRIVATE_ANON, MMAP, MPROTECT, PROT_RW}; +use crate::sys::{call, out, BRK, MAP_FIXED, MAP_PRIVATE_ANON, MMAP, MPROTECT, PROT_RW}; const TIB: u64 = 1 << 40; /// The first address of the kernel half on x86_64. @@ -35,9 +35,9 @@ pub fn scan(r: &mut Report) { r.check("mmap one TiB", granted(rc, "mapped a TiB")); let fixed = MAP_PRIVATE_ANON | MAP_FIXED; let rc = call(MMAP, [KERNEL_HALF, 4096, PROT_RW, fixed, u64::MAX, 0]); - r.check("mmap in the kernel half", granted(rc, "mapped the kernel half")); + r.check("mmap in the kernel half", landed(rc, KERNEL_HALF, "mapped the kernel half")); let rc = call(MMAP, [PAGE_ZERO, 4096, PROT_RW, fixed, u64::MAX, 0]); - r.check("mmap page zero", granted(rc, "mapped page zero")); + r.check("mmap page zero", landed(rc, PAGE_ZERO, "mapped page zero")); let base = call(BRK, [0; 6]); let rc = call(BRK, [(base as u64).wrapping_add(TIB), 0, 0, 0, 0, 0]); // brk reports failure by returning the old break, not an errno. @@ -50,6 +50,20 @@ pub fn scan(r: &mut Report) { r.check("mprotect the kernel half", granted(rc, "changed kernel protections")); } +/// A fixed mapping escaped only if it landed where it asked. Landing anywhere +/// else breaks MAP_FIXED's contract, which is a bug worth a line, but the +/// guest did not get the address it was after. +fn landed(rc: i64, want: u64, what: &str) -> Seen { + match rc { + rc if rc as u64 == want => Seen::Escaped(format!("{what} at {rc:#x}")), + rc if (-4095..0).contains(&rc) => Seen::Refused(rc), + rc => { + out(format!("[GUEST] bounds note: MAP_FIXED {want:#x} landed at {rc:#x}\n").as_bytes()); + Seen::Refused(0) + } + } +} + /// A mapping call succeeded when it returned an address, not an errno. fn granted(rc: i64, what: &str) -> Seen { match rc { From 5b29232506f7d2c863acc375a64e6730492b1e91 Mon Sep 17 00:00:00 2001 From: eKisNonos Date: Sun, 27 Sep 2026 00:11:44 +0000 Subject: [PATCH 049/244] tcb: count ring 0 from what rustc compiled, and gate its growth "Small TCB" is what the design rests on, and nothing measured it. A count of the tree includes three architectures and modules no profile builds, so tools/nonos-tcb reads the kernel library's dep-info and counts the code lines of the files rustc actually read, file by file. nonos-verify build runs it after its microkernel-capsules kernel against nonos-ci/baselines/tcb-x86_64-capsules.txt, main's 132664, and fails if ring 0 has grown. `make nonos-mk-tcb` runs the same check by hand. This branch is over that budget today: the production kernel measures 133235 against main's 132739. --- mk/40-run.mk | 7 ++ nonos-ci | 2 +- nonos-verify/src/build.rs | 15 ++++ tools/nonos-tcb | 139 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 162 insertions(+), 1 deletion(-) create mode 100755 tools/nonos-tcb diff --git a/mk/40-run.mk b/mk/40-run.mk index fff5a2e1e5..3577c786c5 100644 --- a/mk/40-run.mk +++ b/mk/40-run.mk @@ -343,6 +343,13 @@ nonos-mk-check-caps: nonos-mk-static: nonos-mk-check-caps @./nonos-ci/run-static-checks.sh +# Ring 0's size against its budget, from the kernel the last build produced. +# Run after `nonos-mk-capsules`; TCB_BUDGET picks another profile's file. +TCB_BUDGET ?= nonos-ci/baselines/tcb-x86_64-capsules.txt +.PHONY: nonos-mk-tcb +nonos-mk-tcb: + @$(NONOS_PYTHON) tools/nonos-tcb --by-module --baseline $(TCB_BUDGET) + MICROKERNEL_BIN := $(TARGET_DIR)/x86_64-nonos/release/nonos-kernel # Patterns are matched against demangled `nm` output, so each entry is diff --git a/nonos-ci b/nonos-ci index 1f05aa7aed..d68c5a2da3 160000 --- a/nonos-ci +++ b/nonos-ci @@ -1 +1 @@ -Subproject commit 1f05aa7aedc4dc7fce7c9c1e2712a52aff3e00ac +Subproject commit d68c5a2da30ccf3f9572c64270e3d95cf4565e31 diff --git a/nonos-verify/src/build.rs b/nonos-verify/src/build.rs index 4e7ec98c14..c55d600daa 100644 --- a/nonos-verify/src/build.rs +++ b/nonos-verify/src/build.rs @@ -41,6 +41,21 @@ pub fn run(root: &str) -> std::io::Result { let ok = run_logged("make", &["nonos-mk-capsules"], &out.join("build-x86_64.txt")); rpt.check("build-x86_64-capsules", st(ok), "make nonos-mk-capsules"); + // What ring 0 is, from the dep-info of the kernel just built. It may not + // grow past its budget; a PR that raises the budget has to say why. + let tcb = [ + "tools/nonos-tcb", + "--by-module", + "--baseline", + "nonos-ci/baselines/tcb-x86_64-capsules.txt", + ]; + let ok = run_logged("python3", &tcb, &out.join("tcb-budget.txt")); + rpt.check( + "tcb-budget", + st(ok), + "ring 0 lines within nonos-ci/baselines/tcb-x86_64-capsules.txt", + ); + let kbin = "target/x86_64-nonos/release/nonos-kernel"; if Path::new(kbin).exists() { let (_, sz) = capture("size", &[kbin]); diff --git a/tools/nonos-tcb b/tools/nonos-tcb new file mode 100755 index 0000000000..e0d16c9a23 --- /dev/null +++ b/tools/nonos-tcb @@ -0,0 +1,139 @@ +#!/usr/bin/env python3 +# NONOS Operating System +# Copyright (C) 2026 NONOS Contributors +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +"""Count what runs in ring 0, and refuse to let it grow. + +"Small TCB" is the claim the design rests on, and nothing measured it. A +count of the source tree says little: it holds three architectures, test +modules and whole subsystems no profile compiles. What the kernel actually +is comes from the compiler, so the files counted here are the ones rustc's +dep-info for the kernel library says it read. A file inside that set is +counted whole, including anything a `cfg` removes from it, so the number is +an upper bound at file granularity. + +Generated files under OUT_DIR and `include_bytes!` payloads are not source +anyone reviews in the tree and are left out; the count is Rust under src/. +A line counts when it is neither blank nor a comment. +""" + +import argparse +import sys +from collections import Counter +from pathlib import Path + + +def depinfo_sources(depinfo, root): + """The .rs files under root/src that the dep-info lists.""" + text = depinfo.read_text() + first = text.split("\n", 1)[0] + _, _, deps = first.partition(": ") + src = (root / "src").resolve() + out = set() + # Paths with spaces are escaped with a backslash; the kernel has none, so + # a plain split is exact here and anything odd is refused below. + for dep in deps.split(): + p = Path(dep) + if not p.is_absolute(): + p = root / p + p = p.resolve() + if p.suffix == ".rs" and src in p.parents: + out.add(p) + return sorted(out) + + +def code_lines(path): + """Lines that are neither blank nor comment. Block comments may nest in + Rust; the tree does not nest them, and a nested one only miscounts the + lines inside it.""" + n = 0 + in_block = False + for raw in path.read_text(errors="replace").splitlines(): + s = raw.strip() + if in_block: + if "*/" in s: + in_block = False + continue + if not s or s.startswith("//"): + continue + if s.startswith("/*"): + in_block = "*/" not in s + continue + n += 1 + return n + + +def newest_depinfo(target): + found = [] + for d in target.glob("nonos_kernel-*.d"): + # The library's dep-info lists every module; the binary's lists two. + if len(d.read_text().split("\n", 1)[0].split()) > 64: + found.append(d) + if not found: + return None + return max(found, key=lambda d: d.stat().st_mtime) + + +def main(): + ap = argparse.ArgumentParser(description=__doc__.split("\n", 1)[0]) + ap.add_argument("--root", type=Path, default=Path("."), help="repository root") + ap.add_argument("--depinfo", type=Path, help="kernel library dep-info (.d); default: newest") + ap.add_argument("--target-deps", type=Path, default=Path("target/x86_64-nonos/release/deps")) + ap.add_argument("--baseline", type=Path, help="fail when the count exceeds this file's number") + ap.add_argument("--by-module", action="store_true", help="print the count per top-level module") + a = ap.parse_args() + + root = a.root.resolve() + depinfo = a.depinfo or newest_depinfo(root / a.target_deps) + if depinfo is None or not depinfo.is_file(): + print("nonos-tcb: no kernel dep-info; build the kernel first", file=sys.stderr) + return 2 + files = depinfo_sources(depinfo, root) + if not files: + print(f"nonos-tcb: {depinfo} lists no kernel sources", file=sys.stderr) + return 2 + + per = Counter() + total = 0 + for f in files: + n = code_lines(f) + total += n + rel = f.relative_to(root / "src") + per[rel.parts[0] if len(rel.parts) > 1 else "(root)"] += n + + print(f"[tcb] {len(files)} files, {total} lines of ring 0 code ({depinfo.name})") + if a.by_module: + for name, n in per.most_common(): + print(f"[tcb] {name:24} {n:7}") + + if a.baseline: + want = a.baseline.read_text().strip() + if not want.isdigit(): + print(f"nonos-tcb: baseline {a.baseline} is not an integer: {want!r}", file=sys.stderr) + return 2 + budget = int(want) + print(f"[tcb] budget {budget}, delta {total - budget:+d}") + if total > budget: + print( + f"::error::ring 0 grew past its budget: {total} > {budget}. " + "Move the code out of the kernel, or justify raising the budget in the PR.", + file=sys.stderr, + ) + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) From 47f90aa6cd3a5ec5c21476b6d767d1d43558a17e Mon Sep 17 00:00:00 2001 From: eKisNonos Date: Sun, 27 Sep 2026 00:13:26 +0000 Subject: [PATCH 050/244] linux_guests: say whether a forked child runs A shell forks for every command, so a personality whose children never run cannot host one. The suite ends with a fork whose child exits 7, waited for with WNOHANG for ten seconds, so a child that never runs is reported instead of hanging the suite. --- userland/linux_guests/src/bin/suite.rs | 4 +- userland/linux_guests/src/lib.rs | 1 + userland/linux_guests/src/life_probe.rs | 53 +++++++++++++++++++++++++ 3 files changed, 57 insertions(+), 1 deletion(-) create mode 100644 userland/linux_guests/src/life_probe.rs diff --git a/userland/linux_guests/src/bin/suite.rs b/userland/linux_guests/src/bin/suite.rs index 64fc38be10..1265d38306 100644 --- a/userland/linux_guests/src/bin/suite.rs +++ b/userland/linux_guests/src/bin/suite.rs @@ -23,7 +23,7 @@ use std::process::ExitCode; use nonos_linux_guests::report::Report; -use nonos_linux_guests::{bounds_probe, fs_probe, native_probe, proc_probe}; +use nonos_linux_guests::{bounds_probe, fs_probe, life_probe, native_probe, proc_probe}; fn main() -> ExitCode { let mut broken = false; @@ -37,6 +37,8 @@ fn main() -> ExitCode { scan(&mut r); broken |= r.finish() != ExitCode::SUCCESS; } + // Last, since a personality that loses the child may lose this process. + life_probe::run(); ExitCode::from(u8::from(broken)) } diff --git a/userland/linux_guests/src/lib.rs b/userland/linux_guests/src/lib.rs index cf56d26ef5..b1c99eb4a7 100644 --- a/userland/linux_guests/src/lib.rs +++ b/userland/linux_guests/src/lib.rs @@ -20,6 +20,7 @@ pub mod arg; pub mod bounds_probe; pub mod fs_paths; pub mod fs_probe; +pub mod life_probe; pub mod native_probe; pub mod proc_probe; pub mod report; diff --git a/userland/linux_guests/src/life_probe.rs b/userland/linux_guests/src/life_probe.rs new file mode 100644 index 0000000000..c7fd27e438 --- /dev/null +++ b/userland/linux_guests/src/life_probe.rs @@ -0,0 +1,53 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! Whether a guest can make a child and hear back from it. +//! +//! Not an isolation property: a shell forks for every command it runs, so a +//! personality whose children never run cannot host one. The wait is +//! bounded, so a child that never runs is reported instead of hanging here. + +use crate::sys::{call, out, NANOSLEEP}; + +const FORK: u64 = 57; +const WAIT4: u64 = 61; +const EXIT_GROUP: u64 = 231; +const WNOHANG: u64 = 1; +const CHILD_STATUS: u64 = 7; +const WAIT_SECS: u32 = 10; + +pub fn run() { + let pid = call(FORK, [0; 6]); + if pid == 0 { + let _ = call(EXIT_GROUP, [CHILD_STATUS, 0, 0, 0, 0, 0]); + } + if pid < 0 { + out(format!("[GUEST] life fork failed: errno={}\n", -pid).as_bytes()); + return; + } + let mut status = 0i32; + for _ in 0..WAIT_SECS * 10 { + let rc = call(WAIT4, [pid as u64, &mut status as *mut i32 as u64, WNOHANG, 0, 0, 0]); + if rc == pid { + let code = (status >> 8) & 0xff; + out(format!("[GUEST] life fork: child {pid} exited {code}\n").as_bytes()); + return; + } + let tenth = [0u64, 100_000_000]; + let _ = call(NANOSLEEP, [tenth.as_ptr() as u64, 0, 0, 0, 0, 0]); + } + out(format!("[GUEST] life fork: child {pid} did not finish in {WAIT_SECS}s\n").as_bytes()); +} From f81e3c2cb066f7202cc9c7f29f22eed0cec4ad41 Mon Sep 17 00:00:00 2001 From: eKisNonos Date: Sun, 27 Sep 2026 00:47:51 +0000 Subject: [PATCH 051/244] linux: host a family of processes, so a forked child runs fork made the child and resumed it, but the serve loop answered only the guest it started and that guest's threads, so the child's first syscall was claimed and never answered. The hostile suite's fork waited ten seconds for a child that never ran. A shell forks for every command. The loop now holds one Guest per process. A fork builds the child's own state from the parent's (dup'd descriptors that keep close-on-exec, the break, the mapping plan, the cwd; no display), and hands it to the loop before the child runs. Pipe buffers belong to the family, since a pipe made before a fork has ends in both processes; they are lent to the guest being answered. exit and exit_group are no longer answered: the family ends the process, so it cannot run on past its own exit. wait4 gets the real exit code, parks until a child ends instead of answering EAGAIN, and answers 0 under WNOHANG while children still run. Not yet: an empty pipe still answers EAGAIN rather than blocking or reporting end of file. --- userland/capsule_linux/src/linux/call/mod.rs | 2 +- .../src/linux/call/spawn/fork.rs | 6 ++ .../capsule_linux/src/linux/call/spawn/mod.rs | 2 +- .../src/linux/call/spawn/wait.rs | 42 +++++++----- .../src/linux/guest/fork_state.rs | 50 ++++++++++++++ .../capsule_linux/src/linux/guest/handle.rs | 6 ++ .../src/linux/guest/handle_new.rs | 3 + userland/capsule_linux/src/linux/guest/mod.rs | 1 + .../capsule_linux/src/linux/serve/dispatch.rs | 10 ++- .../capsule_linux/src/linux/serve/family.rs | 66 +++++++++++++++++++ .../src/linux/serve/family_reap.rs | 50 ++++++++++++++ .../src/linux/serve/loop_impl.rs | 23 +++---- userland/capsule_linux/src/linux/serve/mod.rs | 2 + userland/capsule_linux/src/linux/start.rs | 2 +- 14 files changed, 228 insertions(+), 37 deletions(-) create mode 100644 userland/capsule_linux/src/linux/guest/fork_state.rs create mode 100644 userland/capsule_linux/src/linux/serve/family.rs create mode 100644 userland/capsule_linux/src/linux/serve/family_reap.rs diff --git a/userland/capsule_linux/src/linux/call/mod.rs b/userland/capsule_linux/src/linux/call/mod.rs index 23894c0e09..88dbeaa20a 100644 --- a/userland/capsule_linux/src/linux/call/mod.rs +++ b/userland/capsule_linux/src/linux/call/mod.rs @@ -60,7 +60,7 @@ pub use session::{getpgid, getsid, setpgid, setsid}; pub use signal::{rt_sigaction, rt_sigprocmask, sigaltstack}; pub use signal_send::kill; pub use sleep::nanosleep; -pub use spawn::{clone, execve, fork, wait4}; +pub use spawn::{clone, execve, fork, reap_one, wait4}; pub use thread::{arch_prctl, clock_gettime, getrandom}; pub use timeops::{gettimeofday, time}; pub use umask::{umask, DEFAULT_UMASK}; diff --git a/userland/capsule_linux/src/linux/call/spawn/fork.rs b/userland/capsule_linux/src/linux/call/spawn/fork.rs index e2b8efeaa9..18bc448fc0 100644 --- a/userland/capsule_linux/src/linux/call/spawn/fork.rs +++ b/userland/capsule_linux/src/linux/call/spawn/fork.rs @@ -33,7 +33,13 @@ pub fn fork(guest: &mut Guest) -> Answer { if !copy_spans(guest, child) { return Answer::value(errno::fail(errno::ENOMEM)); } + /* + * The child's state goes to the serve loop before the child runs, so its + * first trap finds a guest that owns it. + */ + guest.forked.push(guest.fork_state(child)); if mk_foreign_resume(child) < 0 { + guest.forked.pop(); return Answer::value(errno::fail(errno::ENOMEM)); } guest.children.push(child); diff --git a/userland/capsule_linux/src/linux/call/spawn/mod.rs b/userland/capsule_linux/src/linux/call/spawn/mod.rs index 7946a83e89..8369b1db46 100644 --- a/userland/capsule_linux/src/linux/call/spawn/mod.rs +++ b/userland/capsule_linux/src/linux/call/spawn/mod.rs @@ -31,4 +31,4 @@ mod wait; pub use clone::clone; pub use exec::execve; pub use fork::fork; -pub use wait::wait4; +pub use wait::{reap_one, wait4}; diff --git a/userland/capsule_linux/src/linux/call/spawn/wait.rs b/userland/capsule_linux/src/linux/call/spawn/wait.rs index 7b87d44809..9b518d8e08 100644 --- a/userland/capsule_linux/src/linux/call/spawn/wait.rs +++ b/userland/capsule_linux/src/linux/call/spawn/wait.rs @@ -14,9 +14,7 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -//! `wait4`: which of this guest's children has ended. - -use nonos_libc::mk_pid_alive; +//! `wait4`: a child that has ended, or a wait until one does. use crate::linux::abi::errno; use crate::linux::guest::Guest; @@ -25,24 +23,32 @@ use crate::linux::serve::Answer; /// Set by a caller that will not wait. const WNOHANG: u64 = 1; -pub fn wait4(guest: &mut Guest, want: u64, status: u64, flags: u64) -> Answer { +pub fn wait4(guest: &mut Guest, want: u64, status: u64, flags: u64, tid: u32) -> Answer { if guest.children.is_empty() { return Answer::value(errno::fail(errno::ECHILD)); } - let gone = guest.children.iter().copied().find(|pid| { - (want as i64) <= 0 || want as u32 == *pid - }).filter(|pid| !mk_pid_alive(*pid)); - let Some(pid) = gone else { - let _ = flags & WNOHANG; - return Answer::value(errno::fail(errno::EAGAIN)); - }; + if let Some(v) = reap_one(guest, want, status) { + return Answer::value(v); + } + // A child still running under WNOHANG is a zero, not an error. + if flags & WNOHANG != 0 { + return Answer::value(errno::ok(0)); + } + guest.waiting = Some((want, status, tid)); + Answer::Park +} + +/// Take one ended child the caller asked about, write its status, and give +/// the answer wait4 returns. None while no such child has ended. +pub fn reap_one(guest: &mut Guest, want: u64, status: u64) -> Option { + let any = (want as i64) <= 0; + let at = guest.ended.iter().position(|(pid, _)| any || *pid == want as u32)?; + let (pid, code) = guest.ended.remove(at); guest.children.retain(|p| *p != pid); - /* - * The exit code a guest passed to exit is not readable from here: the - * kernel records it and nothing hands it back. - */ - if status != 0 && guest.write(status, &0u32.to_le_bytes()) < 4 { - return Answer::value(errno::fail(errno::EFAULT)); + // An exit status sits in the second byte, as WEXITSTATUS reads it. + let word = ((code as u32) & 0xff) << 8; + if status != 0 && guest.write(status, &word.to_le_bytes()) < 4 { + return Some(errno::fail(errno::EFAULT)); } - Answer::value(errno::ok(pid as u64)) + Some(errno::ok(pid as u64)) } diff --git a/userland/capsule_linux/src/linux/guest/fork_state.rs b/userland/capsule_linux/src/linux/guest/fork_state.rs new file mode 100644 index 0000000000..557befe470 --- /dev/null +++ b/userland/capsule_linux/src/linux/guest/fork_state.rs @@ -0,0 +1,50 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! What a forked child starts with. +//! +//! A fork is a second process, so the child gets its own copy of what this +//! personality tracks for one: the descriptor table, the break, the mapping +//! plan and what it covers, the cwd, the thread pointer. Descriptors are +//! dup'd, sharing what they name as Linux shares an open file. Pipe buffers +//! are not here: they belong to the family, so both ends of a fork see one. +//! The display is not inherited; a child that wants a window connects. + +use alloc::vec::Vec; + +use super::fd::Fd; +use super::handle::Guest; + +impl Guest { + pub fn fork_state(&self, child: u32) -> Guest { + let mut g = Guest::new(child); + g.brk = self.brk; + g.mmap_next = self.mmap_next; + // dup clears close-on-exec; fork keeps it, and exec is where it counts. + g.fds = self.fds.iter().map(|f| Fd { cloexec: f.cloexec, ..Fd::clone_of(f) }).collect(); + g.regions = self.regions.clone(); + g.pipes = Vec::new(); + g.handlers = self.handlers; + g.cwd = self.cwd.clone(); + g.automap = self.automap.clone(); + g.fs_base = self.fs_base; + g.parent = self.parent; + g.pgid = self.pgid; + g.sid = self.sid; + g.umask = self.umask; + g + } +} diff --git a/userland/capsule_linux/src/linux/guest/handle.rs b/userland/capsule_linux/src/linux/guest/handle.rs index 62bc60b465..3eeead2b5e 100644 --- a/userland/capsule_linux/src/linux/guest/handle.rs +++ b/userland/capsule_linux/src/linux/guest/handle.rs @@ -63,4 +63,10 @@ pub struct Guest { /// Remembered, not enforced: the store does not apply it when it creates a /// file. pub umask: u16, + /// Children forked while answering, for the serve loop to adopt. + pub forked: Vec, + /// Children that have ended, with their exit codes, until waited for. + pub ended: Vec<(u32, i32)>, + /// A parked wait4: the pid it wants, where the status goes, the caller. + pub waiting: Option<(u64, u64, u32)>, } diff --git a/userland/capsule_linux/src/linux/guest/handle_new.rs b/userland/capsule_linux/src/linux/guest/handle_new.rs index 91602fc326..75741f64e5 100644 --- a/userland/capsule_linux/src/linux/guest/handle_new.rs +++ b/userland/capsule_linux/src/linux/guest/handle_new.rs @@ -50,6 +50,9 @@ impl Guest { pgid: pid, sid: pid, umask: crate::linux::call::DEFAULT_UMASK, + forked: Vec::new(), + ended: Vec::new(), + waiting: None, } } } diff --git a/userland/capsule_linux/src/linux/guest/mod.rs b/userland/capsule_linux/src/linux/guest/mod.rs index 19f20d91c8..fae201c67a 100644 --- a/userland/capsule_linux/src/linux/guest/mod.rs +++ b/userland/capsule_linux/src/linux/guest/mod.rs @@ -22,6 +22,7 @@ mod fd_dup; mod fd_empty; mod fd_kind; mod fd_make; +mod fork_state; mod handle; mod handle_new; mod layout; diff --git a/userland/capsule_linux/src/linux/serve/dispatch.rs b/userland/capsule_linux/src/linux/serve/dispatch.rs index 67226625ec..28615806d4 100644 --- a/userland/capsule_linux/src/linux/serve/dispatch.rs +++ b/userland/capsule_linux/src/linux/serve/dispatch.rs @@ -14,8 +14,7 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . - -//! One refused call, answered. The two that can leave a caller parked are +//! One refused call, answered. The ones that can leave a caller parked are //! taken first; everything else is a plain value. use nonos_libc::ForeignFrame; @@ -32,9 +31,14 @@ pub fn answer(guest: &mut Guest, frame: &ForeignFrame) -> Answer { nr::CLONE => clone(guest, frame), nr::FORK | nr::VFORK => crate::linux::call::fork(guest), nr::EXECVE => crate::linux::call::execve(guest, frame.pid, a[0], a[1], a[2]), - nr::WAIT4 => crate::linux::call::wait4(guest, a[0], a[1], a[2]), + nr::WAIT4 => crate::linux::call::wait4(guest, a[0], a[1], a[2], frame.pid), // A thread exiting is not the process exiting. nr::EXIT if frame.pid != guest.pid => Answer::Reply(exit_thread(guest, frame.pid)), + // Never answered: the family ends the process, so it cannot run on. + nr::EXIT | nr::EXIT_GROUP => { + let _ = crate::linux::call::exit(guest, a[0]); + Answer::Park + } nr::FUTEX => futex(guest, frame.pid, a[0], a[1], a[2]), other => Answer::Reply(plain(guest, frame.pid, other, a)), } diff --git a/userland/capsule_linux/src/linux/serve/family.rs b/userland/capsule_linux/src/linux/serve/family.rs new file mode 100644 index 0000000000..f1f6e8ae40 --- /dev/null +++ b/userland/capsule_linux/src/linux/serve/family.rs @@ -0,0 +1,66 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! Every process this personality hosts: the guest it started, and whatever +//! that guest forks. +//! +//! Each has its own state, so a child's descriptors, break and cwd are its +//! own. Pipe buffers are the family's, since a pipe opened before a fork has +//! a reader and a writer in different processes; they are lent to the guest +//! being answered and taken back after. + +use alloc::vec::Vec; +use core::mem; + +use nonos_libc::{mk_foreign_reply, ForeignFrame}; + +use super::answer::Answer; +use super::dispatch::answer; +use crate::linux::guest::Guest; + +pub struct Family { + pub(super) guests: Vec, + pub(super) pipes: Vec>, + pub(super) root: u32, + pub(super) root_code: i32, +} + +impl Family { + pub fn new(mut first: Guest) -> Self { + let pipes = mem::take(&mut first.pipes); + let root = first.pid; + Family { guests: alloc::vec![first], pipes, root, root_code: 0 } + } + + pub fn answer(&mut self, frame: &ForeignFrame) { + let Some(g) = self.guests.iter_mut().find(|g| g.owns(frame.pid)) else { + return; + }; + mem::swap(&mut self.pipes, &mut g.pipes); + let got = answer(g, frame); + mem::swap(&mut self.pipes, &mut g.pipes); + let born = mem::take(&mut g.forked); + if let Answer::Reply(value) = got { + let _ = mk_foreign_reply(frame.pid, value); + } + self.guests.extend(born); + } + + /// Done once nothing it hosts is left; the code is the first guest's. + pub fn done(&self) -> Option { + self.guests.is_empty().then_some(self.root_code) + } +} diff --git a/userland/capsule_linux/src/linux/serve/family_reap.rs b/userland/capsule_linux/src/linux/serve/family_reap.rs new file mode 100644 index 0000000000..8dee62fb90 --- /dev/null +++ b/userland/capsule_linux/src/linux/serve/family_reap.rs @@ -0,0 +1,50 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! Ending what has exited, and telling each parent. + +use nonos_libc::{mk_foreign_reply, mk_kill}; + +use super::family::Family; +use crate::linux::call::reap_one; + +const SIGKILL: u64 = 9; + +impl Family { + /// End every process that asked to, and tell its parent. + pub fn reap(&mut self) { + while let Some(i) = self.guests.iter().position(|g| g.exited.is_some()) { + let gone = self.guests.remove(i); + let code = gone.exited.unwrap_or(0); + for tid in gone.threads.iter().chain([gone.pid].iter()) { + let _ = mk_kill(*tid as u64, SIGKILL); + } + if gone.pid == self.root { + self.root_code = code; + } + let Some(p) = self.guests.iter_mut().find(|g| g.children.contains(&gone.pid)) else { + continue; + }; + p.ended.push((gone.pid, code)); + if let Some((want, status, tid)) = p.waiting { + if let Some(value) = reap_one(p, want, status) { + p.waiting = None; + let _ = mk_foreign_reply(tid, value); + } + } + } + } +} diff --git a/userland/capsule_linux/src/linux/serve/loop_impl.rs b/userland/capsule_linux/src/linux/serve/loop_impl.rs index ef9e7bd449..8f0e71e0d2 100644 --- a/userland/capsule_linux/src/linux/serve/loop_impl.rs +++ b/userland/capsule_linux/src/linux/serve/loop_impl.rs @@ -14,29 +14,26 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . +//! The service loop: take a trap from any process or thread the family +//! hosts, answer it or leave the caller parked, and end what has exited. -//! The service loop: take a trap from any thread of the guest, answer it -//! or leave the caller parked. +use nonos_libc::{mk_foreign_wait, ForeignFrame}; -use nonos_libc::{mk_foreign_reply, mk_foreign_wait, ForeignFrame}; - -use super::answer::Answer; -use super::dispatch::answer; +use super::family::Family; use crate::linux::guest::Guest; /// How long one wait blocks before looking at the guest again. const WAIT_MS: u64 = 250; -pub fn serve(guest: &mut Guest) -> i32 { +pub fn serve(guest: Guest) -> i32 { + let mut family = Family::new(guest); loop { let mut frame = ForeignFrame::default(); - let got = mk_foreign_wait(&mut frame, WAIT_MS); - if got > 0 && guest.owns(frame.pid) { - if let Answer::Reply(value) = answer(guest, &frame) { - let _ = mk_foreign_reply(frame.pid, value); - } + if mk_foreign_wait(&mut frame, WAIT_MS) > 0 { + family.answer(&frame); } - if let Some(code) = guest.exited { + family.reap(); + if let Some(code) = family.done() { return code; } } diff --git a/userland/capsule_linux/src/linux/serve/mod.rs b/userland/capsule_linux/src/linux/serve/mod.rs index a10fe22dad..a3f0a87db8 100644 --- a/userland/capsule_linux/src/linux/serve/mod.rs +++ b/userland/capsule_linux/src/linux/serve/mod.rs @@ -18,6 +18,8 @@ mod answer; mod dispatch; +mod family; +mod family_reap; mod loop_impl; mod table; mod table_file; diff --git a/userland/capsule_linux/src/linux/start.rs b/userland/capsule_linux/src/linux/start.rs index 8dac4a2bb9..dcd75aecf0 100644 --- a/userland/capsule_linux/src/linux/start.rs +++ b/userland/capsule_linux/src/linux/start.rs @@ -48,7 +48,7 @@ pub fn run() -> ! { let code = match start(&mut guest, &path, &bytes, origin) { Ok(()) => { say(b"[LINUX] guest running\n"); - serve(&mut guest) + serve(guest) } Err(step) => { say(step); From 824bedbcceafbd72eab0abd4c2c987738e7203c4 Mon Sep 17 00:00:00 2001 From: eKisNonos Date: Sun, 27 Sep 2026 00:48:15 +0000 Subject: [PATCH 052/244] lockfiles: record nonos-stark at 0.2.0 in the enrollment tool --- nonos-stark-enroll/Cargo.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nonos-stark-enroll/Cargo.lock b/nonos-stark-enroll/Cargo.lock index 9bb0952670..0a3a14ff73 100644 --- a/nonos-stark-enroll/Cargo.lock +++ b/nonos-stark-enroll/Cargo.lock @@ -73,7 +73,7 @@ checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" [[package]] name = "nonos-stark" -version = "0.1.0" +version = "0.2.0" dependencies = [ "blake3", ] From 5a2312782e1a4d61c4522321b0a8328fe770bbe9 Mon Sep 17 00:00:00 2001 From: eKisNonos Date: Sun, 27 Sep 2026 00:57:08 +0000 Subject: [PATCH 053/244] linux_guests: two address spaces after a fork, and nothing between them separation maps a page, writes A, forks, then writes B; the child must still see A. The child writes C, which the parent must not see, and tries process_vm_readv and ptrace on its parent, reporting what got through in its exit status. On plain Linux the child reads its parent, so the probe reports BROKEN there, which is the control. ptrace probes use PTRACE_SEIZE, not ATTACH. An attach that succeeds stops its target, and the host control of the reader stopped an unrelated process that way; seize attaches without stopping anything. --- userland/linux_guests/src/bin/suite.rs | 3 +- userland/linux_guests/src/lib.rs | 2 + userland/linux_guests/src/sep_child.rs | 48 +++++++++++++++++ userland/linux_guests/src/sep_probe.rs | 75 ++++++++++++++++++++++++++ userland/linux_guests/src/vm_probe.rs | 7 +-- 5 files changed, 131 insertions(+), 4 deletions(-) create mode 100644 userland/linux_guests/src/sep_child.rs create mode 100644 userland/linux_guests/src/sep_probe.rs diff --git a/userland/linux_guests/src/bin/suite.rs b/userland/linux_guests/src/bin/suite.rs index 1265d38306..4dcb0345d1 100644 --- a/userland/linux_guests/src/bin/suite.rs +++ b/userland/linux_guests/src/bin/suite.rs @@ -23,7 +23,7 @@ use std::process::ExitCode; use nonos_linux_guests::report::Report; -use nonos_linux_guests::{bounds_probe, fs_probe, life_probe, native_probe, proc_probe}; +use nonos_linux_guests::{bounds_probe, fs_probe, life_probe, native_probe, proc_probe, sep_probe}; fn main() -> ExitCode { let mut broken = false; @@ -32,6 +32,7 @@ fn main() -> ExitCode { ("bounds", bounds_probe::scan), ("fs", fs_probe::scan), ("proc", proc_self), + ("separation", sep_probe::scan), ] { let mut r = Report::new(guest); scan(&mut r); diff --git a/userland/linux_guests/src/lib.rs b/userland/linux_guests/src/lib.rs index b1c99eb4a7..6b12211962 100644 --- a/userland/linux_guests/src/lib.rs +++ b/userland/linux_guests/src/lib.rs @@ -24,5 +24,7 @@ pub mod life_probe; pub mod native_probe; pub mod proc_probe; pub mod report; +pub mod sep_child; +pub mod sep_probe; pub mod sys; pub mod vm_probe; diff --git a/userland/linux_guests/src/sep_child.rs b/userland/linux_guests/src/sep_child.rs new file mode 100644 index 0000000000..d2a5f915cd --- /dev/null +++ b/userland/linux_guests/src/sep_child.rs @@ -0,0 +1,48 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! The child's half of the separation probe. + +use crate::sep_probe::{peek, poke}; +use crate::sys::{call, NANOSLEEP, PROCESS_VM_READV, PTRACE}; + +/// Attaches without stopping the target, so a success does not wedge it. +const PTRACE_SEIZE: u64 = 0x4206; + +/// Bits of the exit status: 1 saw the parent's later write, 2 reached into +/// the parent through a call. +pub fn run(parent: u32) -> u64 { + // Long enough for the parent's write to land, if it were going to. + let tenth = [0u64, 200_000_000]; + let _ = call(NANOSLEEP, [tenth.as_ptr() as u64, 0, 0, 0, 0, 0]); + let mut bits = 0u64; + if peek() == b'B' { + bits |= 1; + } + let mut buf = [0u8; 1]; + let local = [buf.as_mut_ptr() as u64, 1u64]; + let remote = [0x5000_0000u64, 1u64]; + let read = call( + PROCESS_VM_READV, + [parent as u64, local.as_ptr() as u64, 1, remote.as_ptr() as u64, 1, 0], + ); + let traced = call(PTRACE, [PTRACE_SEIZE, parent as u64, 0, 0, 0, 0]); + if read >= 0 || traced >= 0 { + bits |= 2; + } + poke(b'C'); + bits +} diff --git a/userland/linux_guests/src/sep_probe.rs b/userland/linux_guests/src/sep_probe.rs new file mode 100644 index 0000000000..55d558cba3 --- /dev/null +++ b/userland/linux_guests/src/sep_probe.rs @@ -0,0 +1,75 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! Two address spaces after a fork, and nothing passing between them. +//! +//! A child starts with a copy of its parent's memory; that is fork. What must +//! not happen after is a write on one side appearing on the other, or either +//! reaching into the other. The child reports through its exit status. + +use crate::report::{Report, Seen}; +use crate::sys::{call, GETPID, MAP_FIXED, MAP_PRIVATE_ANON, MMAP, PROT_RW}; + +const FORK: u64 = 57; +const WAIT4: u64 = 61; +const EXIT_GROUP: u64 = 231; +const AT: u64 = 0x5000_0000; + +pub fn scan(r: &mut Report) { + let fixed = MAP_PRIVATE_ANON | MAP_FIXED; + if call(MMAP, [AT, 4096, PROT_RW, fixed, u64::MAX, 0]) != AT as i64 { + r.check("map the shared-looking page", Seen::Refused(-12)); + return; + } + poke(b'A'); + let parent = call(GETPID, [0; 6]) as u32; + let child = call(FORK, [0; 6]); + if child == 0 { + let _ = call(EXIT_GROUP, [super::sep_child::run(parent), 0, 0, 0, 0, 0]); + } + if child < 0 { + r.check("fork", Seen::Refused(child)); + return; + } + poke(b'B'); + let mut status = 0i32; + let _ = call(WAIT4, [child as u64, &mut status as *mut i32 as u64, 0, 0, 0, 0]); + let bits = (status >> 8) & 0xff; + let seen = |bit: i32, how: &str| match bits & bit { + 0 => Seen::Refused(0), + _ => Seen::Escaped(how.into()), + }; + r.check("parent write reaching the child", seen(1, "the child saw B")); + r.check("child reading the parent", seen(2, "process_vm_readv or ptrace worked")); + let back = peek(); + r.check( + "child write reaching the parent", + match back { + b'C' => Seen::Escaped("the parent saw C".into()), + _ => Seen::Refused(0), + }, + ); +} + +pub(crate) fn poke(v: u8) { + // SAFETY: AT was mapped read-write for 4096 bytes before any call here. + unsafe { core::ptr::write_volatile(AT as *mut u8, v) } +} + +pub(crate) fn peek() -> u8 { + // SAFETY: as for poke. + unsafe { core::ptr::read_volatile(AT as *const u8) } +} diff --git a/userland/linux_guests/src/vm_probe.rs b/userland/linux_guests/src/vm_probe.rs index 5281c7d14f..437babf06c 100644 --- a/userland/linux_guests/src/vm_probe.rs +++ b/userland/linux_guests/src/vm_probe.rs @@ -19,15 +19,16 @@ use crate::report::{Report, Seen}; use crate::sys::{call, KILL, PATTERN_AT, PROCESS_VM_READV, PTRACE}; -const PTRACE_ATTACH: u64 = 16; +/// Attaches without stopping the target, so a success does not wedge it. +const PTRACE_SEIZE: u64 = 0x4206; /// process_vm_readv, ptrace and kill against every pid. Each reports once: /// the first escape it finds, or the errno every pid gave. pub fn scan(r: &mut Report, pids: &[u32]) { r.check("process_vm_readv", sweep(pids, read_sibling)); r.check( - "ptrace attach", - sweep(pids, |pid| call(PTRACE, [PTRACE_ATTACH, pid as u64, 0, 0, 0, 0])), + "ptrace seize", + sweep(pids, |pid| call(PTRACE, [PTRACE_SEIZE, pid as u64, 0, 0, 0, 0])), ); // Signal 0 delivers nothing and only answers whether the pid exists, so // a yes is a disclosure; SIGKILL would end the holder and the run. From 3ffa36e9289655315f339b5f93a3e60fb96bb55a Mon Sep 17 00:00:00 2001 From: eKisNonos Date: Sun, 27 Sep 2026 01:02:39 +0000 Subject: [PATCH 054/244] linux: give the image's boot program its arguments /linux/etc/nonos-boot-guest named a path and nothing else, so a boot could run `busybox` but never `busybox sh -c ...`. After the path, each further line is one argument, which keeps a script passed to sh free of any quoting rules. What is run is carried as one Launch instead of a growing tuple, and the embedded busybox moves to its own file. --- .../capsule_linux/src/linux/boot_guest.rs | 15 ++++---- userland/capsule_linux/src/linux/built_in.rs | 35 +++++++++++++++++++ userland/capsule_linux/src/linux/launch.rs | 30 ++++++++++++++++ userland/capsule_linux/src/linux/mod.rs | 2 ++ userland/capsule_linux/src/linux/source.rs | 23 ++++++------ userland/capsule_linux/src/linux/start.rs | 4 +-- .../capsule_linux/src/linux/start_guest.rs | 16 ++++----- 7 files changed, 97 insertions(+), 28 deletions(-) create mode 100644 userland/capsule_linux/src/linux/built_in.rs create mode 100644 userland/capsule_linux/src/linux/launch.rs diff --git a/userland/capsule_linux/src/linux/boot_guest.rs b/userland/capsule_linux/src/linux/boot_guest.rs index b841a88d12..702dad435d 100644 --- a/userland/capsule_linux/src/linux/boot_guest.rs +++ b/userland/capsule_linux/src/linux/boot_guest.rs @@ -28,17 +28,20 @@ use crate::linux::file::{key, store_read, visible}; /// Guest-visible, so it lives under /linux like the program it names. const BOOT_GUEST: &[u8] = b"/etc/nonos-boot-guest"; -const MAX_NAME: u32 = 256; +const MAX_NAME: u32 = 1024; -/// The path the image names and the program's bytes, or None when the image -/// names nothing. -pub(super) fn boot_guest(max_image: u32) -> Option<(Vec, Vec)> { +/// The path the image names, the program's bytes and its arguments, or None +/// when the image names nothing. One argument a line, so a script passed to +/// `sh -c` needs no quoting rules. +pub(super) fn boot_guest(max_image: u32) -> Option<(Vec, Vec, Vec>)> { let named = store_read(&key(BOOT_GUEST), MAX_NAME).ok()?; - let path = named.split(|b| *b == b'\n' || *b == 0).next()?; + let mut lines = named.split(|b| *b == b'\n').filter(|l| !l.is_empty()); + let path = lines.next()?; if path.first() != Some(&b'/') { return None; } + let args = lines.map(|l| l.to_vec()).collect(); let at = visible(b"/", path); let bytes = store_read(&key(&at), max_image).ok()?; - Some((at, bytes)) + Some((at, bytes, args)) } diff --git a/userland/capsule_linux/src/linux/built_in.rs b/userland/capsule_linux/src/linux/built_in.rs new file mode 100644 index 0000000000..8e7864b902 --- /dev/null +++ b/userland/capsule_linux/src/linux/built_in.rs @@ -0,0 +1,35 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! The program a machine runs when its store names none. + +use alloc::vec::Vec; + +use super::launch::Launch; +use super::origin::Origin; + +/// The built-in program: Alpine's static busybox, embedded so a machine with +/// nothing in the store still runs a real Linux binary. +static BUILT_IN: &[u8] = include_bytes!("../../guests/busybox.elf"); + +pub(super) fn built_in() -> Launch { + Launch { + path: b"/bin/busybox".to_vec(), + bytes: BUILT_IN.to_vec(), + origin: Origin::BuiltIn, + args: Vec::new(), + } +} diff --git a/userland/capsule_linux/src/linux/launch.rs b/userland/capsule_linux/src/linux/launch.rs new file mode 100644 index 0000000000..8edaf68487 --- /dev/null +++ b/userland/capsule_linux/src/linux/launch.rs @@ -0,0 +1,30 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! What the personality is about to run. + +use alloc::vec::Vec; + +use super::origin::Origin; + +pub struct Launch { + /// The guest-visible path, which is also argv[0]. + pub path: Vec, + pub bytes: Vec, + pub origin: Origin, + /// Arguments after argv[0]. + pub args: Vec>, +} diff --git a/userland/capsule_linux/src/linux/mod.rs b/userland/capsule_linux/src/linux/mod.rs index d257525c52..5a7ee1f136 100644 --- a/userland/capsule_linux/src/linux/mod.rs +++ b/userland/capsule_linux/src/linux/mod.rs @@ -23,12 +23,14 @@ mod attest_local; mod attest_paths; mod attest_publisher; mod boot_guest; +mod built_in; mod call; mod env; mod file; mod guest; mod image; mod install; +mod launch; mod net; mod origin; mod request; diff --git a/userland/capsule_linux/src/linux/source.rs b/userland/capsule_linux/src/linux/source.rs index 3e425b64b9..8cd7689797 100644 --- a/userland/capsule_linux/src/linux/source.rs +++ b/userland/capsule_linux/src/linux/source.rs @@ -22,28 +22,29 @@ use nonos_libc::mk_args; use crate::linux::file::{key, store_read, visible}; +use super::launch::Launch; use super::origin::Origin; -/// The built-in program: Alpine's static busybox, embedded so a machine with -/// nothing in the store still runs a real Linux binary. -static BUILT_IN: &[u8] = include_bytes!("../../guests/busybox.elf"); - const MAX_IMAGE: u32 = 64 << 20; const MAX_ARGS: usize = 256; -/// The program's path, its bytes, and where they came from. A run of an +/// The program, where it came from, and what it is given. A run of an /// installed package is its recorded program or nothing: falling back to the /// built-in program would start something the person did not ask for. -pub fn source() -> Option<(Vec, Vec, Origin)> { +pub fn source() -> Option { + let store = |path: Vec, bytes, args| Launch { path, bytes, origin: Origin::Store, args }; if let Some(name) = super::request::run_request() { let path = super::install::recorded(&name)?; let bytes = store_read(&key(&path), MAX_IMAGE).ok()?; - return Some((path, bytes, Origin::Store)); + return Some(store(path, bytes, Vec::new())); + } + if let Some((path, bytes)) = named() { + return Some(store(path, bytes, Vec::new())); + } + if let Some((path, bytes, args)) = super::boot_guest::boot_guest(MAX_IMAGE) { + return Some(store(path, bytes, args)); } - Some(match named().or_else(|| super::boot_guest::boot_guest(MAX_IMAGE)) { - Some((path, bytes)) => (path, bytes, Origin::Store), - None => (b"/bin/busybox".to_vec(), BUILT_IN.to_vec(), Origin::BuiltIn), - }) + Some(super::built_in::built_in()) } fn named() -> Option<(Vec, Vec)> { diff --git a/userland/capsule_linux/src/linux/start.rs b/userland/capsule_linux/src/linux/start.rs index dcd75aecf0..7511462e0e 100644 --- a/userland/capsule_linux/src/linux/start.rs +++ b/userland/capsule_linux/src/linux/start.rs @@ -32,7 +32,7 @@ pub fn run() -> ! { say(if ok { b"[LINUX] installed\n" } else { b"[LINUX] install failed\n" }); mk_exit(if ok { 0 } else { 1 }) } - let Some((path, bytes, origin)) = source() else { + let Some(launch) = source() else { say(b"[LINUX] nothing installed under that name\n"); mk_exit(1) }; @@ -45,7 +45,7 @@ pub fn run() -> ! { mk_exit(1) } let mut guest = Guest::new(pid as u32); - let code = match start(&mut guest, &path, &bytes, origin) { + let code = match start(&mut guest, &launch) { Ok(()) => { say(b"[LINUX] guest running\n"); serve(guest) diff --git a/userland/capsule_linux/src/linux/start_guest.rs b/userland/capsule_linux/src/linux/start_guest.rs index fcf34697dd..c4802a5eef 100644 --- a/userland/capsule_linux/src/linux/start_guest.rs +++ b/userland/capsule_linux/src/linux/start_guest.rs @@ -21,16 +21,13 @@ use nonos_libc::mk_foreign_start; use super::guest::Guest; use super::guest::{STACK_SIZE, STACK_TOP}; use super::image; +use super::launch::Launch; use super::origin::Origin; use super::start::say; -pub(super) fn start( - guest: &mut Guest, - path: &[u8], - bytes: &[u8], - origin: Origin, -) -> Result<(), &'static [u8]> { - if let Err(why) = prove(path, bytes, origin) { +pub(super) fn start(guest: &mut Guest, launch: &Launch) -> Result<(), &'static [u8]> { + let (path, bytes) = (&launch.path[..], &launch.bytes[..]); + if let Err(why) = prove(path, bytes, &launch.origin) { say(b"[LINUX] refused: "); say(why.as_bytes()); say(b"\n"); @@ -38,7 +35,8 @@ pub(super) fn start( } let (image, entry, interp_base) = image::program(guest, bytes).map_err(|e| e.why())?; guest.map(STACK_TOP - STACK_SIZE, STACK_SIZE, true, false); - let argv = alloc::vec![path.to_vec()]; + let mut argv = alloc::vec![path.to_vec()]; + argv.extend(launch.args.iter().cloned()); let rsp = image::build(guest, STACK_TOP, &image, interp_base, &argv, &super::env::default()) .ok_or(&b"[LINUX] stack refused\n"[..])?; match mk_foreign_start(guest.pid, entry, rsp) { @@ -48,7 +46,7 @@ pub(super) fn start( } /// A program out of the store proves itself against the enrolled set. -fn prove(path: &[u8], bytes: &[u8], origin: Origin) -> Result<(), &'static str> { +fn prove(path: &[u8], bytes: &[u8], origin: &Origin) -> Result<(), &'static str> { match origin { Origin::BuiltIn => Ok(()), Origin::Store => super::attest::verify(path, bytes).map(|_| ()), From cf4576be138066537cd10e95e741fed0ee317e88 Mon Sep 17 00:00:00 2001 From: eKisNonos Date: Sun, 27 Sep 2026 01:02:39 +0000 Subject: [PATCH 055/244] linux_guests: busybox from the store, and a boot command with arguments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Alpine's static busybox, the ELF app.linux embeds, is declared as one more guest, so a test image carries it under /linux/bin signed, enrolled and proven like any NØNOS-built Linux program, rather than as the one program the personality trusts because it is built in. LINUX_GUEST_BOOT_LINES names the boot program and its arguments with | between them. One name list now drives the key ordering, the store entries and their dependencies. --- userland/linux_guests/Guests.mk | 66 +++++++++++++++------------------ 1 file changed, 29 insertions(+), 37 deletions(-) diff --git a/userland/linux_guests/Guests.mk b/userland/linux_guests/Guests.mk index bca22916cd..2bcd19e6cc 100644 --- a/userland/linux_guests/Guests.mk +++ b/userland/linux_guests/Guests.mk @@ -1,14 +1,10 @@ -# Hostile Linux guests, signed and enrolled like capsules, for test images. +# Linux guests signed and enrolled like capsules, for test images. # -# NONOS_LINUX_GUESTS=1 builds each guest for musl, gives it a NØNOS-ID -# certificate and manifest through the same template a capsule uses, and so -# puts it under the enrolled policy root. The personality then verifies a -# guest exactly as it would any NØNOS-built Linux program: nothing here -# weakens a check to let a test through. -# -# Test images only: publisher keys are minted on first use, so this needs a -# scratch trust tree (NONOS_DEV=1). A guest holds no capabilities; its two -# endpoints are what a manifest must declare, and no guest registers them. +# NONOS_LINUX_GUESTS=1 builds each guest, signs it through the capsule +# template and enrols it under the policy root, so the personality verifies it +# as it would any NØNOS-built Linux program; no check is weakened for a test. +# Scratch trust only: publisher keys are minted on first use. A guest holds no +# capabilities; its endpoints are declared, never registered. ifneq ($(NONOS_DEV),1) $(error NONOS_LINUX_GUESTS=1 mints scratch publisher keys and needs NONOS_DEV=1) @@ -26,8 +22,6 @@ $(LINUX_GUESTS_OUT)/%: $(LINUX_GUESTS_SRCS) @cd $(LINUX_GUESTS_DIR) && RUSTUP_TOOLCHAIN=$(TOOLCHAIN) \ RUSTFLAGS="-C target-feature=+crt-static -C relocation-model=static" \ cargo build --release --target $(LINUX_GUESTS_TRIPLE) --bin $* - -# A publisher key per guest, minted into the scratch tree when missing. $(NONOS_BAKED_TRUST_DIR)/keys/guest_%_publisher_ed25519.pub \ $(NONOS_BAKED_TRUST_DIR)/keys/guest_%_publisher_mldsa65.pub: | $(CAPSULE_SIGN_BIN) @mkdir -p .keys $(NONOS_BAKED_TRUST_DIR)/keys @@ -37,8 +31,8 @@ $(NONOS_BAKED_TRUST_DIR)/keys/guest_%_publisher_mldsa65.pub: | $(CAPSULE_SIGN_BI mv .keys/guest_$*_publisher_$$alg.pub $(NONOS_BAKED_TRUST_DIR)/keys/; \ done -# name, service port, reply port. The enrolled copy is named guest_, -# so its certificate and trailer cannot collide with a capsule's. +# name, service port, reply port[, prebuilt ELF]. The enrolled copy is named +# guest_, so its certificate and trailer cannot collide with a capsule's. define LINUX_GUEST CAPSULE_SLUG := linux-guest-$(1) CAPSULE_HANDLE := linux.guest.$(1) @@ -50,34 +44,32 @@ CAPSULE_TARGET := $(LINUX_GUESTS_TRIPLE) CAPSULE_SERVICE_ENDPOINT := service:$(2):linux.guest.$(1) CAPSULE_REPLY_ENDPOINT := reply:$(3):endpoint.linux.guest.$(1).reply CAPSULE_REQUIRED_CAPS := 0x0 -CAPSULE_PREBUILT_BIN := $(LINUX_GUESTS_OUT)/$(1) +CAPSULE_PREBUILT_BIN := $(or $(4),$(LINUX_GUESTS_OUT)/$(1)) CAPSULE_MK_FILE := $(LINUX_GUESTS_DIR)/Guests.mk -CAPSULE_METADATA := NØNOS hostile Linux guest $(1) +CAPSULE_METADATA := NØNOS Linux guest $(1) include nonos-mk/capsule.mk +# The template checks the keys exist; this makes it wait for the mint. +nonos-mk-check-linux-guest-$(1)-keys: \ + $(NONOS_BAKED_TRUST_DIR)/keys/guest_$(1)_publisher_ed25519.pub \ + $(NONOS_BAKED_TRUST_DIR)/keys/guest_$(1)_publisher_mldsa65.pub +LINUX_GUEST_STORE_DEPS += $$(linux-guest-$(1)_ARTIFACTS) $$(linux-guest-$(1)_ATTESTATION) +LINUX_GUEST_STORE_ENTRIES += --entry /linux/bin/$(1)=$$(linux-guest-$(1)_BIN) \ + --entry /linux/bin/$(1).nonos_id_cert.bin=$$(linux-guest-$(1)_CERT) \ + --entry /linux/bin/$(1).manifest.bin=$$(linux-guest-$(1)_MANIFEST) \ + --entry /linux/bin/$(1).zk_trailer.bin=$$(linux-guest-$(1)_ATTESTATION) endef $(eval $(call LINUX_GUEST,suite,4950,4951)) $(eval $(call LINUX_GUEST,holder,4952,4953)) $(eval $(call LINUX_GUEST,reader,4954,4955)) +# Alpine's static busybox, the one app.linux embeds, as a program from the store. +$(eval $(call LINUX_GUEST,busybox,4956,4957,userland/capsule_linux/guests/busybox.elf)) -LINUX_GUEST_SLUGS := linux-guest-suite linux-guest-holder linux-guest-reader -# The template checks keys exist; this makes the check wait for the mint. -$(foreach g,suite holder reader,$(eval nonos-mk-check-linux-guest-$(g)-keys: \ - $(NONOS_BAKED_TRUST_DIR)/keys/guest_$(g)_publisher_ed25519.pub \ - $(NONOS_BAKED_TRUST_DIR)/keys/guest_$(g)_publisher_mldsa65.pub)) - -# What the store image carries: each guest under /linux/bin with its proof -# beside it, and the file naming the one the boot instance runs. -LINUX_GUEST_BOOT ?= suite -LINUX_GUEST_BOOT_FILE := $(TARGET_DIR)/linux-guests/boot-$(LINUX_GUEST_BOOT) -$(LINUX_GUEST_BOOT_FILE): - @mkdir -p $(@D) && printf '/bin/%s\n' '$(LINUX_GUEST_BOOT)' > $@ - -LINUX_GUEST_STORE_ENTRIES := --entry /linux/etc/nonos-boot-guest=$(LINUX_GUEST_BOOT_FILE) \ - $(foreach s,$(LINUX_GUEST_SLUGS),\ - --entry /linux/bin/$(patsubst guest_%,%,$($(s)_BIN_NAME))=$($(s)_BIN) \ - --entry /linux/bin/$(patsubst guest_%,%,$($(s)_BIN_NAME)).nonos_id_cert.bin=$($(s)_CERT) \ - --entry /linux/bin/$(patsubst guest_%,%,$($(s)_BIN_NAME)).manifest.bin=$($(s)_MANIFEST) \ - --entry /linux/bin/$(patsubst guest_%,%,$($(s)_BIN_NAME)).zk_trailer.bin=$($(s)_ATTESTATION)) -LINUX_GUEST_STORE_DEPS := $(LINUX_GUEST_BOOT_FILE) \ - $(foreach s,$(LINUX_GUEST_SLUGS),$($(s)_ARTIFACTS) $($(s)_ATTESTATION)) +# The boot program and its arguments, `|` between them, one a line in the file. +LINUX_GUEST_BOOT_LINES ?= /bin/suite +LINUX_GUEST_BOOT_FILE := $(TARGET_DIR)/linux-guests/nonos-boot-guest +.PHONY: nonos-mk-linux-guest-boot +$(LINUX_GUEST_BOOT_FILE): nonos-mk-linux-guest-boot + @mkdir -p $(@D) && printf '%s\n' '$(LINUX_GUEST_BOOT_LINES)' | tr '|' '\n' > $@ +LINUX_GUEST_STORE_DEPS += $(LINUX_GUEST_BOOT_FILE) +LINUX_GUEST_STORE_ENTRIES += --entry /linux/etc/nonos-boot-guest=$(LINUX_GUEST_BOOT_FILE) From f8c3ab4f112a8734eab618b479bdaa6188de8f7a Mon Sep 17 00:00:00 2001 From: eKisNonos Date: Sun, 27 Sep 2026 01:31:26 +0000 Subject: [PATCH 056/244] linux: name an unserved call by number when the table has no name for it The name table covers what is served, which is the wrong set for a line about what is not: symlink came out as "[LINUX] unserved ?". A number the table does not know is now printed as nr=. --- .../capsule_linux/src/linux/serve/unserved.rs | 24 +++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/userland/capsule_linux/src/linux/serve/unserved.rs b/userland/capsule_linux/src/linux/serve/unserved.rs index 69174a1660..db5901ba83 100644 --- a/userland/capsule_linux/src/linux/serve/unserved.rs +++ b/userland/capsule_linux/src/linux/serve/unserved.rs @@ -14,7 +14,6 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . - //! Naming a call this capsule does not serve. use crate::linux::abi::{errno, name}; @@ -24,7 +23,12 @@ use crate::linux::abi::{errno, name}; pub fn unserved(number: u64) -> u64 { let mut line = [0u8; 64]; let head = b"[LINUX] unserved "; - let tag = name::of(number); + // The name table covers what is served; anything else is named by number. + let mut digits = [0u8; 24]; + let tag = match name::of(number) { + b"?" => decimal(number, &mut digits), + known => known, + }; let n = head.len().min(line.len()); line[..n].copy_from_slice(&head[..n]); let m = (n + tag.len()).min(line.len()); @@ -34,3 +38,19 @@ pub fn unserved(number: u64) -> u64 { let _ = nonos_libc::mk_debug(line.as_ptr(), end); errno::fail(errno::ENOSYS) } + +/// `nr=`, written into `out`. +fn decimal(mut v: u64, out: &mut [u8; 24]) -> &[u8] { + let mut at = out.len(); + loop { + at -= 1; + out[at] = b'0' + (v % 10) as u8; + v /= 10; + if v == 0 || at <= 3 { + break; + } + } + at -= 3; + out[at..at + 3].copy_from_slice(b"nr="); + &out[at..] +} From 0c4e4eb8be3d20ddb8e9121c595017be8e0913f5 Mon Sep 17 00:00:00 2001 From: eKisNonos Date: Sun, 27 Sep 2026 01:32:57 +0000 Subject: [PATCH 057/244] linux: a read on an empty pipe waits, and ends when no writer is left An empty pipe answered EAGAIN, so a reader spun or gave up, and nothing ever reported end of file: a shell pipeline could not work. The read now parks. The family settles it after every answer, since the write end may be in another process: bytes when some arrive, or 0 once no process holds a write end of that pipe. A full pipe still answers EAGAIN to its writer. --- userland/capsule_linux/src/linux/call/mod.rs | 2 + .../capsule_linux/src/linux/call/pipe_wait.rs | 44 +++++++++++++++ .../capsule_linux/src/linux/guest/handle.rs | 2 + .../src/linux/guest/handle_new.rs | 1 + .../capsule_linux/src/linux/serve/dispatch.rs | 3 + .../capsule_linux/src/linux/serve/family.rs | 1 + .../src/linux/serve/family_pipes.rs | 55 +++++++++++++++++++ .../src/linux/serve/family_reap.rs | 1 + userland/capsule_linux/src/linux/serve/mod.rs | 1 + 9 files changed, 110 insertions(+) create mode 100644 userland/capsule_linux/src/linux/call/pipe_wait.rs create mode 100644 userland/capsule_linux/src/linux/serve/family_pipes.rs diff --git a/userland/capsule_linux/src/linux/call/mod.rs b/userland/capsule_linux/src/linux/call/mod.rs index 88dbeaa20a..93f66cc634 100644 --- a/userland/capsule_linux/src/linux/call/mod.rs +++ b/userland/capsule_linux/src/linux/call/mod.rs @@ -32,6 +32,7 @@ mod pipe_dup; mod pipe_end; mod pipe_io; mod pipe_read; +mod pipe_wait; mod session; mod signal; mod signal_send; @@ -56,6 +57,7 @@ pub use pipe::pipe2; pub use pipe_dup::{dup, dup2}; pub use pipe_io::write as pipe_write; pub use pipe_read::read as pipe_read; +pub use pipe_wait::{is_pipe, read_or_park as pipe_read_or_park}; pub use session::{getpgid, getsid, setpgid, setsid}; pub use signal::{rt_sigaction, rt_sigprocmask, sigaltstack}; pub use signal_send::kill; diff --git a/userland/capsule_linux/src/linux/call/pipe_wait.rs b/userland/capsule_linux/src/linux/call/pipe_wait.rs new file mode 100644 index 0000000000..f79d9e8261 --- /dev/null +++ b/userland/capsule_linux/src/linux/call/pipe_wait.rs @@ -0,0 +1,44 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! A read on an empty pipe waits, as Linux's does. +//! +//! Whether anything could still fill the pipe is the family's to say, since +//! the write end may be in another process: the read parks here and the serve +//! loop settles it, with bytes when some arrive or end of file when no write +//! end is left anywhere. + +use crate::linux::abi::errno; +use crate::linux::guest::{Guest, Kind}; +use crate::linux::serve::Answer; + +use super::pipe_end::end_of; +use super::pipe_read::read; + +pub fn is_pipe(guest: &Guest, fd: u64) -> bool { + guest.fds.get(fd as usize).is_some_and(|f| f.kind == Kind::Pipe) +} + +pub fn read_or_park(guest: &mut Guest, fd: u64, buf: u64, len: u64, tid: u32) -> Answer { + let Some((slot, writable)) = end_of(guest, fd) else { + return Answer::value(errno::fail(errno::EBADF)); + }; + if writable || len == 0 || !guest.pipes[slot].is_empty() { + return Answer::value(read(guest, fd, buf, len)); + } + guest.pipe_wait = Some((slot, buf, len, tid)); + Answer::Park +} diff --git a/userland/capsule_linux/src/linux/guest/handle.rs b/userland/capsule_linux/src/linux/guest/handle.rs index 3eeead2b5e..aeb0e1f534 100644 --- a/userland/capsule_linux/src/linux/guest/handle.rs +++ b/userland/capsule_linux/src/linux/guest/handle.rs @@ -69,4 +69,6 @@ pub struct Guest { pub ended: Vec<(u32, i32)>, /// A parked wait4: the pid it wants, where the status goes, the caller. pub waiting: Option<(u64, u64, u32)>, + /// A read parked on an empty pipe: its buffer slot, where, how much, who. + pub pipe_wait: Option<(usize, u64, u64, u32)>, } diff --git a/userland/capsule_linux/src/linux/guest/handle_new.rs b/userland/capsule_linux/src/linux/guest/handle_new.rs index 75741f64e5..bb8937dc24 100644 --- a/userland/capsule_linux/src/linux/guest/handle_new.rs +++ b/userland/capsule_linux/src/linux/guest/handle_new.rs @@ -53,6 +53,7 @@ impl Guest { forked: Vec::new(), ended: Vec::new(), waiting: None, + pipe_wait: None, } } } diff --git a/userland/capsule_linux/src/linux/serve/dispatch.rs b/userland/capsule_linux/src/linux/serve/dispatch.rs index 28615806d4..f86ab9c473 100644 --- a/userland/capsule_linux/src/linux/serve/dispatch.rs +++ b/userland/capsule_linux/src/linux/serve/dispatch.rs @@ -40,6 +40,9 @@ pub fn answer(guest: &mut Guest, frame: &ForeignFrame) -> Answer { Answer::Park } nr::FUTEX => futex(guest, frame.pid, a[0], a[1], a[2]), + nr::READ if crate::linux::call::is_pipe(guest, a[0]) => { + crate::linux::call::pipe_read_or_park(guest, a[0], a[1], a[2], frame.pid) + } other => Answer::Reply(plain(guest, frame.pid, other, a)), } } diff --git a/userland/capsule_linux/src/linux/serve/family.rs b/userland/capsule_linux/src/linux/serve/family.rs index f1f6e8ae40..b5e7758198 100644 --- a/userland/capsule_linux/src/linux/serve/family.rs +++ b/userland/capsule_linux/src/linux/serve/family.rs @@ -57,6 +57,7 @@ impl Family { let _ = mk_foreign_reply(frame.pid, value); } self.guests.extend(born); + self.settle_pipes(); } /// Done once nothing it hosts is left; the code is the first guest's. diff --git a/userland/capsule_linux/src/linux/serve/family_pipes.rs b/userland/capsule_linux/src/linux/serve/family_pipes.rs new file mode 100644 index 0000000000..dcdaab7dca --- /dev/null +++ b/userland/capsule_linux/src/linux/serve/family_pipes.rs @@ -0,0 +1,55 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! Settling reads parked on empty pipes. + +use nonos_libc::mk_foreign_reply; + +use super::family::Family; +use crate::linux::guest::Kind; + +impl Family { + /// Give each parked pipe read its bytes, or end of file once no process + /// in the family holds a write end. + pub fn settle_pipes(&mut self) { + for i in 0..self.guests.len() { + let Some((slot, buf, len, tid)) = self.guests[i].pipe_wait else { + continue; + }; + let have = self.pipes.get(slot).map_or(0, |p| p.len()); + let value = if have > 0 { + let take = (len as usize).min(have); + let bytes: alloc::vec::Vec = self.pipes[slot].drain(..take).collect(); + match self.guests[i].write(buf, &bytes) < take as i64 { + true => crate::linux::abi::errno::fail(crate::linux::abi::errno::EFAULT), + false => take as u64, + } + } else if !self.writer_left(slot) { + 0 + } else { + continue; + }; + self.guests[i].pipe_wait = None; + let _ = mk_foreign_reply(tid, value); + } + } + + fn writer_left(&self, slot: usize) -> bool { + self.guests.iter().any(|g| { + g.fds.iter().any(|f| f.kind == Kind::Pipe && f.writable && f.handle as usize == slot) + }) + } +} diff --git a/userland/capsule_linux/src/linux/serve/family_reap.rs b/userland/capsule_linux/src/linux/serve/family_reap.rs index 8dee62fb90..b5e2c10f21 100644 --- a/userland/capsule_linux/src/linux/serve/family_reap.rs +++ b/userland/capsule_linux/src/linux/serve/family_reap.rs @@ -26,6 +26,7 @@ const SIGKILL: u64 = 9; impl Family { /// End every process that asked to, and tell its parent. pub fn reap(&mut self) { + self.settle_pipes(); while let Some(i) = self.guests.iter().position(|g| g.exited.is_some()) { let gone = self.guests.remove(i); let code = gone.exited.unwrap_or(0); diff --git a/userland/capsule_linux/src/linux/serve/mod.rs b/userland/capsule_linux/src/linux/serve/mod.rs index a3f0a87db8..48f3cbe7c4 100644 --- a/userland/capsule_linux/src/linux/serve/mod.rs +++ b/userland/capsule_linux/src/linux/serve/mod.rs @@ -19,6 +19,7 @@ mod answer; mod dispatch; mod family; +mod family_pipes; mod family_reap; mod loop_impl; mod table; From a2b697bd2e9c46c5a1462cd59023bb2e73c27df4 Mon Sep 17 00:00:00 2001 From: eKisNonos Date: Sun, 27 Sep 2026 01:34:03 +0000 Subject: [PATCH 058/244] linux: refuse MAP_FIXED at page zero instead of mapping elsewhere A zero address was taken to mean the guest left the choice here, flags or not, so MAP_FIXED at 0 came back mapped at 0x2000b000. MAP_FIXED is the exact address or failure, and page zero is never in the plan, so it is refused with EPERM, as Linux refuses it below mmap_min_addr. Left as it was: a non-zero hint without MAP_FIXED is still treated as fixed. Linux would place it elsewhere when the hint is taken. --- userland/capsule_linux/src/linux/call/mem/map.rs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/userland/capsule_linux/src/linux/call/mem/map.rs b/userland/capsule_linux/src/linux/call/mem/map.rs index 68c0fb11e0..227252a8ce 100644 --- a/userland/capsule_linux/src/linux/call/mem/map.rs +++ b/userland/capsule_linux/src/linux/call/mem/map.rs @@ -26,6 +26,7 @@ use super::prot::wx_refused; const MAP_SHARED: u64 = 0x01; const MAP_ANONYMOUS: u64 = 0x20; +const MAP_FIXED: u64 = 0x10; pub fn mmap(guest: &mut Guest, req: MapReq) -> u64 { if req.len == 0 { @@ -34,6 +35,12 @@ pub fn mmap(guest: &mut Guest, req: MapReq) -> u64 { if wx_refused(req.prot) { return errno::fail(errno::EPERM); } + // MAP_FIXED is the exact address or failure. Page zero is never in the + // plan, and landing elsewhere would hand back memory the guest did not + // ask for, so it is refused, as Linux refuses it below mmap_min_addr. + if req.flags & MAP_FIXED != 0 && req.addr == 0 { + return errno::fail(errno::EPERM); + } // The ceiling differs by who chose the address. let (at, limit) = match req.fixed() { Some(addr) => (addr, STACK_TOP), From aeb91baadbcbdb4e9be5dc0629fe631b7f8fe730 Mon Sep 17 00:00:00 2001 From: eKisNonos Date: Sun, 27 Sep 2026 01:45:20 +0000 Subject: [PATCH 059/244] linux: give a forked child its parent's thread pointer Copying the spans copies memory, and the thread pointer is a register, so a forked child ran with %fs at zero. busybox sh forking to run a command faulted in the child reading address 0 (TLS through %fs:0); the hostile suite's own fork never touched TLS, so it passed. The child now gets the pointer the process last set, before it runs. That is the forking thread's own pointer for a single-threaded process. A thread other than the first forking would hand the child the wrong one; nothing tested here does. --- userland/capsule_linux/src/linux/call/spawn/fork.rs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/userland/capsule_linux/src/linux/call/spawn/fork.rs b/userland/capsule_linux/src/linux/call/spawn/fork.rs index 18bc448fc0..d2f5a9079d 100644 --- a/userland/capsule_linux/src/linux/call/spawn/fork.rs +++ b/userland/capsule_linux/src/linux/call/spawn/fork.rs @@ -33,6 +33,14 @@ pub fn fork(guest: &mut Guest) -> Answer { if !copy_spans(guest, child) { return Answer::value(errno::fail(errno::ENOMEM)); } + /* + * The thread pointer is a register, not memory, so copying the spans does + * not carry it: without this a child's first TLS access reads through a + * zero %fs, and musl makes one almost at once. + */ + if guest.fs_base != 0 && nonos_libc::peer::mk_peer_tls(child, guest.fs_base) < 0 { + return Answer::value(errno::fail(errno::ENOMEM)); + } /* * The child's state goes to the serve loop before the child runs, so its * first trap finds a guest that owns it. From bd3dae6508f0ae0674e9f82e5b9db6c26893e24c Mon Sep 17 00:00:00 2001 From: eKisNonos Date: Sun, 27 Sep 2026 01:48:29 +0000 Subject: [PATCH 060/244] linux: follow symbolic links the image lists The store holds files and nothing else, and a distribution leans on links: busybox sh answered "ls: not found" because /bin/ls did not exist, and libraries are found through their soname links. An image now lists its links in /linux/etc/nonos-links, one `path target` a line, read once at start and shared by the family. Resolution follows them a component at a time: open, stat, access, chdir and exec follow the last component too, the *at calls do not, and readlink reports a link's target. Every result passes through the same clamp as any path, so a link cannot point out of /linux, and exec proves the file a link reaches by that file's own path, never the link's. --- userland/capsule_linux/src/linux/call/cwd.rs | 2 +- .../src/linux/call/spawn/exec.rs | 2 +- .../src/linux/call/spawn/exec_resolve.rs | 8 ++- userland/capsule_linux/src/linux/file/at.rs | 11 +-- .../src/linux/file/meta/query.rs | 17 +++-- .../capsule_linux/src/linux/file/meta/stat.rs | 2 +- userland/capsule_linux/src/linux/file/open.rs | 2 +- .../src/linux/guest/fork_state.rs | 1 + .../capsule_linux/src/linux/guest/handle.rs | 5 +- .../src/linux/guest/handle_new.rs | 1 + .../capsule_linux/src/linux/guest/links.rs | 69 +++++++++++++++++++ .../src/linux/guest/links_load.rs | 39 +++++++++++ userland/capsule_linux/src/linux/guest/mod.rs | 3 + .../src/linux/serve/table_file.rs | 3 +- userland/capsule_linux/src/linux/start.rs | 1 + 15 files changed, 145 insertions(+), 21 deletions(-) create mode 100644 userland/capsule_linux/src/linux/guest/links.rs create mode 100644 userland/capsule_linux/src/linux/guest/links_load.rs diff --git a/userland/capsule_linux/src/linux/call/cwd.rs b/userland/capsule_linux/src/linux/call/cwd.rs index 90a55a0080..474e0b54d3 100644 --- a/userland/capsule_linux/src/linux/call/cwd.rs +++ b/userland/capsule_linux/src/linux/call/cwd.rs @@ -24,7 +24,7 @@ pub fn chdir(guest: &mut Guest, path: u64) -> u64 { let Some(name) = read_path(guest, path) else { return errno::fail(errno::EFAULT); }; - let at = visible(&guest.cwd, &name); + let at = guest.links.follow(visible(&guest.cwd, &name), true); // Checked before it is taken. match look(&at) { Some(_) => { diff --git a/userland/capsule_linux/src/linux/call/spawn/exec.rs b/userland/capsule_linux/src/linux/call/spawn/exec.rs index e45cb53116..825ff2ae0d 100644 --- a/userland/capsule_linux/src/linux/call/spawn/exec.rs +++ b/userland/capsule_linux/src/linux/call/spawn/exec.rs @@ -40,7 +40,7 @@ pub fn execve(guest: &mut Guest, pid: u32, path: u64, argv: u64, envp: u64) -> A * Found, followed through any `#!` line, and proved at every step, all * while the caller still has an address space to be told no in. */ - let program = match resolve(&guest.cwd, &name, &args) { + let program = match resolve(&guest.links, &guest.cwd, &name, &args) { Ok(p) => p, Err(e) => return Answer::value(e), }; diff --git a/userland/capsule_linux/src/linux/call/spawn/exec_resolve.rs b/userland/capsule_linux/src/linux/call/spawn/exec_resolve.rs index 3c782a18e7..28098c4206 100644 --- a/userland/capsule_linux/src/linux/call/spawn/exec_resolve.rs +++ b/userland/capsule_linux/src/linux/call/spawn/exec_resolve.rs @@ -16,6 +16,7 @@ //! Which image actually runs, once `#!` has had its say. +use crate::linux::guest::Links; use alloc::vec::Vec; use crate::linux::abi::errno; @@ -35,8 +36,9 @@ pub struct Program { pub argv: Vec>, } -pub fn resolve(cwd: &[u8], name: &[u8], argv: &[Vec]) -> Result { - let mut path = visible(cwd, name); +/// A path reached through a link is loaded, and proved, as the file it names. +pub fn resolve(links: &Links, cwd: &[u8], name: &[u8], argv: &[Vec]) -> Result { + let mut path = links.follow(visible(cwd, name), true); let mut args = argv.to_vec(); for _ in 0..MAX_DEPTH { let Ok(bytes) = store_read(&key(&path), MAX_IMAGE) else { @@ -49,7 +51,7 @@ pub fn resolve(cwd: &[u8], name: &[u8], argv: &[Vec]) -> Result Option> { let name = read_path(guest, path)?; - if name.first() == Some(&b'/') { - return Some(visible(b"/", &name)); - } - let base = base_of(guest, dirfd)?; - Some(visible(&base, &name)) + // The *at calls act on the name, so its own last component is not followed. + let full = match name.first() == Some(&b'/') { + true => visible(b"/", &name), + false => visible(&base_of(guest, dirfd)?, &name), + }; + Some(guest.links.follow(full, false)) } fn base_of(guest: &Guest, dirfd: u64) -> Option> { diff --git a/userland/capsule_linux/src/linux/file/meta/query.rs b/userland/capsule_linux/src/linux/file/meta/query.rs index a88df72755..fff8fefedb 100644 --- a/userland/capsule_linux/src/linux/file/meta/query.rs +++ b/userland/capsule_linux/src/linux/file/meta/query.rs @@ -27,20 +27,27 @@ pub fn access(guest: &Guest, path_ptr: u64) -> u64 { let Some(name) = path::read_path(guest, path_ptr) else { return errno::fail(errno::EFAULT); }; - let full = resolve::visible(&guest.cwd, &name); + let full = guest.links.follow(resolve::visible(&guest.cwd, &name), true); match stat::look(&full) { Some(_) => errno::ok(0), None => errno::fail(errno::ENOENT), } } -/// The store holds no symbolic links, so a path that exists is not one and a -/// path that does not exist is absent. -pub fn readlink(guest: &Guest, path_ptr: u64) -> u64 { +/// A link's target, from the image's table. A path that exists and is not a +/// link is EINVAL, as Linux answers. +pub fn readlink(guest: &Guest, path_ptr: u64, buf: u64, len: u64) -> u64 { let Some(name) = path::read_path(guest, path_ptr) else { return errno::fail(errno::EFAULT); }; - let full = resolve::visible(&guest.cwd, &name); + let full = guest.links.follow(resolve::visible(&guest.cwd, &name), false); + if let Some(to) = guest.links.target(&full) { + let n = to.len().min(len as usize); + return match guest.write(buf, &to[..n]) < n as i64 { + true => errno::fail(errno::EFAULT), + false => errno::ok(n as u64), + }; + } match stat::look(&full) { Some(_) => errno::fail(errno::EINVAL), None => errno::fail(errno::ENOENT), diff --git a/userland/capsule_linux/src/linux/file/meta/stat.rs b/userland/capsule_linux/src/linux/file/meta/stat.rs index bc902a52d1..9b955b9d72 100644 --- a/userland/capsule_linux/src/linux/file/meta/stat.rs +++ b/userland/capsule_linux/src/linux/file/meta/stat.rs @@ -52,7 +52,7 @@ pub fn newfstatat(guest: &mut Guest, dirfd: u64, path_ptr: u64, out: u64) -> u64 if dirfd != AT_FDCWD { return errno::fail(errno::ENOSYS); } - let full = resolve::visible(&guest.cwd, &name); + let full = guest.links.follow(resolve::visible(&guest.cwd, &name), true); match look(&full) { Some((size, is_dir)) => write_out(guest, out, size, is_dir), None => errno::fail(errno::ENOENT), diff --git a/userland/capsule_linux/src/linux/file/open.rs b/userland/capsule_linux/src/linux/file/open.rs index 20026d90a3..a36d29a0b0 100644 --- a/userland/capsule_linux/src/linux/file/open.rs +++ b/userland/capsule_linux/src/linux/file/open.rs @@ -32,7 +32,7 @@ pub fn openat(guest: &mut Guest, dirfd: u64, path_ptr: u64, flags: u64) -> u64 { Ok(base) => base, Err(e) => return e, }; - let full = resolve::visible(&base, &name); + let full = guest.links.follow(resolve::visible(&base, &name), true); let got = match store::stat(&resolve::key(&full)).ok() { Some((_, true)) => dir::open(guest, full), Some((_, false)) if flags & O_DIRECTORY != 0 => errno::fail(errno::ENOTDIR), diff --git a/userland/capsule_linux/src/linux/guest/fork_state.rs b/userland/capsule_linux/src/linux/guest/fork_state.rs index 557befe470..3a7cd9985d 100644 --- a/userland/capsule_linux/src/linux/guest/fork_state.rs +++ b/userland/capsule_linux/src/linux/guest/fork_state.rs @@ -45,6 +45,7 @@ impl Guest { g.pgid = self.pgid; g.sid = self.sid; g.umask = self.umask; + g.links = self.links.clone(); g } } diff --git a/userland/capsule_linux/src/linux/guest/handle.rs b/userland/capsule_linux/src/linux/guest/handle.rs index aeb0e1f534..b660daeb12 100644 --- a/userland/capsule_linux/src/linux/guest/handle.rs +++ b/userland/capsule_linux/src/linux/guest/handle.rs @@ -60,8 +60,7 @@ pub struct Guest { /// Process group and session. pub pgid: u32, pub sid: u32, - /// Remembered, not enforced: the store does not apply it when it creates a - /// file. + /// Remembered, not enforced: the store does not apply it to a new file. pub umask: u16, /// Children forked while answering, for the serve loop to adopt. pub forked: Vec, @@ -71,4 +70,6 @@ pub struct Guest { pub waiting: Option<(u64, u64, u32)>, /// A read parked on an empty pipe: its buffer slot, where, how much, who. pub pipe_wait: Option<(usize, u64, u64, u32)>, + /// The image's symbolic links, read once and shared by the family. + pub links: alloc::rc::Rc, } diff --git a/userland/capsule_linux/src/linux/guest/handle_new.rs b/userland/capsule_linux/src/linux/guest/handle_new.rs index bb8937dc24..69fd8fc947 100644 --- a/userland/capsule_linux/src/linux/guest/handle_new.rs +++ b/userland/capsule_linux/src/linux/guest/handle_new.rs @@ -54,6 +54,7 @@ impl Guest { ended: Vec::new(), waiting: None, pipe_wait: None, + links: Default::default(), } } } diff --git a/userland/capsule_linux/src/linux/guest/links.rs b/userland/capsule_linux/src/linux/guest/links.rs new file mode 100644 index 0000000000..8af5692009 --- /dev/null +++ b/userland/capsule_linux/src/linux/guest/links.rs @@ -0,0 +1,69 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! Symbolic links, as a table the image carries. +//! +//! The store has files and nothing else, and a distribution leans on links: +//! busybox finds its applets through /bin/ls pointing at /bin/busybox, and +//! libraries are found through their soname links. The image lists its links +//! in /etc/nonos-links, one `path target` a line, and resolution follows them +//! a component at a time. Every result passes through `visible` again, so a +//! link cannot point out of the guest's tree, and a program reached through +//! one is proved by its own path, never the link's. + +use alloc::vec::Vec; + +use crate::linux::file::visible; + +/// Linux gives up at forty; a table this small never legitimately nears it. +const MAX_HOPS: usize = 16; + +#[derive(Default)] +pub struct Links(pub(super) Vec<(Vec, Vec)>); + +impl Links { + /// `path` with every link in it followed; the last component too when + /// `last` is set, which is everything but lstat, readlink and the *at + /// calls that act on a name rather than what it names. + pub fn follow(&self, mut path: Vec, last: bool) -> Vec { + for _ in 0..MAX_HOPS { + let Some((end, target)) = self.first_in(&path, last) else { + return path; + }; + let dir_end = path[..end].iter().rposition(|b| *b == b'/').unwrap_or(0); + let mut joined = match target.first() == Some(&b'/') { + true => Vec::new(), + false => path[..dir_end].to_vec(), + }; + joined.push(b'/'); + joined.extend_from_slice(target); + joined.extend_from_slice(&path[end..]); + path = visible(b"/", &joined); + } + path + } + + /// The target of `path` itself, when it is a link. + pub fn target(&self, path: &[u8]) -> Option<&[u8]> { + self.0.iter().find(|(from, _)| from == path).map(|(_, to)| &to[..]) + } + + fn first_in(&self, path: &[u8], last: bool) -> Option<(usize, &[u8])> { + let ends = path.iter().enumerate().skip(1).filter(|(_, b)| **b == b'/').map(|(i, _)| i); + let whole = last.then_some(path.len()); + ends.chain(whole).find_map(|end| self.target(&path[..end]).map(|t| (end, t))) + } +} diff --git a/userland/capsule_linux/src/linux/guest/links_load.rs b/userland/capsule_linux/src/linux/guest/links_load.rs new file mode 100644 index 0000000000..e71c254399 --- /dev/null +++ b/userland/capsule_linux/src/linux/guest/links_load.rs @@ -0,0 +1,39 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! Reading the image's link table. + +use crate::linux::file::{key, store_read, visible}; + +use super::links::Links; + +const TABLE: &[u8] = b"/etc/nonos-links"; +const MAX_TABLE: u32 = 64 << 10; + +impl Links { + pub fn load() -> Links { + let Ok(raw) = store_read(&key(TABLE), MAX_TABLE) else { + return Links::default(); + }; + let pairs = raw.split(|b| *b == b'\n').filter_map(|line| { + let at = line.iter().position(|b| *b == b' ')?; + let (from, to) = (&line[..at], &line[at + 1..]); + (from.first() == Some(&b'/') && !to.is_empty()) + .then(|| (visible(b"/", from), to.to_vec())) + }); + Links(pairs.collect()) + } +} diff --git a/userland/capsule_linux/src/linux/guest/mod.rs b/userland/capsule_linux/src/linux/guest/mod.rs index fae201c67a..3f99fe7313 100644 --- a/userland/capsule_linux/src/linux/guest/mod.rs +++ b/userland/capsule_linux/src/linux/guest/mod.rs @@ -26,6 +26,8 @@ mod fork_state; mod handle; mod handle_new; mod layout; +mod links; +mod links_load; mod mem; mod mem_copy; mod mem_map; @@ -38,6 +40,7 @@ mod threads; pub use fd::Fd; pub use fd_kind::Kind; pub use handle::Guest; +pub use links::Links; pub use layout::{ BRK_BASE, BRK_LIMIT, EXEC_BASE, INTERP_BASE, MMAP_BASE, MMAP_LIMIT, STACK_SIZE, STACK_TOP, diff --git a/userland/capsule_linux/src/linux/serve/table_file.rs b/userland/capsule_linux/src/linux/serve/table_file.rs index 96fd7f03a8..a065fbb0c4 100644 --- a/userland/capsule_linux/src/linux/serve/table_file.rs +++ b/userland/capsule_linux/src/linux/serve/table_file.rs @@ -14,7 +14,6 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . - //! Calls that name a file or a descriptor. use crate::linux::abi::{nr, nr_path as np}; @@ -68,7 +67,7 @@ pub fn file_ops(guest: &mut Guest, tid: u32, nr: u64, a: [u64; 6]) -> Option file::statx(guest, a[0], a[1], a[4]), np::EPOLL_WAIT => file::epoll_wait(guest, a[0], a[1], a[2]), nr::ACCESS => file::access(guest, a[0]), - nr::READLINK => file::readlink(guest, a[0]), + nr::READLINK => file::readlink(guest, a[0], a[1], a[2]), _ => return None, }) } diff --git a/userland/capsule_linux/src/linux/start.rs b/userland/capsule_linux/src/linux/start.rs index 7511462e0e..b99134992d 100644 --- a/userland/capsule_linux/src/linux/start.rs +++ b/userland/capsule_linux/src/linux/start.rs @@ -45,6 +45,7 @@ pub fn run() -> ! { mk_exit(1) } let mut guest = Guest::new(pid as u32); + guest.links = alloc::rc::Rc::new(super::guest::Links::load()); let code = match start(&mut guest, &launch) { Ok(()) => { say(b"[LINUX] guest running\n"); From c7cde11e078599fd4b6057cc9a956573c99061ef Mon Sep 17 00:00:00 2001 From: eKisNonos Date: Sun, 27 Sep 2026 01:48:29 +0000 Subject: [PATCH 061/244] linux_guests: carry busybox's applet links in a guest-test image The link table lists every applet at the path busybox's own --list-full gives, pointing at /bin/busybox, so a shell finds ls and cat where Alpine puts them. The boot command and the table move to GuestFiles.mk. --- userland/linux_guests/GuestFiles.mk | 19 +++++++++++++++++++ userland/linux_guests/Guests.mk | 9 +-------- 2 files changed, 20 insertions(+), 8 deletions(-) create mode 100644 userland/linux_guests/GuestFiles.mk diff --git a/userland/linux_guests/GuestFiles.mk b/userland/linux_guests/GuestFiles.mk new file mode 100644 index 0000000000..a4ebb80e8e --- /dev/null +++ b/userland/linux_guests/GuestFiles.mk @@ -0,0 +1,19 @@ +# The files a guest-test image carries beside the guests. Included by Guests.mk. + +# The boot program and its arguments, `|` between them, one a line in the file. +LINUX_GUEST_BOOT_LINES ?= /bin/suite +LINUX_GUEST_BOOT_FILE := $(TARGET_DIR)/linux-guests/nonos-boot-guest +.PHONY: nonos-mk-linux-guest-boot +$(LINUX_GUEST_BOOT_FILE): nonos-mk-linux-guest-boot + @mkdir -p $(@D) && printf '%s\n' '$(LINUX_GUEST_BOOT_LINES)' | tr '|' '\n' > $@ +LINUX_GUEST_STORE_DEPS += $(LINUX_GUEST_BOOT_FILE) +LINUX_GUEST_STORE_ENTRIES += --entry /linux/etc/nonos-boot-guest=$(LINUX_GUEST_BOOT_FILE) + +# busybox finds its applets through links: /bin/ls to /bin/busybox and so on, +# at the paths its own --list-full gives. The personality follows the table. +LINUX_GUEST_LINKS := $(TARGET_DIR)/linux-guests/nonos-links +$(LINUX_GUEST_LINKS): userland/capsule_linux/guests/busybox.elf + @mkdir -p $(@D) && ./$< --list-full | grep -v '^bin/busybox$$' | \ + sed 's|^|/|; s|$$| /bin/busybox|' > $@ +LINUX_GUEST_STORE_DEPS += $(LINUX_GUEST_LINKS) +LINUX_GUEST_STORE_ENTRIES += --entry /linux/etc/nonos-links=$(LINUX_GUEST_LINKS) diff --git a/userland/linux_guests/Guests.mk b/userland/linux_guests/Guests.mk index 2bcd19e6cc..08a2ea2ae8 100644 --- a/userland/linux_guests/Guests.mk +++ b/userland/linux_guests/Guests.mk @@ -65,11 +65,4 @@ $(eval $(call LINUX_GUEST,reader,4954,4955)) # Alpine's static busybox, the one app.linux embeds, as a program from the store. $(eval $(call LINUX_GUEST,busybox,4956,4957,userland/capsule_linux/guests/busybox.elf)) -# The boot program and its arguments, `|` between them, one a line in the file. -LINUX_GUEST_BOOT_LINES ?= /bin/suite -LINUX_GUEST_BOOT_FILE := $(TARGET_DIR)/linux-guests/nonos-boot-guest -.PHONY: nonos-mk-linux-guest-boot -$(LINUX_GUEST_BOOT_FILE): nonos-mk-linux-guest-boot - @mkdir -p $(@D) && printf '%s\n' '$(LINUX_GUEST_BOOT_LINES)' | tr '|' '\n' > $@ -LINUX_GUEST_STORE_DEPS += $(LINUX_GUEST_BOOT_FILE) -LINUX_GUEST_STORE_ENTRIES += --entry /linux/etc/nonos-boot-guest=$(LINUX_GUEST_BOOT_FILE) +include $(LINUX_GUESTS_DIR)/GuestFiles.mk From 37166e6b04fddb0dfb83cb5d7217d8aafccffca8 Mon Sep 17 00:00:00 2001 From: eKisNonos Date: Sun, 27 Sep 2026 02:08:05 +0000 Subject: [PATCH 062/244] linux: answer implicit directories, and list links in them The store has no directory entries, so stat of /bin failed and ls /bin found nothing. A key with keys below it is now a directory, and a listing includes the links the image puts in that directory. --- userland/capsule_linux/src/linux/file/dir.rs | 7 +++- .../capsule_linux/src/linux/file/store.rs | 14 +++++++- .../src/linux/guest/links_list.rs | 32 +++++++++++++++++++ userland/capsule_linux/src/linux/guest/mod.rs | 1 + 4 files changed, 52 insertions(+), 2 deletions(-) create mode 100644 userland/capsule_linux/src/linux/guest/links_list.rs diff --git a/userland/capsule_linux/src/linux/file/dir.rs b/userland/capsule_linux/src/linux/file/dir.rs index 9dfe5053c3..838ea60dac 100644 --- a/userland/capsule_linux/src/linux/file/dir.rs +++ b/userland/capsule_linux/src/linux/file/dir.rs @@ -31,7 +31,12 @@ pub fn open(guest: &mut Guest, path: Vec) -> u64 { return errno::fail(errno::EACCES); }; // Cut against the store key, not against the path the guest named. - let names = children(at.as_bytes(), keys); + let mut names = children(at.as_bytes(), keys); + for link in guest.links.names_in(&path) { + if !names.contains(&link) { + names.push(link); + } + } match slot::install(guest, Fd::dir(path, names)) { Some(n) => errno::ok(n), None => errno::fail(errno::EMFILE), diff --git a/userland/capsule_linux/src/linux/file/store.rs b/userland/capsule_linux/src/linux/file/store.rs index 9b215fc3c2..ef1e9f8892 100644 --- a/userland/capsule_linux/src/linux/file/store.rs +++ b/userland/capsule_linux/src/linux/file/store.rs @@ -34,12 +34,24 @@ pub fn write(at: &Key, data: &[u8]) -> Result<(), Fail> { vfs::write_file(mk_getpid(), at.as_bytes(), data) } +/* + * The store keeps files and no directories: /linux/bin exists only as the + * prefix of what is in it. A key that is no file but has keys below it is + * answered as a directory, or `ls /bin` finds nothing to list. + */ pub fn stat(at: &Key) -> Result<(u64, bool), Fail> { - vfs::stat(mk_getpid(), at.as_bytes()) + vfs::stat(mk_getpid(), at.as_bytes()).or_else(|e| implicit_dir(at).map(|_| (0, true)).ok_or(e)) } pub fn stat_full(at: &Key) -> Result<(u64, bool, u64, bool), Fail> { vfs::stat_full(mk_getpid(), at.as_bytes()) + .or_else(|e| implicit_dir(at).map(|_| (0, true, 0, false)).ok_or(e)) +} + +fn implicit_dir(at: &Key) -> Option<()> { + let below = list(at).ok()?; + let prefix = at.as_bytes(); + below.iter().any(|k| k.as_bytes().get(prefix.len()) == Some(&b'/')).then_some(()) } pub fn list(at: &Key) -> Result, Fail> { diff --git a/userland/capsule_linux/src/linux/guest/links_list.rs b/userland/capsule_linux/src/linux/guest/links_list.rs new file mode 100644 index 0000000000..14870072ff --- /dev/null +++ b/userland/capsule_linux/src/linux/guest/links_list.rs @@ -0,0 +1,32 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! Links as a directory listing sees them. + +use alloc::string::String; +use alloc::vec::Vec; + +use super::links::Links; + +impl Links { + /// The names of the links directly inside `dir`, so a listing shows them. + pub fn names_in(&self, dir: &[u8]) -> Vec { + let dir = if dir == b"/" { &b""[..] } else { dir }; + let leaf = |from: &[u8]| from.strip_prefix(dir)?.strip_prefix(b"/").map(|l| l.to_vec()); + let names = self.0.iter().filter_map(|(from, _)| leaf(from)); + names.filter(|l| !l.contains(&b'/')).filter_map(|l| String::from_utf8(l).ok()).collect() + } +} diff --git a/userland/capsule_linux/src/linux/guest/mod.rs b/userland/capsule_linux/src/linux/guest/mod.rs index 3f99fe7313..f67dd09062 100644 --- a/userland/capsule_linux/src/linux/guest/mod.rs +++ b/userland/capsule_linux/src/linux/guest/mod.rs @@ -27,6 +27,7 @@ mod handle; mod handle_new; mod layout; mod links; +mod links_list; mod links_load; mod mem; mod mem_copy; From e14a97974f69c5473219d7047a1c5c8487ba977b Mon Sep 17 00:00:00 2001 From: eKisNonos Date: Sun, 27 Sep 2026 02:08:05 +0000 Subject: [PATCH 063/244] linux: drop the unused EXEC_BASE re-export Nothing reached it through image; it was the capsule's only warning. --- userland/capsule_linux/src/linux/image/mod.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/userland/capsule_linux/src/linux/image/mod.rs b/userland/capsule_linux/src/linux/image/mod.rs index 8a09fbf870..44c688da39 100644 --- a/userland/capsule_linux/src/linux/image/mod.rs +++ b/userland/capsule_linux/src/linux/image/mod.rs @@ -32,5 +32,5 @@ mod stack_guard; mod stack_strings; mod stack_words; -pub use interp::{program, EXEC_BASE}; +pub use interp::program; pub use stack::build; From 2071de92d28adcb2b67be150f26ad14ed7d90900 Mon Sep 17 00:00:00 2001 From: eKisNonos Date: Sun, 27 Sep 2026 02:08:06 +0000 Subject: [PATCH 064/244] linux_guests: take the boot command from a file of arguments The | separator split a shell script at its own pipes, so sh -c ran only the text before the first one. LINUX_GUEST_BOOT_ARGS names a file with one argument a line instead. --- userland/linux_guests/GuestFiles.mk | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/userland/linux_guests/GuestFiles.mk b/userland/linux_guests/GuestFiles.mk index a4ebb80e8e..b93b22d45e 100644 --- a/userland/linux_guests/GuestFiles.mk +++ b/userland/linux_guests/GuestFiles.mk @@ -1,11 +1,13 @@ # The files a guest-test image carries beside the guests. Included by Guests.mk. -# The boot program and its arguments, `|` between them, one a line in the file. -LINUX_GUEST_BOOT_LINES ?= /bin/suite +# The boot program and its arguments, one a line: LINUX_GUEST_BOOT_ARGS names a +# file of them, since a shell script's own | would collide with any separator. +LINUX_GUEST_BOOT_ARGS ?= LINUX_GUEST_BOOT_FILE := $(TARGET_DIR)/linux-guests/nonos-boot-guest .PHONY: nonos-mk-linux-guest-boot $(LINUX_GUEST_BOOT_FILE): nonos-mk-linux-guest-boot - @mkdir -p $(@D) && printf '%s\n' '$(LINUX_GUEST_BOOT_LINES)' | tr '|' '\n' > $@ + @mkdir -p $(@D) && if [ -n '$(LINUX_GUEST_BOOT_ARGS)' ]; then cp '$(LINUX_GUEST_BOOT_ARGS)' $@; \ + else echo /bin/suite > $@; fi LINUX_GUEST_STORE_DEPS += $(LINUX_GUEST_BOOT_FILE) LINUX_GUEST_STORE_ENTRIES += --entry /linux/etc/nonos-boot-guest=$(LINUX_GUEST_BOOT_FILE) From d4466bf44d346f5452915b612594228c844956d4 Mon Sep 17 00:00:00 2001 From: eKisNonos Date: Sun, 27 Sep 2026 02:11:15 +0000 Subject: [PATCH 065/244] vfs: refuse any path with a .. component With the Linux personality's clamp mutated out, a guest reached /capsules through vfs resolving .. itself. normalize now refuses it at all seventeen call sites, so the clamp is no longer the only barrier. fs_proofs pins the refusal; removing it fails three tests. --- .../src/server/handlers/artifact_path.rs | 2 +- .../capsule_vfs/src/server/handlers/chmod.rs | 4 +- .../capsule_vfs/src/server/handlers/copy.rs | 8 +++- .../src/server/handlers/dirstat.rs | 4 +- .../src/server/handlers/journal.rs | 5 +- .../capsule_vfs/src/server/handlers/mkdir.rs | 4 +- .../capsule_vfs/src/server/handlers/open.rs | 4 +- .../src/server/handlers/path/normalize.rs | 19 +++++++- .../capsule_vfs/src/server/handlers/rename.rs | 8 +++- .../capsule_vfs/src/server/handlers/rmdir.rs | 4 +- .../capsule_vfs/src/server/handlers/stat.rs | 4 +- .../src/server/handlers/store_persist.rs | 12 ++++- .../src/server/handlers/store_remove.rs | 4 +- .../src/server/handlers/truncate.rs | 4 +- .../capsule_vfs/src/server/handlers/unlink.rs | 4 +- userland/fs_proofs/src/fuzz_tests.rs | 7 ++- userland/fs_proofs/src/journal_wire_tests.rs | 2 +- userland/fs_proofs/src/lib.rs | 2 +- userland/fs_proofs/src/search_wire_tests.rs | 3 +- userland/fs_proofs/src/vfs_path_tests.rs | 46 +++++++++---------- 20 files changed, 103 insertions(+), 47 deletions(-) diff --git a/userland/capsule_vfs/src/server/handlers/artifact_path.rs b/userland/capsule_vfs/src/server/handlers/artifact_path.rs index bf32e036c0..7cada7c196 100644 --- a/userland/capsule_vfs/src/server/handlers/artifact_path.rs +++ b/userland/capsule_vfs/src/server/handlers/artifact_path.rs @@ -52,7 +52,7 @@ pub(super) fn split_artifact(rest: &[u8]) -> Result<(String, &[u8]), i32> { return Err(EINVAL); } let raw = str::from_utf8(&rest[1..1 + len]).map_err(|_| EINVAL)?; - let path = normalize(raw); + let path = normalize(raw).ok_or(EINVAL)?; if !is_capsule_artifact(&path) { return Err(EINVAL); } diff --git a/userland/capsule_vfs/src/server/handlers/chmod.rs b/userland/capsule_vfs/src/server/handlers/chmod.rs index 90c186b591..9d8b9b7877 100644 --- a/userland/capsule_vfs/src/server/handlers/chmod.rs +++ b/userland/capsule_vfs/src/server/handlers/chmod.rs @@ -40,7 +40,9 @@ pub fn chmod(store: &mut Store, req: Request<'_>, sender_pid: u32) -> Vec { Err(_) => return encode_response(OP_CHMOD, req.flags, req.request_id, EINVAL, &[]), }; let mode = u16::from_le_bytes([rest[1 + len], rest[1 + len + 1]]); - let path = normalize(path); + let Some(path) = normalize(path) else { + return encode_response(OP_CHMOD, req.flags, req.request_id, EINVAL, &[]); + }; if is_read_only(&path) { return encode_response(OP_CHMOD, req.flags, req.request_id, EACCES, &[]); } diff --git a/userland/capsule_vfs/src/server/handlers/copy.rs b/userland/capsule_vfs/src/server/handlers/copy.rs index 356d2c5ef6..72585bda3e 100644 --- a/userland/capsule_vfs/src/server/handlers/copy.rs +++ b/userland/capsule_vfs/src/server/handlers/copy.rs @@ -50,8 +50,12 @@ pub fn copy(store: &mut Store, req: Request<'_>, sender_pid: u32) -> Vec { Err(_) => return encode_response(OP_COPY, req.flags, req.request_id, EINVAL, &[]), }; let recursive = after.get(1 + dl).is_some_and(|&b| b != 0); - let src = normalize(src); - let dst = normalize(dst); + let Some(src) = normalize(src) else { + return encode_response(OP_COPY, req.flags, req.request_id, EINVAL, &[]); + }; + let Some(dst) = normalize(dst) else { + return encode_response(OP_COPY, req.flags, req.request_id, EINVAL, &[]); + }; // Copying out of /capsules is fine; creating or overwriting inside it is not. if is_read_only(&dst) { return encode_response(OP_COPY, req.flags, req.request_id, EACCES, &[]); diff --git a/userland/capsule_vfs/src/server/handlers/dirstat.rs b/userland/capsule_vfs/src/server/handlers/dirstat.rs index 219db6c8f2..bd21c792b9 100644 --- a/userland/capsule_vfs/src/server/handlers/dirstat.rs +++ b/userland/capsule_vfs/src/server/handlers/dirstat.rs @@ -42,7 +42,9 @@ pub fn dirstat(store: &mut Store, req: Request<'_>, sender_pid: u32) -> Vec Ok(s) => s, Err(_) => return encode_response(OP_DIRSTAT, req.flags, req.request_id, EINVAL, &[]), }; - let prefix = normalize(prefix); + let Some(prefix) = normalize(prefix) else { + return encode_response(OP_DIRSTAT, req.flags, req.request_id, EINVAL, &[]); + }; let (files, dirs, bytes, truncated) = store.dirstat(&prefix, DIRSTAT_MAX_NODES); let mut body = Vec::with_capacity(20); body.extend_from_slice(&files.to_le_bytes()); diff --git a/userland/capsule_vfs/src/server/handlers/journal.rs b/userland/capsule_vfs/src/server/handlers/journal.rs index d3955ef163..c492863f0f 100644 --- a/userland/capsule_vfs/src/server/handlers/journal.rs +++ b/userland/capsule_vfs/src/server/handlers/journal.rs @@ -45,7 +45,10 @@ pub fn journal_touch(store: &mut Store, req: Request<'_>, sender_pid: u32) -> Ve Ok(s) => s, Err(_) => return encode_response(OP_JOURNAL_TOUCH, req.flags, req.request_id, EINVAL, &[]), }; - store.journal_touch(&normalize(path)); + let Some(path) = normalize(path) else { + return encode_response(OP_JOURNAL_TOUCH, req.flags, req.request_id, EINVAL, &[]); + }; + store.journal_touch(&path); encode_response(OP_JOURNAL_TOUCH, req.flags, req.request_id, 0, &[]) } diff --git a/userland/capsule_vfs/src/server/handlers/mkdir.rs b/userland/capsule_vfs/src/server/handlers/mkdir.rs index 564f1f1de9..cb5c9b55d6 100644 --- a/userland/capsule_vfs/src/server/handlers/mkdir.rs +++ b/userland/capsule_vfs/src/server/handlers/mkdir.rs @@ -38,7 +38,9 @@ pub fn mkdir(store: &mut Store, req: Request<'_>, sender_pid: u32) -> Vec { Ok(s) => s, Err(_) => return encode_response(OP_MKDIR, req.flags, req.request_id, EINVAL, &[]), }; - let path = normalize(path); + let Some(path) = normalize(path) else { + return encode_response(OP_MKDIR, req.flags, req.request_id, EINVAL, &[]); + }; if is_read_only(&path) { return encode_response(OP_MKDIR, req.flags, req.request_id, EACCES, &[]); } diff --git a/userland/capsule_vfs/src/server/handlers/open.rs b/userland/capsule_vfs/src/server/handlers/open.rs index 46d6b1eed8..1fbb422e29 100644 --- a/userland/capsule_vfs/src/server/handlers/open.rs +++ b/userland/capsule_vfs/src/server/handlers/open.rs @@ -55,7 +55,9 @@ pub fn open(store: &mut Store, req: Request<'_>, sender_pid: u32) -> Vec { let create = flags & O_CREATE != 0; let truncate = flags & O_TRUNC != 0; let append = flags & O_APPEND != 0; - let path = normalize(path); + let Some(path) = normalize(path) else { + return encode_response(OP_OPEN, req.flags, req.request_id, EINVAL, &[]); + }; // The signed artifacts under /capsules open read-only: a write intent is // refused up front, and the handle itself carries no write permission. let read_only = is_read_only(&path); diff --git a/userland/capsule_vfs/src/server/handlers/path/normalize.rs b/userland/capsule_vfs/src/server/handlers/path/normalize.rs index 4066195cb8..83a74a3841 100644 --- a/userland/capsule_vfs/src/server/handlers/path/normalize.rs +++ b/userland/capsule_vfs/src/server/handlers/path/normalize.rs @@ -19,10 +19,25 @@ use alloc::vec; use super::normalize_to_buffer; -pub(crate) fn normalize(path: &str) -> String { +/* + * None when any component is `..`. Every caller that means a parent resolves + * it before calling, as the terminal and the Linux personality do, so vfs + * never has to decide what a climb above some caller's root should reach. + * With the personality's own clamp removed, a guest's `/../capsules` reached + * the capsule tree through here; this is the second barrier. + */ +pub(crate) fn normalize(path: &str) -> Option { + if path.split('/').any(|part| part == "..") { + return None; + } let needed = path.len().saturating_add(1); let mut out = vec![0; needed]; let len = normalize_to_buffer(path.as_bytes(), &mut out); out.truncate(len); - unsafe { String::from_utf8_unchecked(out) } + /* + * SAFETY: `path` is a &str, and normalize_to_buffer only drops whole + * components between '/' bytes and inserts '/', into a buffer one byte + * longer than its input, so every multi-byte sequence it copies is whole. + */ + Some(unsafe { String::from_utf8_unchecked(out) }) } diff --git a/userland/capsule_vfs/src/server/handlers/rename.rs b/userland/capsule_vfs/src/server/handlers/rename.rs index 13417a680c..0fa26b0f68 100644 --- a/userland/capsule_vfs/src/server/handlers/rename.rs +++ b/userland/capsule_vfs/src/server/handlers/rename.rs @@ -47,8 +47,12 @@ pub fn rename(store: &mut Store, req: Request<'_>, sender_pid: u32) -> Vec { Ok(s) => s, Err(_) => return encode_response(OP_RENAME, req.flags, req.request_id, EINVAL, &[]), }; - let old = normalize(old); - let new = normalize(new); + let Some(old) = normalize(old) else { + return encode_response(OP_RENAME, req.flags, req.request_id, EINVAL, &[]); + }; + let Some(new) = normalize(new) else { + return encode_response(OP_RENAME, req.flags, req.request_id, EINVAL, &[]); + }; if is_read_only(&old) || is_read_only(&new) { return encode_response(OP_RENAME, req.flags, req.request_id, EACCES, &[]); } diff --git a/userland/capsule_vfs/src/server/handlers/rmdir.rs b/userland/capsule_vfs/src/server/handlers/rmdir.rs index 159faaf608..4e2be2fa02 100644 --- a/userland/capsule_vfs/src/server/handlers/rmdir.rs +++ b/userland/capsule_vfs/src/server/handlers/rmdir.rs @@ -42,7 +42,9 @@ pub fn rmdir(store: &mut Store, req: Request<'_>, sender_pid: u32) -> Vec { Err(_) => return encode_response(OP_RMDIR, req.flags, req.request_id, EINVAL, &[]), }; let recursive = rest.get(1 + len).is_some_and(|&b| b != 0); - let path = normalize(path); + let Some(path) = normalize(path) else { + return encode_response(OP_RMDIR, req.flags, req.request_id, EINVAL, &[]); + }; if is_read_only(&path) { return encode_response(OP_RMDIR, req.flags, req.request_id, EACCES, &[]); } diff --git a/userland/capsule_vfs/src/server/handlers/stat.rs b/userland/capsule_vfs/src/server/handlers/stat.rs index 26d33787c0..7d59a5c40f 100644 --- a/userland/capsule_vfs/src/server/handlers/stat.rs +++ b/userland/capsule_vfs/src/server/handlers/stat.rs @@ -49,7 +49,9 @@ pub fn stat(store: &mut Store, req: Request<'_>, sender_pid: u32) -> Vec { Ok(s) => s, Err(_) => return encode_response(OP_STAT, req.flags, req.request_id, EINVAL, &[]), }; - let path = normalize(path); + let Some(path) = normalize(path) else { + return encode_response(OP_STAT, req.flags, req.request_id, EINVAL, &[]); + }; match store.stat(&path) { Ok((size, is_dir, mtime, mode)) => { let writable = mode & MODE_WRITE != 0 && !is_read_only(&path); diff --git a/userland/capsule_vfs/src/server/handlers/store_persist.rs b/userland/capsule_vfs/src/server/handlers/store_persist.rs index 2ed05a0ffd..ec50d5d4bc 100644 --- a/userland/capsule_vfs/src/server/handlers/store_persist.rs +++ b/userland/capsule_vfs/src/server/handlers/store_persist.rs @@ -38,11 +38,19 @@ pub fn store_persist(store: &mut Store, req: Request<'_>, sender_pid: u32) -> Ve Ok(s) => s, Err(_) => return encode_response(OP_STORE_PERSIST, req.flags, req.request_id, EINVAL, &[]), }; - let path = normalize(path); + let Some(path) = normalize(path) else { + return encode_response(OP_STORE_PERSIST, req.flags, req.request_id, EINVAL, &[]); + }; let data = match store.persistable(&path, pid) { Ok(d) => d, Err(e) => { - return encode_response(OP_STORE_PERSIST, req.flags, req.request_id, map_store_err(e), &[]) + return encode_response( + OP_STORE_PERSIST, + req.flags, + req.request_id, + map_store_err(e), + &[], + ) } }; match crate::blk::store_write::append(&path, &data) { diff --git a/userland/capsule_vfs/src/server/handlers/store_remove.rs b/userland/capsule_vfs/src/server/handlers/store_remove.rs index 2ba6a7152d..fc64fcdd0e 100644 --- a/userland/capsule_vfs/src/server/handlers/store_remove.rs +++ b/userland/capsule_vfs/src/server/handlers/store_remove.rs @@ -41,7 +41,9 @@ pub fn store_remove(req: Request<'_>, sender_pid: u32) -> Vec { Ok(s) => s, Err(_) => return encode_response(OP_STORE_REMOVE, req.flags, req.request_id, EINVAL, &[]), }; - let path = normalize(path); + let Some(path) = normalize(path) else { + return encode_response(OP_STORE_REMOVE, req.flags, req.request_id, EINVAL, &[]); + }; match crate::blk::store_remove::remove(&path) { Ok(()) => encode_response(OP_STORE_REMOVE, req.flags, req.request_id, 0, &[]), Err(_) => encode_response(OP_STORE_REMOVE, req.flags, req.request_id, EINVAL, &[]), diff --git a/userland/capsule_vfs/src/server/handlers/truncate.rs b/userland/capsule_vfs/src/server/handlers/truncate.rs index 0de379745b..153f437c7a 100644 --- a/userland/capsule_vfs/src/server/handlers/truncate.rs +++ b/userland/capsule_vfs/src/server/handlers/truncate.rs @@ -42,7 +42,9 @@ pub fn truncate(store: &mut Store, req: Request<'_>, sender_pid: u32) -> Vec let mut sz = [0u8; 8]; sz.copy_from_slice(&rest[1 + len..1 + len + 8]); let size = u64::from_le_bytes(sz); - let path = normalize(path); + let Some(path) = normalize(path) else { + return encode_response(OP_TRUNCATE, req.flags, req.request_id, EINVAL, &[]); + }; if is_read_only(&path) { return encode_response(OP_TRUNCATE, req.flags, req.request_id, EACCES, &[]); } diff --git a/userland/capsule_vfs/src/server/handlers/unlink.rs b/userland/capsule_vfs/src/server/handlers/unlink.rs index bd62cdd175..94f295d3ee 100644 --- a/userland/capsule_vfs/src/server/handlers/unlink.rs +++ b/userland/capsule_vfs/src/server/handlers/unlink.rs @@ -38,7 +38,9 @@ pub fn unlink(store: &mut Store, req: Request<'_>, sender_pid: u32) -> Vec { Ok(s) => s, Err(_) => return encode_response(OP_UNLINK, req.flags, req.request_id, EINVAL, &[]), }; - let path = normalize(path); + let Some(path) = normalize(path) else { + return encode_response(OP_UNLINK, req.flags, req.request_id, EINVAL, &[]); + }; if is_read_only(&path) { return encode_response(OP_UNLINK, req.flags, req.request_id, EACCES, &[]); } diff --git a/userland/fs_proofs/src/fuzz_tests.rs b/userland/fs_proofs/src/fuzz_tests.rs index 409c0aa804..e63ffa13fb 100644 --- a/userland/fs_proofs/src/fuzz_tests.rs +++ b/userland/fs_proofs/src/fuzz_tests.rs @@ -108,7 +108,12 @@ fn normalize_never_panics_and_stays_absolute() { let s: Vec = (0..len).map(|_| alphabet[(next(&mut rng) as usize) % alphabet.len()]).collect(); let text = core::str::from_utf8(&s).unwrap(); - let out = normalize(text); + // Refused exactly when a component is `..`. + let Some(out) = normalize(text) else { + assert!(text.split('/').any(|p| p == ".."), "refused without ..: {text:?}"); + continue; + }; + assert!(!text.split('/').any(|p| p == ".."), "accepted with ..: {text:?}"); // Invariants that must hold for every input. assert!(out.starts_with('/'), "not rooted: {out:?}"); assert!(!out.contains("//"), "double slash: {out:?}"); diff --git a/userland/fs_proofs/src/journal_wire_tests.rs b/userland/fs_proofs/src/journal_wire_tests.rs index f4581afac7..13ee2aa201 100644 --- a/userland/fs_proofs/src/journal_wire_tests.rs +++ b/userland/fs_proofs/src/journal_wire_tests.rs @@ -73,7 +73,7 @@ fn journal_list_reply_body_round_trips() { #[test] fn a_name_too_long_for_the_prefix_is_skipped_not_truncated() { let mut s = Store::new(); - let long = crate::vfs_path::normalize(&"a".repeat(255)); + let long = crate::vfs_path::normalize(&"a".repeat(255)).unwrap_or_default(); assert_eq!(long.len(), 256, "normalize prepends a slash, pushing 255 to 256"); s.journal_touch(&long); s.journal_touch("/after"); diff --git a/userland/fs_proofs/src/lib.rs b/userland/fs_proofs/src/lib.rs index 32ee95dd2d..55d7397b30 100644 --- a/userland/fs_proofs/src/lib.rs +++ b/userland/fs_proofs/src/lib.rs @@ -84,7 +84,7 @@ pub fn map_store_err(err: store::StoreError) -> i32 { // Public surface so the included production functions are part of this crate's // API and exercised as such, not flagged unused outside the test build. -pub fn normalize(path: &str) -> String { +pub fn normalize(path: &str) -> Option { vfs_path::normalize(path) } pub fn normalize_to_buffer(src: &[u8], out: &mut [u8]) -> usize { diff --git a/userland/fs_proofs/src/search_wire_tests.rs b/userland/fs_proofs/src/search_wire_tests.rs index 44aa1e44a7..4e9f32d10e 100644 --- a/userland/fs_proofs/src/search_wire_tests.rs +++ b/userland/fs_proofs/src/search_wire_tests.rs @@ -79,7 +79,8 @@ fn search_reply_body_round_trips() { #[test] fn a_hit_too_long_for_the_prefix_is_skipped_not_truncated() { let mut s = Store::new(); - let long = crate::vfs_path::normalize(&(String::from("needle") + &"a".repeat(249))); + let long = crate::vfs_path::normalize(&(String::from("needle") + &"a".repeat(249))) + .unwrap_or_default(); assert_eq!(long.len(), 256, "normalize prepends a slash, pushing 255 to 256"); put(&mut s, &long, b"x"); put(&mut s, "/needle.txt", b"x"); diff --git a/userland/fs_proofs/src/vfs_path_tests.rs b/userland/fs_proofs/src/vfs_path_tests.rs index 15533198ad..1905fa8d15 100644 --- a/userland/fs_proofs/src/vfs_path_tests.rs +++ b/userland/fs_proofs/src/vfs_path_tests.rs @@ -18,50 +18,47 @@ use crate::{is_read_only, normalize}; #[test] fn collapses_duplicate_slashes() { - assert_eq!(normalize("/a//b"), "/a/b"); - assert_eq!(normalize("/a///b//c"), "/a/b/c"); + assert_eq!(normalize("/a//b").as_deref(), Some("/a/b")); + assert_eq!(normalize("/a///b//c").as_deref(), Some("/a/b/c")); } #[test] fn drops_dot_components() { - assert_eq!(normalize("/a/./b"), "/a/b"); - assert_eq!(normalize("/./a"), "/a"); + assert_eq!(normalize("/a/./b").as_deref(), Some("/a/b")); + assert_eq!(normalize("/./a").as_deref(), Some("/a")); } #[test] -fn resolves_parent_components() { - assert_eq!(normalize("/a/../b"), "/b"); - assert_eq!(normalize("/a/b/c/../../d"), "/a/d"); - assert_eq!(normalize("/a/b/.."), "/a"); +fn refuses_parent_components() { + // Callers resolve `..` themselves; vfs never decides what a climb reaches. + for path in ["/a/../b", "/a/b/c/../../d", "/a/b/..", "/..", "/../..", "..", "/linux/../capsules"] { + assert_eq!(normalize(path), None, "{path}"); + } } #[test] fn strips_trailing_slash_except_root() { - assert_eq!(normalize("/a/b/"), "/a/b"); - assert_eq!(normalize("/a/"), "/a"); - assert_eq!(normalize("/"), "/"); + assert_eq!(normalize("/a/b/").as_deref(), Some("/a/b")); + assert_eq!(normalize("/a/").as_deref(), Some("/a")); + assert_eq!(normalize("/").as_deref(), Some("/")); } #[test] fn empty_and_root_normalize_to_root() { - assert_eq!(normalize(""), "/"); - assert_eq!(normalize("//"), "/"); - assert_eq!(normalize("/.."), "/"); - assert_eq!(normalize("/../.."), "/"); + assert_eq!(normalize("").as_deref(), Some("/")); + assert_eq!(normalize("//").as_deref(), Some("/")); } #[test] fn adds_leading_slash_to_relative() { - assert_eq!(normalize("a/b"), "/a/b"); - assert_eq!(normalize("a"), "/a"); + assert_eq!(normalize("a/b").as_deref(), Some("/a/b")); + assert_eq!(normalize("a").as_deref(), Some("/a")); } #[test] -fn parent_of_root_stays_root() { - // A `..` that would escape the root is clamped, never producing a path - // above `/`. - assert_eq!(normalize("/../a"), "/a"); - assert_eq!(normalize("/a/../../b"), "/b"); +fn dots_that_are_names_are_kept() { + // Only a whole `..` component is refused; names containing dots are not. + assert_eq!(normalize("/a/..b/c...").as_deref(), Some("/a/..b/c...")); } #[test] @@ -84,6 +81,7 @@ fn read_only_rejects_lookalikes_and_others() { fn normalized_capsules_path_is_still_guarded() { // The guard runs on the normalized form, so slash tricks cannot smuggle a // write into the protected tree. - assert!(is_read_only(&normalize("/capsules//evil"))); - assert!(is_read_only(&normalize("/capsules/../capsules/evil"))); + assert!(normalize("/capsules//evil").as_deref().is_some_and(is_read_only)); + // A climb back into the tree is not normalised into it: it is refused. + assert_eq!(normalize("/capsules/../capsules/evil"), None); } From fbde30e6a6f80b4b6b248423906cd298610ccf14 Mon Sep 17 00:00:00 2001 From: eKisNonos Date: Sun, 27 Sep 2026 02:14:22 +0000 Subject: [PATCH 066/244] linux: never make unproven file bytes executable through mprotect An executable file mapping had to be proved, but a file mapped readable could then be mprotect'd to exec, running bytes the exec path refuses. Such regions are now marked, and mprotect refuses exec over them. Anonymous memory can still become executable, as a JIT needs. --- .../src/linux/call/mem/map_file.rs | 4 +++ .../capsule_linux/src/linux/call/mem/prot.rs | 9 +++++ .../capsule_linux/src/linux/guest/mem_map.rs | 2 +- userland/capsule_linux/src/linux/guest/mod.rs | 1 + .../capsule_linux/src/linux/guest/region.rs | 3 ++ .../src/linux/guest/region_mark.rs | 35 +++++++++++++++++++ 6 files changed, 53 insertions(+), 1 deletion(-) create mode 100644 userland/capsule_linux/src/linux/guest/region_mark.rs diff --git a/userland/capsule_linux/src/linux/call/mem/map_file.rs b/userland/capsule_linux/src/linux/call/mem/map_file.rs index 1bfd0302df..ca7a3a7bc8 100644 --- a/userland/capsule_linux/src/linux/call/mem/map_file.rs +++ b/userland/capsule_linux/src/linux/call/mem/map_file.rs @@ -57,6 +57,10 @@ pub fn file(guest: &mut Guest, req: &MapReq, at: u64, span: u64) -> u64 { if protect_span(guest, at, span, req.prot) < 0 { return errno::fail(errno::EACCES); } + // Not proved, since nothing asked to run it: it stays that way. + if req.prot & PROT_EXEC == 0 { + guest.mark_unproven(at, span); + } if req.fixed().is_none() { guest.mmap_next += span; } diff --git a/userland/capsule_linux/src/linux/call/mem/prot.rs b/userland/capsule_linux/src/linux/call/mem/prot.rs index 5136faba6f..c19164d343 100644 --- a/userland/capsule_linux/src/linux/call/mem/prot.rs +++ b/userland/capsule_linux/src/linux/call/mem/prot.rs @@ -43,6 +43,15 @@ pub fn mprotect(guest: &mut Guest, addr: u64, len: u64, prot: u64) -> u64 { let Some((start, span)) = span_within(addr, len, STACK_TOP) else { return errno::fail(errno::EINVAL); }; + /* + * A file mapped without exec was never proved, and making it executable + * now would run bytes the exec path would have refused. Anonymous memory + * may still become executable, as a JIT needs; that is the guest's own + * code, confined by its token rather than by provenance. + */ + if prot & PROT_EXEC != 0 && guest.span_unproven(start, span) { + return errno::fail(errno::EPERM); + } if protect_span(guest, start, span, prot) < 0 { return errno::fail(errno::EACCES); } diff --git a/userland/capsule_linux/src/linux/guest/mem_map.rs b/userland/capsule_linux/src/linux/guest/mem_map.rs index a615617e05..d2e495076b 100644 --- a/userland/capsule_linux/src/linux/guest/mem_map.rs +++ b/userland/capsule_linux/src/linux/guest/mem_map.rs @@ -50,7 +50,7 @@ impl Guest { * Remembered because fork copies a guest by walking what its * supervisor gave it. */ - self.regions.push(Region { at: start, len: span, write, exec }); + self.regions.push(Region { at: start, len: span, write, exec, unproven: false }); 0 } } diff --git a/userland/capsule_linux/src/linux/guest/mod.rs b/userland/capsule_linux/src/linux/guest/mod.rs index f67dd09062..35e447eb84 100644 --- a/userland/capsule_linux/src/linux/guest/mod.rs +++ b/userland/capsule_linux/src/linux/guest/mod.rs @@ -36,6 +36,7 @@ mod mem_unmap; mod region; mod region_cut; mod region_find; +mod region_mark; mod threads; pub use fd::Fd; diff --git a/userland/capsule_linux/src/linux/guest/region.rs b/userland/capsule_linux/src/linux/guest/region.rs index 67ff889c33..c92a696606 100644 --- a/userland/capsule_linux/src/linux/guest/region.rs +++ b/userland/capsule_linux/src/linux/guest/region.rs @@ -22,4 +22,7 @@ pub struct Region { pub len: u64, pub write: bool, pub exec: bool, + /// File bytes mapped without exec, so never proved: mprotect may not + /// make them executable later. + pub unproven: bool, } diff --git a/userland/capsule_linux/src/linux/guest/region_mark.rs b/userland/capsule_linux/src/linux/guest/region_mark.rs new file mode 100644 index 0000000000..58f60287fc --- /dev/null +++ b/userland/capsule_linux/src/linux/guest/region_mark.rs @@ -0,0 +1,35 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! Marking file bytes that were never proved, and asking about them. + +use super::handle::Guest; + +impl Guest { + /// Every region inside [at, at + len) holds file bytes nothing proved. + pub fn mark_unproven(&mut self, at: u64, len: u64) { + let end = at.saturating_add(len); + for r in self.regions.iter_mut().filter(|r| r.at >= at && r.at < end) { + r.unproven = true; + } + } + + /// Whether any region overlapping [at, at + len) is unproven file bytes. + pub fn span_unproven(&self, at: u64, len: u64) -> bool { + let end = at.saturating_add(len); + self.regions.iter().any(|r| r.unproven && r.at < end && at < r.at.saturating_add(r.len)) + } +} From 7165cbb58962720412d8ce1e5524dabced80f8e0 Mon Sep 17 00:00:00 2001 From: eKisNonos Date: Sun, 27 Sep 2026 02:14:22 +0000 Subject: [PATCH 067/244] linux_guests: a tampered program the personality must refuse The suite with one byte flipped is packed beside the suite's proofs. The exec probe maps it executable, maps it readable then asks for exec, and execs it; all three must be refused, and the untampered suite mapped the same way is the control that the check still admits. --- tools/nonos-flip-byte | 49 +++++++++++++++++++ userland/linux_guests/GuestFiles.mk | 11 +++++ userland/linux_guests/src/bin/suite.rs | 5 +- userland/linux_guests/src/exec_child.rs | 44 ++++++++++++++++++ userland/linux_guests/src/exec_probe.rs | 62 +++++++++++++++++++++++++ userland/linux_guests/src/lib.rs | 2 + 6 files changed, 172 insertions(+), 1 deletion(-) create mode 100755 tools/nonos-flip-byte create mode 100644 userland/linux_guests/src/exec_child.rs create mode 100644 userland/linux_guests/src/exec_probe.rs diff --git a/tools/nonos-flip-byte b/tools/nonos-flip-byte new file mode 100755 index 0000000000..496f52875a --- /dev/null +++ b/tools/nonos-flip-byte @@ -0,0 +1,49 @@ +#!/usr/bin/env python3 +# NONOS Operating System +# Copyright (C) 2026 NONOS Contributors +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +"""Copy a file with one byte flipped, for a negative test. + +A proof must stop verifying the moment the bytes it measured change, and the +only honest way to show that is to change one and watch the refusal. The copy +keeps its length, so a size check alone cannot catch it. +""" + +import argparse +import sys +from pathlib import Path + + +def main(): + ap = argparse.ArgumentParser(description=__doc__.split("\n", 1)[0]) + ap.add_argument("src", type=Path) + ap.add_argument("dst", type=Path) + ap.add_argument("--at", type=int, default=None, help="byte offset; default: the middle") + a = ap.parse_args() + data = bytearray(a.src.read_bytes()) + if not data: + print(f"nonos-flip-byte: {a.src} is empty", file=sys.stderr) + return 1 + at = len(data) // 2 if a.at is None else a.at + if not 0 <= at < len(data): + print(f"nonos-flip-byte: offset {at} outside {len(data)} bytes", file=sys.stderr) + return 1 + data[at] ^= 0xFF + a.dst.write_bytes(bytes(data)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/userland/linux_guests/GuestFiles.mk b/userland/linux_guests/GuestFiles.mk index b93b22d45e..a4a5edeb7f 100644 --- a/userland/linux_guests/GuestFiles.mk +++ b/userland/linux_guests/GuestFiles.mk @@ -19,3 +19,14 @@ $(LINUX_GUEST_LINKS): userland/capsule_linux/guests/busybox.elf sed 's|^|/|; s|$$| /bin/busybox|' > $@ LINUX_GUEST_STORE_DEPS += $(LINUX_GUEST_LINKS) LINUX_GUEST_STORE_ENTRIES += --entry /linux/etc/nonos-links=$(LINUX_GUEST_LINKS) + +# The suite with one byte flipped, beside the suite's own proofs: a tampered +# library or program that must be refused however it is reached. +LINUX_GUEST_TAMPERED := $(TARGET_DIR)/linux-guests/tampered +$(LINUX_GUEST_TAMPERED): $(linux-guest-suite_BIN) tools/nonos-flip-byte + @mkdir -p $(@D) && $(NONOS_PYTHON) tools/nonos-flip-byte $< $@ +LINUX_GUEST_STORE_DEPS += $(LINUX_GUEST_TAMPERED) +LINUX_GUEST_STORE_ENTRIES += --entry /linux/bin/tampered=$(LINUX_GUEST_TAMPERED) \ + --entry /linux/bin/tampered.nonos_id_cert.bin=$(linux-guest-suite_CERT) \ + --entry /linux/bin/tampered.manifest.bin=$(linux-guest-suite_MANIFEST) \ + --entry /linux/bin/tampered.zk_trailer.bin=$(linux-guest-suite_ATTESTATION) diff --git a/userland/linux_guests/src/bin/suite.rs b/userland/linux_guests/src/bin/suite.rs index 4dcb0345d1..4473cdd062 100644 --- a/userland/linux_guests/src/bin/suite.rs +++ b/userland/linux_guests/src/bin/suite.rs @@ -23,7 +23,9 @@ use std::process::ExitCode; use nonos_linux_guests::report::Report; -use nonos_linux_guests::{bounds_probe, fs_probe, life_probe, native_probe, proc_probe, sep_probe}; +use nonos_linux_guests::{ + bounds_probe, exec_probe, fs_probe, life_probe, native_probe, proc_probe, sep_probe, +}; fn main() -> ExitCode { let mut broken = false; @@ -33,6 +35,7 @@ fn main() -> ExitCode { ("fs", fs_probe::scan), ("proc", proc_self), ("separation", sep_probe::scan), + ("exec", exec_probe::scan), ] { let mut r = Report::new(guest); scan(&mut r); diff --git a/userland/linux_guests/src/exec_child.rs b/userland/linux_guests/src/exec_child.rs new file mode 100644 index 0000000000..2289518b62 --- /dev/null +++ b/userland/linux_guests/src/exec_child.rs @@ -0,0 +1,44 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! The exec half of the tamper probe, in a child of its own. + +use crate::exec_probe::p; +use crate::report::Seen; +use crate::sys::call; + +const FORK: u64 = 57; +const EXECVE: u64 = 59; +const WAIT4: u64 = 61; +const EXIT_GROUP: u64 = 231; + +/// exec replaces the process that calls it, so a child makes the attempt and +/// reports an errno through its status; a status under 100 is a program +/// that ran. +pub fn exec_in_child() -> Seen { + let child = call(FORK, [0; 6]); + if child == 0 { + let argv = [p("/bin/tampered\0"), 0u64]; + let rc = call(EXECVE, [argv[0], argv.as_ptr() as u64, 0, 0, 0, 0]); + let _ = call(EXIT_GROUP, [(100 + (-rc).clamp(0, 100)) as u64, 0, 0, 0, 0, 0]); + } + let mut status = 0i32; + let _ = call(WAIT4, [child as u64, &mut status as *mut i32 as u64, 0, 0, 0, 0]); + match (status >> 8) & 0xff { + code if code >= 100 => Seen::Refused(-(code as i64 - 100)), + code => Seen::Escaped(format!("the tampered program ran and exited {code}")), + } +} diff --git a/userland/linux_guests/src/exec_probe.rs b/userland/linux_guests/src/exec_probe.rs new file mode 100644 index 0000000000..6b92f241d8 --- /dev/null +++ b/userland/linux_guests/src/exec_probe.rs @@ -0,0 +1,62 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! A tampered program, carrying the proofs of the one it was made from. +//! +//! Its bytes differ from what was measured by one flipped byte, so every way +//! of running them must be refused: mapping them executable, mapping them +//! readable and then asking for exec, and exec itself. The untampered suite +//! mapped the same way is the control that the check is not simply closed. + +use crate::report::{Report, Seen}; +use crate::sys::{call, out, MMAP, MPROTECT, OPEN}; + +const PROT_READ: u64 = 1; +const PROT_EXEC: u64 = 4; +const MAP_PRIVATE: u64 = 2; + +pub fn scan(r: &mut Report) { + let fd = call(OPEN, [p("/bin/tampered\0"), 0, 0, 0, 0, 0]); + if fd < 0 { + r.check("open the tampered file", Seen::Refused(fd)); + return; + } + let map = |prot| call(MMAP, [0, 4096, prot, MAP_PRIVATE, fd as u64, 0]); + r.check("map it executable", refused(map(PROT_READ | PROT_EXEC))); + let at = map(PROT_READ); + let upgraded = match at { + a if a < 0 => a, + a => call(MPROTECT, [a as u64, 4096, PROT_READ | PROT_EXEC, 0, 0, 0]), + }; + r.check("map it readable, then make it executable", refused(upgraded)); + r.check("exec it", crate::exec_child::exec_in_child()); + let good = call(OPEN, [p("/bin/suite\0"), 0, 0, 0, 0, 0]); + let proven = call(MMAP, [0, 4096, PROT_READ | PROT_EXEC, MAP_PRIVATE, good as u64, 0]); + let note = + if proven >= 0 { "maps executable" } else { "REFUSED: the check is closed, not proving" }; + out(format!("[GUEST] exec note: the proven suite {note}\n").as_bytes()); +} + +fn refused(rc: i64) -> Seen { + match rc { + rc if rc < 0 => Seen::Refused(rc), + rc => Seen::Escaped(format!("succeeded at {rc:#x}")), + } +} + +pub(crate) fn p(s: &str) -> u64 { + s.as_ptr() as u64 +} diff --git a/userland/linux_guests/src/lib.rs b/userland/linux_guests/src/lib.rs index 6b12211962..737350674f 100644 --- a/userland/linux_guests/src/lib.rs +++ b/userland/linux_guests/src/lib.rs @@ -18,6 +18,8 @@ pub mod arg; pub mod bounds_probe; +pub mod exec_child; +pub mod exec_probe; pub mod fs_paths; pub mod fs_probe; pub mod life_probe; From 52f82deadffcda0af702cfade35642a2ee93fdfe Mon Sep 17 00:00:00 2001 From: eKisNonos Date: Sun, 27 Sep 2026 02:16:48 +0000 Subject: [PATCH 068/244] lean: state T2, and prove it false for the private-leaf verifier T2 is put to a verifier, so the same sentence covers today's kernel and its fix. t2_fails_private_leaf is the forgery: any enrolled leaf, drawn under a forged capsule's context, admits it. t2_holds_public_leaf shows opening the leaf as the capsule's measurement makes it true. Not built here: no Lean toolchain is reachable from this machine. --- verification/lean/Nonos.lean | 1 + verification/lean/Nonos/Stark/T2.lean | 73 +++++++++++++++++++++++++++ 2 files changed, 74 insertions(+) create mode 100644 verification/lean/Nonos/Stark/T2.lean diff --git a/verification/lean/Nonos.lean b/verification/lean/Nonos.lean index 0c7730cd76..b55aa8e2bb 100644 --- a/verification/lean/Nonos.lean +++ b/verification/lean/Nonos.lean @@ -101,6 +101,7 @@ import Nonos.KeyringCustody import Nonos.Spinlock import Nonos.Stark.AssociationSet import Nonos.Stark.Attest +import Nonos.Stark.T2 import Nonos.Stark.AttestSoundness import Nonos.Stark.BootChain import Nonos.Stark.CapabilityBinding diff --git a/verification/lean/Nonos/Stark/T2.lean b/verification/lean/Nonos/Stark/T2.lean new file mode 100644 index 0000000000..415f43b19a --- /dev/null +++ b/verification/lean/Nonos/Stark/T2.lean @@ -0,0 +1,73 @@ +/- +NONOS Operating System +Copyright (C) 2026 NONOS Contributors + +This program is free software: you can redistribute it and/or modify it under +the terms of the GNU Affero General Public License as published by the Free +Software Foundation, either version 3 of the License, or (at your option) any +later version. See . + +T2: a capsule executes only if its measurement is in the enrolled set. + +Stated over a verifier, so the same sentence can be put to the kernel's +current one and to the one that should replace it. Against the private-leaf +verifier it is false, and `t2_fails_private_leaf` is the forgery: an enrolled +leaf and its path, drawn under the forged capsule's own context, admit a +capsule whose measurement is in no tree. Opening the leaf publicly, and +requiring it to be the capsule's measurement, is what makes it true +(`t2_holds_public_leaf`). Nothing here assumes the hash: the failure needs no +collision, and the repair needs none either, since the leaf is no longer +chosen by the prover. +-/ + +import Nonos.Stark.Attest + +namespace Nonos.Stark.T2 + +open Nonos.Stark.Merkle Nonos.Stark.Attest + +variable {α : Type} + +/-- A capsule as the gate sees it: the measurement of its image, and the + context its spawn derives the challenge from. -/ +structure Capsule (α : Type) where + measurement : α + ctx : Nat + +/-- A measurement is enrolled when some path carries it to the root. -/ +def inTree (f : α → α → α) (root : α) (m : α) : Prop := + ∃ p : List (Step α), recompute f m p = root + +/-- T2 for a verifier `acc`: whatever it admits has its own measurement in the + tree the kernel trusts. -/ +def T2 (acc : Attestation α → α → Capsule α → Prop) (f : α → α → α) (root : α) : Prop := + ∀ (c : Capsule α) (a : Attestation α), acc a root c → inTree f root c.measurement + +/-- The kernel's verifier today: the leaf is the prover's to choose. -/ +def privateLeaf (f : α → α → α) (bind : Nat → Nat) : Attestation α → α → Capsule α → Prop := + fun a root c => accepts f bind a root c.ctx + +/-- The verifier T2 needs: the opened leaf is the capsule's own measurement. -/ +def publicLeaf (f : α → α → α) (bind : Nat → Nat) : Attestation α → α → Capsule α → Prop := + fun a root c => a.leaf = c.measurement ∧ accepts f bind a root c.ctx + +/-- T2 is false for the private-leaf verifier: one enrolled leaf is enough to + admit any capsule, enrolled or not. -/ +theorem t2_fails_private_leaf (f : α → α → α) (bind : Nat → Nat) (root leaf : α) + (path : List (Step α)) (hroot : recompute f leaf path = root) + (c : Capsule α) (hc : ¬ inTree f root c.measurement) : + ¬ T2 (privateLeaf f bind) f root := by + intro h + let a : Attestation α := ⟨leaf, path, bind c.ctx⟩ + have hacc : privateLeaf f bind a root c := And.intro hroot rfl + exact hc (h c a hacc) + +/-- T2 holds for the public-leaf verifier. -/ +theorem t2_holds_public_leaf (f : α → α → α) (bind : Nat → Nat) (root : α) : + T2 (publicLeaf f bind) f root := by + intro c a h + have h' : a.leaf = c.measurement ∧ + (recompute f a.leaf a.path = root ∧ a.challenge = bind c.ctx) := h + exact ⟨a.path, by rw [← h'.1]; exact h'.2.1⟩ + +end Nonos.Stark.T2 From ef5387371050d371353ecd301551db29821f39cd Mon Sep 17 00:00:00 2001 From: eKisNonos Date: Sun, 27 Sep 2026 02:29:44 +0000 Subject: [PATCH 069/244] stark_proofs: the public-leaf gate refuses another image's slot Bumps stark-attest to the public-leaf verifier and tests it: an enrolled image verifies, a proof of an enrolled slot under a rogue's context is refused, and the same forgery still verifies under the private-leaf gate. Unpinning the leaf (the witness form) makes the refusal test fail. --- stark-attest | 2 +- userland/stark_proofs/Cargo.lock | 1 + userland/stark_proofs/Cargo.toml | 1 + userland/stark_proofs/src/lib.rs | 4 ++ .../src/private_leaf_forgery_tests.rs | 40 ++++++++++++ .../stark_proofs/src/public_leaf_tests.rs | 61 +++++++++++++++++++ 6 files changed, 108 insertions(+), 1 deletion(-) create mode 100644 userland/stark_proofs/src/private_leaf_forgery_tests.rs create mode 100644 userland/stark_proofs/src/public_leaf_tests.rs diff --git a/stark-attest b/stark-attest index 92e05312ff..39827e11b9 160000 --- a/stark-attest +++ b/stark-attest @@ -1 +1 @@ -Subproject commit 92e05312ffc2392f129ff27685ae62d3a9d7b731 +Subproject commit 39827e11b971b38828184efc939acf4e73677fc7 diff --git a/userland/stark_proofs/Cargo.lock b/userland/stark_proofs/Cargo.lock index 107b198f31..2bbaefbba7 100644 --- a/userland/stark_proofs/Cargo.lock +++ b/userland/stark_proofs/Cargo.lock @@ -140,6 +140,7 @@ checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" name = "stark_proofs" version = "0.3.0" dependencies = [ + "blake3", "nonos-stark", "rayon", ] diff --git a/userland/stark_proofs/Cargo.toml b/userland/stark_proofs/Cargo.toml index 296a7cd2ac..1722510bfb 100644 --- a/userland/stark_proofs/Cargo.toml +++ b/userland/stark_proofs/Cargo.toml @@ -15,6 +15,7 @@ path = "src/lib.rs" nonos-stark = { path = "../../stark-attest/crates/stark-core" } [dev-dependencies] +blake3 = { version = "1.0", default-features = false } rayon = "1" [features] diff --git a/userland/stark_proofs/src/lib.rs b/userland/stark_proofs/src/lib.rs index e2ac332198..44508682bf 100644 --- a/userland/stark_proofs/src/lib.rs +++ b/userland/stark_proofs/src/lib.rs @@ -15,6 +15,10 @@ mod barycentric_tests; #[cfg(test)] mod enroll_batch_tests; #[cfg(test)] +mod private_leaf_forgery_tests; +#[cfg(test)] +mod public_leaf_tests; +#[cfg(test)] mod field_ext_tests; #[cfg(test)] mod field_tests; diff --git a/userland/stark_proofs/src/private_leaf_forgery_tests.rs b/userland/stark_proofs/src/private_leaf_forgery_tests.rs new file mode 100644 index 0000000000..c922714b6b --- /dev/null +++ b/userland/stark_proofs/src/private_leaf_forgery_tests.rs @@ -0,0 +1,40 @@ +// NONOS Operating System (AGPL-3.0-or-later) +//! T2's counterexample, kept as a test so it cannot quietly move: the forgery +//! the public-leaf gate refuses verifies under the private-leaf gate. + +use crate::crypto::stark::air::{build_attestation_trailer_from_set, verify_membership_trailer}; +use crate::crypto::stark::air::{verify_public_trailer, Poseidon, RATE}; +use crate::crypto::stark::attest_params::{EXTRA_BLOWUP_BITS, GRIND_BITS, LOG_ROUNDS, N_QUERIES}; +use crate::crypto::stark::field::Fp; +use crate::public_leaf_tests::{context, root_bytes, set, DEPTH, ROGUE}; + +#[test] +fn the_private_leaf_gate_accepts_the_forgery() { + let s = set(false); + let ctx = context(ROGUE, 7); + let h = Poseidon::new(LOG_ROUNDS, [Fp::ZERO; RATE]); + let forged = build_attestation_trailer_from_set( + &h, + LOG_ROUNDS, + &s, + 2, + &ctx, + N_QUERIES, + GRIND_BITS, + EXTRA_BLOWUP_BITS, + ); + let root = root_bytes(s.root()); + let ok = verify_membership_trailer( + &h, + LOG_ROUNDS, + root, + DEPTH, + &forged, + &ctx, + N_QUERIES, + GRIND_BITS, + EXTRA_BLOWUP_BITS, + ); + assert!(ok, "the private-leaf forgery stopped verifying; T2's counterexample moved"); + assert!(!verify_public_trailer(&root, DEPTH, ROGUE, &forged, &ctx), "old magic accepted"); +} diff --git a/userland/stark_proofs/src/public_leaf_tests.rs b/userland/stark_proofs/src/public_leaf_tests.rs new file mode 100644 index 0000000000..ce02694590 --- /dev/null +++ b/userland/stark_proofs/src/public_leaf_tests.rs @@ -0,0 +1,61 @@ +// NONOS Operating System (AGPL-3.0-or-later) +//! T2 at the gate: an image runs only if its own measurement is enrolled. The +//! forgery is a trailer proving an enrolled slot under a context the forger +//! picked for another image. The private-leaf gate accepts it; the public-leaf +//! gate must refuse it, and the refusal test fails if the gate stops measuring +//! the image itself. + +use crate::crypto::stark::air::{ + build_public_trailer, verify_public_trailer, MeasuredSet, Poseidon, RATE, +}; +use crate::crypto::stark::attest_params::LOG_ROUNDS; +use crate::crypto::stark::field::Fp; +use alloc::vec::Vec; + +pub(crate) const DEPTH: usize = 3; +pub(crate) const ROGUE: &[u8] = b"\x7fELF never enrolled"; + +pub(crate) fn images() -> Vec> { + (0..1usize << DEPTH).map(|k| alloc::vec![0x7f, b'E', b'L', b'F', k as u8]).collect() +} + +pub(crate) fn context(image: &[u8], caps: u64) -> Vec { + let mut ctx = blake3::hash(image).as_bytes().to_vec(); + ctx.extend_from_slice(&caps.to_be_bytes()); + ctx +} + +pub(crate) fn root_bytes(root: [Fp; RATE]) -> [u8; 32] { + let mut out = [0u8; 32]; + for (i, lane) in root.iter().enumerate() { + out[i * 8..i * 8 + 8].copy_from_slice(&lane.value().to_le_bytes()); + } + out +} + +pub(crate) fn set(hybrid: bool) -> MeasuredSet { + let imgs = images(); + let refs: Vec<&[u8]> = imgs.iter().map(|v| v.as_slice()).collect(); + let h = Poseidon::new(LOG_ROUNDS, [Fp::ZERO; RATE]); + if hybrid { + MeasuredSet::commit_hybrid(&h, &refs) + } else { + MeasuredSet::commit(&h, &refs) + } +} + +#[test] +fn an_enrolled_image_verifies() { + let s = set(true); + let img = &images()[2]; + let t = build_public_trailer(&s, 2, &context(img, 7)).unwrap_or_default(); + assert!(verify_public_trailer(&root_bytes(s.root()), DEPTH, img, &t, &context(img, 7))); +} + +#[test] +fn another_images_slot_does_not_admit_a_rogue() { + let s = set(true); + let ctx = context(ROGUE, 7); + let forged = build_public_trailer(&s, 2, &ctx).unwrap_or_default(); + assert!(!verify_public_trailer(&root_bytes(s.root()), DEPTH, ROGUE, &forged, &ctx)); +} From 3007832bafdb9157244620a9530c5b3889ff982d Mon Sep 17 00:00:00 2001 From: eKisNonos Date: Sun, 27 Sep 2026 02:29:45 +0000 Subject: [PATCH 070/244] attest: admit an image only if its own measurement is enrolled (T2) The kernel, bootloader, enroller and secops tools move to NZKSTRK2: the gate measures the ELF and pins it as the opened leaf. Under NZKSTRK1 any enrolled path proved any capsule. Enrolment switches to the hybrid commit, so every root and trailer is regenerated; secops had drifted to three Poseidon rounds and now re-exports the gate's. --- .github/workflows/ci-iso.yml | 2 +- docs | 2 +- mk/20-build.mk | 2 +- .../src/image_format/validate/image.rs | 2 +- .../src/kernel_verify/stark_attest.rs | 20 +--- nonos-bootloader/src/kernel_verify/verify.rs | 2 +- .../tests/kernel_self_attest_poc.rs | 39 +++----- nonos-stark-enroll/src/main.rs | 97 +++++-------------- security/nonos-secops/src/attest/constants.rs | 6 +- security/nonos-secops/src/attest/enroll.rs | 19 ++-- security/nonos-secops/src/attest/verify.rs | 18 +--- src/security/capsule_attest/stark.rs | 68 +++---------- tools/nonos_console.py | 2 +- verification/lean/Nonos/Stark/T2.lean | 5 +- 14 files changed, 72 insertions(+), 212 deletions(-) diff --git a/.github/workflows/ci-iso.yml b/.github/workflows/ci-iso.yml index 4597c78358..05d83eac8a 100644 --- a/.github/workflows/ci-iso.yml +++ b/.github/workflows/ci-iso.yml @@ -88,7 +88,7 @@ jobs: # Build the flashable image. This drives, in order: build and sign every # capsule, enroll the whole set under one STARK policy root and emit each - # NZKSTRK1 trailer, build and dual-sign the kernel, enroll the kernel and + # NZKSTRK2 trailer, build and dual-sign the kernel, enroll the kernel and # embed its STARK self-attestation trailer, build the bootloader with the # stark-kernel-attest check and the enrolled kernel root, and package the # GPT/FAT32 image. diff --git a/docs b/docs index 8672acfa8b..439e94af9d 160000 --- a/docs +++ b/docs @@ -1 +1 @@ -Subproject commit 8672acfa8bcc482ff37268fc52fd5e93834f4f0c +Subproject commit 439e94af9dcf887c881847f00abace03d59d8bc2 diff --git a/mk/20-build.mk b/mk/20-build.mk index d367f56338..c5f3c59cb6 100644 --- a/mk/20-build.mk +++ b/mk/20-build.mk @@ -638,7 +638,7 @@ $(ZK_CAPSULE_LABELS): $(NONOS_VERIFIED_CAPSULE_MKS) Makefile # Capsule attestation policy, transparent post-quantum STARK. The enrollment # produces the policy root over the actual capsule measurements and every -# capsule's NZKSTRK1 trailer together, each re-checked against the exact +# capsule's NZKSTRK2 trailer together, each re-checked against the exact # spawn-gate parse before it is written. The nonos-mk/capsule.mk companion # depends each trailer on this rule, so building any capsule's artifacts # triggers the single enrollment. This replaces the curve enrolled-secret diff --git a/nonos-bootloader/src/image_format/validate/image.rs b/nonos-bootloader/src/image_format/validate/image.rs index f77d59bbf3..9ea7d6547b 100644 --- a/nonos-bootloader/src/image_format/validate/image.rs +++ b/nonos-bootloader/src/image_format/validate/image.rs @@ -24,7 +24,7 @@ pub const ELF_MAGIC: [u8; 4] = [0x7f, b'E', b'L', b'F']; pub const ZK_PROOF_MAGIC: [u8; 4] = [0x4E, 0xC3, 0x5A, 0x50]; // The transparent-STARK kernel self-attestation trailer carries this magic // instead of the boot-binding block above. -pub const STARK_TRAILER_MAGIC: [u8; 8] = *b"NZKSTRK1"; +pub const STARK_TRAILER_MAGIC: [u8; 8] = *b"NZKSTRK2"; pub const MIN_ZK_PROOF_SIZE: usize = 272; pub fn validate_image(data: &[u8]) -> Result, ImageValidationError> { diff --git a/nonos-bootloader/src/kernel_verify/stark_attest.rs b/nonos-bootloader/src/kernel_verify/stark_attest.rs index 56e24062bc..b36c5d9b54 100644 --- a/nonos-bootloader/src/kernel_verify/stark_attest.rs +++ b/nonos-bootloader/src/kernel_verify/stark_attest.rs @@ -22,11 +22,7 @@ //! the prover and the verifier agree by construction. No trusted setup, no //! pairing: trust rests only on the hash. -use nonos_stark::air::{verify_membership_trailer, Poseidon, RATE}; -use nonos_stark::field::Fp; -// One definition, in nonos_stark. Prover and verifier must -// agree exactly; a drift downward in queries or grinding still verifies. -use nonos_stark::attest_params::{GRIND_BITS, LOG_ROUNDS, N_QUERIES, EXTRA_BLOWUP_BITS as EXTRA_BLOWUP_BITS}; +use nonos_stark::air::verify_public_trailer; const DEPTH: usize = 8; const BOOT_EPOCH: u64 = 1; @@ -53,16 +49,6 @@ pub fn verify_kernel_self_attestation(kernel_bytes: &[u8], trailer: &[u8]) -> bo ctx[..32].copy_from_slice(&measurement); ctx[32..40].copy_from_slice(&BOOT_EPOCH.to_be_bytes()); - let hasher = Poseidon::new(LOG_ROUNDS, [Fp::ZERO; RATE]); - verify_membership_trailer( - &hasher, - LOG_ROUNDS, - KERNEL_ATTEST_ROOT, - DEPTH, - trailer, - &ctx, - N_QUERIES, - GRIND_BITS, - EXTRA_BLOWUP_BITS, - ) + // The leaf is measured from the bytes about to run, not taken on trust. + verify_public_trailer(&KERNEL_ATTEST_ROOT, DEPTH, kernel_bytes, trailer, &ctx) } diff --git a/nonos-bootloader/src/kernel_verify/verify.rs b/nonos-bootloader/src/kernel_verify/verify.rs index 6659bea336..19e019c514 100644 --- a/nonos-bootloader/src/kernel_verify/verify.rs +++ b/nonos-bootloader/src/kernel_verify/verify.rs @@ -85,7 +85,7 @@ fn verify_kernel_stark_self_attestation( parsed: &crate::image_format::ParsedImage<'_>, result: &mut CryptoVerifyResult, ) { - const MAGIC: &[u8; 8] = b"NZKSTRK1"; + const MAGIC: &[u8; 8] = b"NZKSTRK2"; let Some(trailer) = parsed.proof_bytes else { log_info("kernel_verify", "no STARK self-attestation trailer present"); return; diff --git a/nonos-bootloader/tools/embed-zk-proof/tests/kernel_self_attest_poc.rs b/nonos-bootloader/tools/embed-zk-proof/tests/kernel_self_attest_poc.rs index d2e5e294ff..a9d92e5298 100644 --- a/nonos-bootloader/tools/embed-zk-proof/tests/kernel_self_attest_poc.rs +++ b/nonos-bootloader/tools/embed-zk-proof/tests/kernel_self_attest_poc.rs @@ -24,18 +24,13 @@ //! byte layout, the same verdict. use embed_zk_proof::{assemble_attested_image, SignedKernel}; -use nonos_stark::air::{ - build_attestation_trailer, enroll_policy_root, verify_membership_trailer, Poseidon, RATE, -}; +use nonos_stark::air::{build_public_trailer, verify_public_trailer, MeasuredSet, Poseidon, RATE}; +use nonos_stark::attest_params::LOG_ROUNDS; use nonos_stark::field::Fp; // The constants the bootloader's stark_attest.rs and the enrollment tool agree on. -const LOG_ROUNDS: u32 = 3; const DEPTH: usize = 8; const LEAVES: usize = 1 << DEPTH; -const N_QUERIES: usize = 32; -const GRIND_BITS: u32 = 16; -const EXTRA_BLOWUP_BITS: u32 = 3; const BOOT_EPOCH: u64 = 1; const PAD_IMAGE: &[u8] = b"\x00NONOS-POLICY-RESERVED-SLOT-v1"; @@ -65,11 +60,9 @@ fn enroll_kernel(kernel_bytes: &[u8]) -> ([u8; 32], Vec) { while images.len() < LEAVES { images.push(PAD_IMAGE); } - let root = root_to_bytes(enroll_policy_root(&hasher, &images)); - let ctx = kernel_context(kernel_bytes); - let trailer = build_attestation_trailer( - &hasher, LOG_ROUNDS, &images, 0, &ctx, N_QUERIES, GRIND_BITS, EXTRA_BLOWUP_BITS, - ); + let set = MeasuredSet::commit_hybrid(&hasher, &images); + let root = root_to_bytes(set.root()); + let trailer = build_public_trailer(&set, 0, &kernel_context(kernel_bytes)).unwrap_or_default(); (root, trailer) } @@ -91,18 +84,7 @@ fn parse_footer(image: &[u8]) -> (Vec, Vec) { /// Verify a trailer exactly as the bootloader does before the jump. fn boot_verify(root: &[u8; 32], kernel_bytes: &[u8], trailer: &[u8]) -> bool { - let hasher = Poseidon::new(LOG_ROUNDS, [Fp::ZERO; RATE]); - verify_membership_trailer( - &hasher, - LOG_ROUNDS, - *root, - DEPTH, - trailer, - &kernel_context(kernel_bytes), - N_QUERIES, - GRIND_BITS, - EXTRA_BLOWUP_BITS, - ) + verify_public_trailer(root, DEPTH, kernel_bytes, trailer, &kernel_context(kernel_bytes)) } fn signed_kernel(kernel_bytes: &[u8]) -> SignedKernel { @@ -117,7 +99,8 @@ fn signed_kernel(kernel_bytes: &[u8]) -> SignedKernel { #[test] fn the_kernel_self_attestation_survives_embed_and_boot_verify() { - let kernel_bytes = b"nonos-kernel code region, the exact bytes the bootloader measures".to_vec(); + let kernel_bytes = + b"nonos-kernel code region, the exact bytes the bootloader measures".to_vec(); // Enroll and embed, the build side. let (root, trailer) = enroll_kernel(&kernel_bytes); @@ -137,7 +120,8 @@ fn the_kernel_self_attestation_survives_embed_and_boot_verify() { #[test] fn a_tampered_kernel_fails_self_attestation() { - let kernel_bytes = b"nonos-kernel code region, the exact bytes the bootloader measures".to_vec(); + let kernel_bytes = + b"nonos-kernel code region, the exact bytes the bootloader measures".to_vec(); let (root, trailer) = enroll_kernel(&kernel_bytes); let mut tampered = kernel_bytes.clone(); @@ -156,7 +140,8 @@ fn a_tampered_kernel_fails_self_attestation() { #[test] fn attack_flip_a_byte_in_the_image_kernel_region() { // An attacker edits the flashed image's kernel code, keeping the trailer. - let kernel_bytes = b"nonos-kernel code region, the exact bytes the bootloader measures".to_vec(); + let kernel_bytes = + b"nonos-kernel code region, the exact bytes the bootloader measures".to_vec(); let (root, trailer) = enroll_kernel(&kernel_bytes); let mut image = assemble_attested_image(&signed_kernel(&kernel_bytes), trailer).data; image[10] ^= 0xFF; diff --git a/nonos-stark-enroll/src/main.rs b/nonos-stark-enroll/src/main.rs index f2235b6cff..8e59c50918 100644 --- a/nonos-stark-enroll/src/main.rs +++ b/nonos-stark-enroll/src/main.rs @@ -27,22 +27,14 @@ use std::process::exit; use std::sync::atomic::{AtomicUsize, Ordering}; use std::thread; -use nonos_stark::air::{ - build_attestation_trailer_from_set, deserialize_proof_ext, stark_verify_ext_blown_bound, - MeasuredSet, MerkleMembership, Poseidon, RATE, -}; +use nonos_stark::air::{build_public_trailer, verify_public_trailer, MeasuredSet, Poseidon, RATE}; +use nonos_stark::attest_params::LOG_ROUNDS; use nonos_stark::field::Fp; -// One definition, in nonos_stark. Prover and verifier must -// agree exactly; a drift downward in queries or grinding still verifies. -use nonos_stark::attest_params::{ - EXTRA_BLOWUP_BITS as EXTRA_BLOWUP, GRIND_BITS, LOG_ROUNDS, N_QUERIES, -}; const POLICY_EPOCH: u64 = 1; const BOOT_EPOCH: u64 = 1; const POLICY_TREE_DEPTH: usize = 8; const LEAVES: usize = 1 << POLICY_TREE_DEPTH; -const MAGIC: &[u8; 8] = b"NZKSTRK1"; /// The padding image for unused policy slots. It begins with a byte no ELF /// starts with, so a real capsule can never measure to a padding leaf. @@ -65,17 +57,6 @@ fn kernel_context(image: &[u8]) -> Vec { ctx } -/// Four little-endian words into a rate-width digest, as the gate reads a root. -fn to_rate(bytes: &[u8]) -> [Fp; RATE] { - let mut out = [Fp::ZERO; RATE]; - for (i, lane) in out.iter_mut().enumerate() { - let mut w = [0u8; 8]; - w.copy_from_slice(&bytes[i * 8..i * 8 + 8]); - *lane = Fp::from_u64(u64::from_le_bytes(w)); - } - out -} - /// A rate-width root serialized as the gate expects to read it back. fn root_to_bytes(root: [Fp; RATE]) -> [u8; 32] { let mut out = [0u8; 32]; @@ -95,36 +76,11 @@ fn padded_images<'a>(images: &[&'a [u8]]) -> Vec<&'a [u8]> { v } -/// The kernel spawn gate's exact parse and verify, run here so an emitted -/// trailer that would be refused at boot is caught now. Returns the verdict. -fn gate_verify(root_bytes: &[u8; 32], trailer: &[u8], context: &[u8]) -> bool { - let depth = POLICY_TREE_DEPTH; - let dir_bytes = depth.div_ceil(8); - let sib_end = 9 + depth * 32; - if trailer.len() < sib_end + dir_bytes - || &trailer[0..8] != MAGIC - || trailer[8] as usize != depth - { - return false; - } - let mut siblings = Vec::with_capacity(depth); - for i in 0..depth { - siblings.push(to_rate(&trailer[9 + i * 32..9 + i * 32 + 32])); - } - let dirs = &trailer[sib_end..sib_end + dir_bytes]; - let directions: Vec = (0..depth).map(|i| (dirs[i / 8] >> (i % 8)) & 1 == 1).collect(); - let Some(proof) = deserialize_proof_ext(&trailer[sib_end + dir_bytes..]) else { - return false; - }; - let root = to_rate(root_bytes); - let air = MerkleMembership::new( - Poseidon::new(LOG_ROUNDS, [Fp::ZERO; RATE]), - LOG_ROUNDS, - root, - siblings, - directions, - ); - stark_verify_ext_blown_bound(&air, &proof, N_QUERIES, GRIND_BITS, EXTRA_BLOWUP, context) +/// The kernel spawn gate's exact verify, run here so an emitted trailer that +/// would be refused at boot is caught now. The same crate function the kernel +/// and the bootloader call, measuring `image` itself. +fn gate_verify(root_bytes: &[u8; 32], image: &[u8], trailer: &[u8], context: &[u8]) -> bool { + verify_public_trailer(root_bytes, POLICY_TREE_DEPTH, image, trailer, context) } /// Enroll `images` under one policy root and emit a trailer for each, bound to @@ -134,9 +90,8 @@ fn enroll(images: &[&[u8]], contexts: &[Vec]) -> ([u8; 32], Vec>) { assert_eq!(images.len(), contexts.len(), "one context per image"); let hasher = Poseidon::new(LOG_ROUNDS, [Fp::ZERO; RATE]); let padded = padded_images(images); - // Measure and commit once. Every trailer opens this same tree, so measuring - // per capsule would hash the whole image set once per capsule. - let set = MeasuredSet::commit(&hasher, &padded); + // Measure and commit once, with the measurement the gate recomputes. + let set = MeasuredSet::commit_hybrid(&hasher, &padded); let root = root_to_bytes(set.root()); let n = contexts.len(); @@ -153,17 +108,11 @@ fn enroll(images: &[&[u8]], contexts: &[Vec]) -> ([u8; 32], Vec>) { break; } let ctx = &contexts[i]; - let trailer = build_attestation_trailer_from_set( - &hasher, - LOG_ROUNDS, - &set, - i, - ctx, - N_QUERIES, - GRIND_BITS, - EXTRA_BLOWUP, - ); - if !gate_verify(&root, &trailer, ctx) { + let Some(trailer) = build_public_trailer(&set, i, ctx) else { + eprintln!("enroll: slot {i} is outside the policy tree"); + exit(2); + }; + if !gate_verify(&root, padded[i], &trailer, ctx) { eprintln!("enroll: trailer {i} failed the gate self-check"); exit(2); } @@ -200,12 +149,18 @@ fn selftest() { let (root, trailers) = enroll(&images, &contexts); for (i, trailer) in trailers.iter().enumerate() { - assert!(gate_verify(&root, trailer, &contexts[i]), "enrolled image {i} refused"); + assert!(gate_verify(&root, images[i], trailer, &contexts[i]), "enrolled image {i} refused"); } let wrong = capsule_context(&cap_a, 0x0000_0000_0000_00FF); - assert!(!gate_verify(&root, &trailers[0], &wrong), "wrong capability context accepted"); - let rogue = capsule_context(b"capsule:rogue never enrolled", 0x7); - assert!(!gate_verify(&root, &trailers[0], &rogue), "rogue measurement accepted"); + assert!(!gate_verify(&root, &cap_a, &trailers[0], &wrong), "wrong capability context accepted"); + let rogue_image = b"capsule:rogue never enrolled"; + let rogue = capsule_context(rogue_image, 0x7); + assert!(!gate_verify(&root, rogue_image, &trailers[0], &rogue), "rogue measurement accepted"); + // The forgery: a fresh proof of cap_a's slot under the rogue's own context. + let hasher = Poseidon::new(LOG_ROUNDS, [Fp::ZERO; RATE]); + let set = MeasuredSet::commit_hybrid(&hasher, &padded_images(&images)); + let forged = build_public_trailer(&set, 0, &rogue).unwrap_or_default(); + assert!(!gate_verify(&root, rogue_image, &forged, &rogue), "an enrolled slot admitted a rogue"); println!("selftest OK: {} images enrolled under root {}", images.len(), hex(&root)); } @@ -302,7 +257,7 @@ fn verify_capsules(root_path: &str, specs: &[String]) { let image = read(parts[1]); let trailer = read(parts[2]); let ctx = capsule_context(&image, caps); - if gate_verify(&root, &trailer, &ctx) { + if gate_verify(&root, &image, &trailer, &ctx) { println!(" ok {}", parts[1]); } else { println!(" FAIL {}", parts[1]); @@ -334,7 +289,7 @@ fn verify_kernel(root_path: &str, image_path: &str, trailer_path: &str) { let image = read(image_path); let trailer = read(trailer_path); let ctx = kernel_context(&image); - if gate_verify(&root, &trailer, &ctx) { + if gate_verify(&root, &image, &trailer, &ctx) { println!("verified kernel self-attestation under root {}", hex(&root)); } else { eprintln!("kernel self-attestation FAILED under root {}", hex(&root)); diff --git a/security/nonos-secops/src/attest/constants.rs b/security/nonos-secops/src/attest/constants.rs index a0f78864e5..0376c3c07a 100644 --- a/security/nonos-secops/src/attest/constants.rs +++ b/security/nonos-secops/src/attest/constants.rs @@ -18,11 +18,9 @@ //! capsule gate all agree on. They are the single source of these numbers for //! the security tools, so a tool cannot drift from the gate it tests. -pub const LOG_ROUNDS: u32 = 3; +// Re-exported, not copied: a copy here had drifted to three rounds. +pub use nonos_stark::attest_params::{EXTRA_BLOWUP_BITS, GRIND_BITS, LOG_ROUNDS, N_QUERIES}; pub const DEPTH: usize = 8; pub const LEAVES: usize = 1 << DEPTH; -pub const N_QUERIES: usize = 32; -pub const GRIND_BITS: u32 = 16; -pub const EXTRA_BLOWUP_BITS: u32 = 3; pub const BOOT_EPOCH: u64 = 1; pub const PAD_IMAGE: &[u8] = b"\x00NONOS-POLICY-RESERVED-SLOT-v1"; diff --git a/security/nonos-secops/src/attest/enroll.rs b/security/nonos-secops/src/attest/enroll.rs index 53ed9640d7..902b57d80e 100644 --- a/security/nonos-secops/src/attest/enroll.rs +++ b/security/nonos-secops/src/attest/enroll.rs @@ -17,9 +17,9 @@ //! Enroll a kernel image and build the trailer that proves its membership. Same //! padding, same commitment, same trailer the build side produces. -use super::constants::{EXTRA_BLOWUP_BITS, GRIND_BITS, LEAVES, LOG_ROUNDS, N_QUERIES, PAD_IMAGE}; +use super::constants::{LEAVES, LOG_ROUNDS, PAD_IMAGE}; use super::context::{kernel_context, root_to_bytes}; -use nonos_stark::air::{build_attestation_trailer, enroll_policy_root, Poseidon, RATE}; +use nonos_stark::air::{build_public_trailer, MeasuredSet, Poseidon, RATE}; use nonos_stark::field::Fp; /// Enroll a kernel image: pad the tree to the gate depth, commit, and build the @@ -30,17 +30,10 @@ pub fn enroll_kernel(kernel_bytes: &[u8]) -> ([u8; 32], Vec) { while images.len() < LEAVES { images.push(PAD_IMAGE); } - let root = root_to_bytes(enroll_policy_root(&hasher, &images)); + // The hybrid set is what the bootloader recomputes the kernel's leaf with. + let set = MeasuredSet::commit_hybrid(&hasher, &images); + let root = root_to_bytes(set.root()); let ctx = kernel_context(kernel_bytes); - let trailer = build_attestation_trailer( - &hasher, - LOG_ROUNDS, - &images, - 0, - &ctx, - N_QUERIES, - GRIND_BITS, - EXTRA_BLOWUP_BITS, - ); + let trailer = build_public_trailer(&set, 0, &ctx).unwrap_or_default(); (root, trailer) } diff --git a/security/nonos-secops/src/attest/verify.rs b/security/nonos-secops/src/attest/verify.rs index 530807ad8f..348a00a5e8 100644 --- a/security/nonos-secops/src/attest/verify.rs +++ b/security/nonos-secops/src/attest/verify.rs @@ -16,23 +16,11 @@ //! Verify a kernel self-attestation exactly as the bootloader does before jump. -use super::constants::{DEPTH, EXTRA_BLOWUP_BITS, GRIND_BITS, LOG_ROUNDS, N_QUERIES}; +use super::constants::DEPTH; use super::context::kernel_context; -use nonos_stark::air::{verify_membership_trailer, Poseidon, RATE}; -use nonos_stark::field::Fp; +use nonos_stark::air::verify_public_trailer; /// Verify a trailer against an enrolled root, the boot-side check byte for byte. pub fn verify_kernel_attestation(root: &[u8; 32], kernel_bytes: &[u8], trailer: &[u8]) -> bool { - let hasher = Poseidon::new(LOG_ROUNDS, [Fp::ZERO; RATE]); - verify_membership_trailer( - &hasher, - LOG_ROUNDS, - *root, - DEPTH, - trailer, - &kernel_context(kernel_bytes), - N_QUERIES, - GRIND_BITS, - EXTRA_BLOWUP_BITS, - ) + verify_public_trailer(root, DEPTH, kernel_bytes, trailer, &kernel_context(kernel_bytes)) } diff --git a/src/security/capsule_attest/stark.rs b/src/security/capsule_attest/stark.rs index 45fd07907d..5c58b7c93d 100644 --- a/src/security/capsule_attest/stark.rs +++ b/src/security/capsule_attest/stark.rs @@ -14,39 +14,20 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -//! The transparent, post-quantum spawn gate. A capsule ships a money-grade STARK -//! proof that its measurement is enrolled under the kernel policy root, bound to the -//! capsule context. The trusted root is the kernel's own, never the trailer's, and -//! the proof is verified at extension-field soundness before a spawn is allowed. This -//! replaces the forgeable pairing gate with a sound one. +//! The transparent, post-quantum spawn gate. A capsule ships a STARK proof that +//! its own measurement is a leaf under the kernel policy root, bound to the +//! capsule context. The gate measures the ELF itself and pins that measurement +//! as the opened leaf, so a proof about any other enrolled capsule is refused. +//! The trusted root is the kernel's own, never the trailer's. use super::error::AttestError; use super::layout::{POLICY_EPOCH, POLICY_TREE_DEPTH}; -use crate::crypto::stark::air::{ - deserialize_proof_ext, stark_verify_ext_blown_bound, MerkleMembership, Poseidon, RATE, -}; -use crate::crypto::stark::field::Fp; -use alloc::vec::Vec; -// One definition, in crate::crypto::stark. Prover and verifier must -// agree exactly; a drift downward in queries or grinding still verifies. -use crate::crypto::stark::attest_params::{GRIND_BITS, LOG_ROUNDS, N_QUERIES, EXTRA_BLOWUP_BITS as EXTRA_BLOWUP}; +use crate::crypto::stark::air::{verify_public_trailer, PUBLIC_TRAILER_MAGIC}; -pub(super) const MAGIC: &[u8; 8] = b"NZKSTRK1"; +pub(super) const MAGIC: &[u8; 8] = PUBLIC_TRAILER_MAGIC; -/// Read four little-endian words into a rate-width Poseidon digest. -fn to_rate(bytes: &[u8]) -> [Fp; RATE] { - let mut out = [Fp::ZERO; RATE]; - for (i, lane) in out.iter_mut().enumerate() { - let mut w = [0u8; 8]; - w.copy_from_slice(&bytes[i * 8..i * 8 + 8]); - *lane = Fp::from_u64(u64::from_le_bytes(w)); - } - out -} - -/// Verify a capsule's transparent-STARK attestation against `policy`, bound to -/// its measurement, its granted capabilities and the epoch. True only for a -/// money-grade membership proof under exactly this root and context. +/// Verify a capsule's attestation against `policy`, bound to its measurement, +/// its granted capabilities and the epoch. /// /// The root is a parameter rather than a lookup, so a capsule built on this /// machine clears exactly the bar a shipped one does. Only whose tree it is @@ -58,42 +39,15 @@ pub(super) fn verify_against( granted_caps: u64, policy: &[u8; 32], ) -> Result<[u8; 32], AttestError> { - let dir_bytes = POLICY_TREE_DEPTH.div_ceil(8); - let sib_end = 9 + POLICY_TREE_DEPTH * 32; - if trailer.len() < sib_end + dir_bytes - || &trailer[0..8] != MAGIC - || trailer[8] as usize != POLICY_TREE_DEPTH - { + if !trailer.starts_with(MAGIC) { return Err(AttestError::Malformed); } - - let mut siblings = Vec::with_capacity(POLICY_TREE_DEPTH); - for i in 0..POLICY_TREE_DEPTH { - siblings.push(to_rate(&trailer[9 + i * 32..9 + i * 32 + 32])); - } - let dirs = &trailer[sib_end..sib_end + dir_bytes]; - let directions: Vec = - (0..POLICY_TREE_DEPTH).map(|i| (dirs[i / 8] >> (i % 8)) & 1 == 1).collect(); - let proof = - deserialize_proof_ext(&trailer[sib_end + dir_bytes..]).ok_or(AttestError::Malformed)?; - - let root = to_rate(policy); - - // Bind the proof to the capsule: its measurement, its capabilities, the epoch. let capsule_hash = *blake3::hash(elf).as_bytes(); let mut ctx = [0u8; 48]; ctx[..32].copy_from_slice(&capsule_hash); ctx[32..40].copy_from_slice(&granted_caps.to_be_bytes()); ctx[40..48].copy_from_slice(&POLICY_EPOCH.to_be_bytes()); - - let air = MerkleMembership::new( - Poseidon::new(LOG_ROUNDS, [Fp::ZERO; RATE]), - LOG_ROUNDS, - root, - siblings, - directions, - ); - if stark_verify_ext_blown_bound(&air, &proof, N_QUERIES, GRIND_BITS, EXTRA_BLOWUP, &ctx) { + if verify_public_trailer(policy, POLICY_TREE_DEPTH, elf, trailer, &ctx) { Ok(capsule_hash) } else { Err(AttestError::Rejected) diff --git a/tools/nonos_console.py b/tools/nonos_console.py index d8192f90ea..e7312b9d6d 100755 --- a/tools/nonos_console.py +++ b/tools/nonos_console.py @@ -51,7 +51,7 @@ ] EM_MACHINES = {62: "x86-64", 183: "AArch64", 243: "RISC-V"} -TRAILER_MAGIC = b"NZKSTRK1" +TRAILER_MAGIC = b"NZKSTRK2" STT_FUNC, STT_OBJECT, SHT_SYMTAB = 2, 1, 2 CAPABILITIES = [ diff --git a/verification/lean/Nonos/Stark/T2.lean b/verification/lean/Nonos/Stark/T2.lean index 415f43b19a..dbc78ff866 100644 --- a/verification/lean/Nonos/Stark/T2.lean +++ b/verification/lean/Nonos/Stark/T2.lean @@ -43,11 +43,12 @@ def inTree (f : α → α → α) (root : α) (m : α) : Prop := def T2 (acc : Attestation α → α → Capsule α → Prop) (f : α → α → α) (root : α) : Prop := ∀ (c : Capsule α) (a : Attestation α), acc a root c → inTree f root c.measurement -/-- The kernel's verifier today: the leaf is the prover's to choose. -/ +/-- The kernel's verifier under NZKSTRK1: the leaf is the prover's to choose. -/ def privateLeaf (f : α → α → α) (bind : Nat → Nat) : Attestation α → α → Capsule α → Prop := fun a root c => accepts f bind a root c.ctx -/-- The verifier T2 needs: the opened leaf is the capsule's own measurement. -/ +/-- The verifier T2 needs, and NZKSTRK2 runs: the opened leaf is the capsule's + own measurement. -/ def publicLeaf (f : α → α → α) (bind : Nat → Nat) : Attestation α → α → Capsule α → Prop := fun a root c => a.leaf = c.measurement ∧ accepts f bind a root c.ctx From 1eac9dc5df6b498522d910b915d9168759b9ef24 Mon Sep 17 00:00:00 2001 From: eKisNonos Date: Sun, 27 Sep 2026 02:31:04 +0000 Subject: [PATCH 071/244] check_attest_params: look where the stale copies were hiding security/ and nonos-bootloader/tools held LOG_ROUNDS = 3 after the gate moved to five, and the checker never read either tree. Its SOURCE path also named a file that no longer exists. --- scripts/check_attest_params.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/scripts/check_attest_params.py b/scripts/check_attest_params.py index f126ba4635..6b4ff67b62 100644 --- a/scripts/check_attest_params.py +++ b/scripts/check_attest_params.py @@ -35,8 +35,11 @@ from pathlib import Path PARAMS = ["LOG_ROUNDS", "N_QUERIES", "GRIND_BITS", "EXTRA_BLOWUP_BITS"] -SOURCE = Path("nonos-stark/src/attest_params.rs") -TREES = ["src", "nonos-bootloader/src", "nonos-stark-enroll/src", "userland"] +SOURCE = Path("stark-attest/crates/stark-core/src/attest_params.rs") +# security/ and the bootloader's tools were missing, and both held a copy +# still at three rounds after the gate moved to five. +TREES = ["src", "nonos-bootloader/src", "nonos-bootloader/tools", "nonos-stark-enroll/src", + "security", "userland"] DECL = re.compile(rf"^\s*(?:pub(?:\([^)]*\))?\s+)?const ({'|'.join(PARAMS)})\s*:", re.M) From b3fbb175741b861fb91d23e035b2ad4925500458 Mon Sep 17 00:00:00 2001 From: eKisNonos Date: Sun, 27 Sep 2026 02:34:20 +0000 Subject: [PATCH 072/244] verification: one register of what the security trusts unproven tools/nonos-assumptions finds ring 0 and bootloader crates, in-tree crypto, hardware features, toolchains and Lean axioms, and fails on one the register does not list or a row nothing still needs. Removing the x86_64 row makes it fail. It runs in the verify workflow. --- .github/workflows/verify.yml | 2 + mk/40-run.mk | 8 ++- tools/nonos-assumptions | 67 +++++++++++++++++++++++++ tools/ratchets/assume_proofs.py | 47 ++++++++++++++++++ tools/ratchets/assume_scan.py | 66 +++++++++++++++++++++++++ verification/ASSUMPTIONS.md | 88 +++++++++++++++++++++++++++++++++ 6 files changed, 277 insertions(+), 1 deletion(-) create mode 100755 tools/nonos-assumptions create mode 100644 tools/ratchets/assume_proofs.py create mode 100644 tools/ratchets/assume_scan.py create mode 100644 verification/ASSUMPTIONS.md diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml index 5846d829b1..44747e41ca 100644 --- a/.github/workflows/verify.yml +++ b/.github/workflows/verify.yml @@ -115,6 +115,8 @@ jobs: run: python3 scripts/check_service_caps.py - name: Attestation parameters live in one place run: python3 scripts/check_attest_params.py + - name: Every assumption the security rests on is registered + run: python3 tools/nonos-assumptions # The committed verification/evidence/EVIDENCE.json is a machine-readable inventory of # everything NONOS proves. Regenerate it from the source tree and fail if it diff --git a/mk/40-run.mk b/mk/40-run.mk index 3577c786c5..cd5e03559a 100644 --- a/mk/40-run.mk +++ b/mk/40-run.mk @@ -340,7 +340,13 @@ nonos-mk-check-caps: @$(NONOS_PYTHON) scripts/check_cap_parity.py @$(NONOS_PYTHON) scripts/check_attest_params.py -nonos-mk-static: nonos-mk-check-caps +# Every assumption the security rests on is named in one register, and a new +# one that is not fails here, before a build. +.PHONY: nonos-mk-check-assumptions +nonos-mk-check-assumptions: + @$(NONOS_PYTHON) tools/nonos-assumptions + +nonos-mk-static: nonos-mk-check-caps nonos-mk-check-assumptions @./nonos-ci/run-static-checks.sh # Ring 0's size against its budget, from the kernel the last build produced. diff --git a/tools/nonos-assumptions b/tools/nonos-assumptions new file mode 100755 index 0000000000..ba36ee6aae --- /dev/null +++ b/tools/nonos-assumptions @@ -0,0 +1,67 @@ +#!/usr/bin/env python3 +# NONOS Operating System +# Copyright (C) 2026 NONOS Contributors +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +"""Fail when the security rests on something the register does not name. + +The register is verification/ASSUMPTIONS.md: one row per thing that is +trusted rather than proven. Most rows are found in the tree, not recalled: +every third-party crate linked into the kernel or the bootloader, every +in-tree cryptographic implementation, the hardware features the kernel +relies on, the toolchains, and any Lean axiom or Verus assumption. A found +assumption with no row fails, and so does a found-kind row nothing matches, +so the register cannot drift in either direction. Rows of kind `stated` +have no detector; they are what a reader must know that no scan can see. +""" + +import argparse +import re +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent / "ratchets")) +import assume_scan # noqa: E402 + +ROW = re.compile(r"^\|\s*`([^`]+)`\s*\|\s*([a-z-]+)\s*\|") + + +def main(): + ap = argparse.ArgumentParser(description=__doc__.split("\n", 1)[0]) + ap.add_argument("--root", type=Path, default=Path(".")) + ap.add_argument("--register", type=Path, default=Path("verification/ASSUMPTIONS.md")) + ap.add_argument("--list", action="store_true", help="print what the scan finds and exit") + a = ap.parse_args() + root = a.root.resolve() + found = assume_scan.found(root) + if a.list: + print("\n".join(sorted(found))) + return 0 + rows = {} + for line in (root / a.register).read_text().splitlines(): + if m := ROW.match(line): + rows[m.group(1)] = m.group(2) + unlisted = sorted(found - rows.keys()) + stale = sorted(k for k, kind in rows.items() if kind != "stated" and k not in found) + for k in unlisted: + print(f"::error::assumption {k} is not in {a.register}", file=sys.stderr) + for k in stale: + print(f"::error::{a.register} lists {k}, which nothing in the tree still rests on", file=sys.stderr) + stated = sum(1 for kind in rows.values() if kind == "stated") + print(f"[assumptions] {len(found)} found, {stated} stated, {len(unlisted)} unlisted, {len(stale)} stale") + return 1 if unlisted or stale else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/ratchets/assume_proofs.py b/tools/ratchets/assume_proofs.py new file mode 100644 index 0000000000..36d574a752 --- /dev/null +++ b/tools/ratchets/assume_proofs.py @@ -0,0 +1,47 @@ +# NONOS Operating System +# Copyright (C) 2026 NONOS Contributors +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +"""The toolchains the proofs are checked with, and any escape hatch inside them.""" + +import re +import tomllib + +EXTRACTORS = ["aeneas", "charon", "kani", "verus"] + + +def tools(root): + out = set() + chan = tomllib.loads((root / "rust-toolchain.toml").read_text())["toolchain"]["channel"] + out.add(f"tool:rustc-{chan}") + lean = (root / "verification/lean/lean-toolchain").read_text().strip() + out.add(f"tool:lean-{lean.rsplit(':', 1)[-1]}") + ver = root / "verification" + # The register is left out, or its own rows would confirm themselves. + kinds = {".md", ".toml", ".py", ".lean", ".rs"} + files = [p for p in ver.rglob("*") if p.is_file() and p.suffix in kinds and p.name != "ASSUMPTIONS.md"] + text = "\n".join(p.read_text(errors="ignore").lower() for p in files) + out |= {f"tool:{x}" for x in EXTRACTORS if re.search(rf"\b{x}\b", text)} + return out + + +def proof_escapes(root): + out = set() + for p in (root / "verification").rglob("*.lean"): + for m in re.finditer(r"^\s*(?:private |protected )?axiom\s+([\w.']+)\s*[{(\[:]", p.read_text(errors="ignore"), re.M): + out.add(f"lean-axiom:{m.group(1)}") + for p in (root / "verification/verus").rglob("*.rs"): + if re.search(r"external_body|\bassume\(", p.read_text(errors="ignore")): + out.add(f"verus-assume:{p.relative_to(root)}") + return out diff --git a/tools/ratchets/assume_scan.py b/tools/ratchets/assume_scan.py new file mode 100644 index 0000000000..db1e15901f --- /dev/null +++ b/tools/ratchets/assume_scan.py @@ -0,0 +1,66 @@ +# NONOS Operating System +# Copyright (C) 2026 NONOS Contributors +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +"""Where the tree says what it trusts: each detector returns assumption ids.""" + +import re +import tomllib + +from assume_proofs import proof_escapes, tools + +CRYPTO_FAMILIES = ["hash", "asymmetric", "pqc", "symmetric"] +CRYPTO_WHOLE = ["zk", "zk_kernel"] +# (id, pattern over kernel source text). Each names a hardware promise. +HARDWARE = [ + ("hw:rdrand", re.compile(r"\brd(rand|seed)\b", re.I)), + ("hw:smep-smap", re.compile(r"\bSM[EA]P\b")), + ("hw:nx", re.compile(r"\bNO_EXECUTE\b|\bNXE\b")), + ("hw:iommu", re.compile(r"\bDMAR\b|\bVT-?d\b", re.I)), + ("hw:tpm", re.compile(r"\bTPM2?_", re.I)), +] + + +def external_deps(manifest, prefix): + d = tomllib.loads(manifest.read_text()) + tables = [d.get("dependencies", {})] + tables += [t.get("dependencies", {}) for t in d.get("target", {}).values()] + out = set() + for table in tables: + for name, spec in table.items(): + if not (isinstance(spec, dict) and "path" in spec): + out.add(f"{prefix}:{name}") + return out + + +def crypto_impls(root): + base = root / "src" / "crypto" + out = {f"prim:crypto/{w}" for w in CRYPTO_WHOLE if (base / w).exists()} + for fam in CRYPTO_FAMILIES: + for p in sorted((base / fam).iterdir()): + if p.name != "mod.rs": + out.add(f"prim:crypto/{fam}/{p.stem}") + out.add("prim:stark-core") + return out + + +def hardware(root): + text = "\n".join(p.read_text(errors="ignore") for p in (root / "src").rglob("*.rs")) + return {hid for hid, pat in HARDWARE if pat.search(text)} + + +def found(root): + return (external_deps(root / "Cargo.toml", "crate") + | external_deps(root / "nonos-bootloader/Cargo.toml", "boot-crate") + | crypto_impls(root) | hardware(root) | tools(root) | proof_escapes(root)) diff --git a/verification/ASSUMPTIONS.md b/verification/ASSUMPTIONS.md new file mode 100644 index 0000000000..bf625a3d6e --- /dev/null +++ b/verification/ASSUMPTIONS.md @@ -0,0 +1,88 @@ +# What NØNOS trusts without proof + +Everything the security claims rest on that no theorem, test or check in this +tree establishes. `tools/nonos-assumptions` rebuilds the found rows from the +tree and fails when one is missing here or listed here but gone. `stated` rows +have no detector. This file is one list by design, so it is longer than the +75-line rule allows. + +| id | kind | what is trusted | +|---|---|---| +| `stated:cpu-isa` | stated | The CPU implements x86-64 paging, rings, SYSCALL/SYSRET, SWAPGS and the TSS as documented. | +| `stated:firmware` | stated | UEFI firmware is honest until ExitBootServices, and the Secure Boot keys are the owner's. | +| `stated:dma-no-iommu` | stated | With no IOMMU, every DMA-capable device and its driver capsule can reach all of physical memory. | +| `stated:physical` | stated | No attacker with bus, JTAG or cold-boot access to the machine. | +| `stated:hash-collision` | stated | BLAKE3, SHA-2, SHA-3 and the width-8 Poseidon are collision resistant. | +| `stated:fri-soundness` | stated | The FRI proximity bound the STARK soundness figures use holds at these parameters. | +| `stated:lean-kernel` | stated | Lean's kernel and its three standard axioms (propext, Classical.choice, Quot.sound) are sound. | +| `stated:extraction` | stated | Charon and Aeneas lower MIR faithfully, so an extracted definition is the kernel function. | +| `stated:alpine-busybox` | stated | The busybox guest test images carry is Alpine's static build, trusted as built by Alpine. | +| `crate:bitflags` | crate | Third-party code linked into ring 0. | +| `crate:bitvec` | crate | Third-party code linked into ring 0. | +| `crate:blake3` | crate | Third-party code linked into ring 0; the capsule and kernel measurement. | +| `crate:curve25519-dalek` | crate | Third-party code linked into ring 0. | +| `crate:ed25519-dalek` | crate | Third-party code linked into ring 0; classical signature verification. | +| `crate:heapless` | crate | Third-party code linked into ring 0. | +| `crate:lazy_static` | crate | Third-party code linked into ring 0. | +| `crate:linked_list_allocator` | crate | Third-party code linked into ring 0; the kernel heap. | +| `crate:sha2` | crate | Third-party code linked into ring 0. | +| `crate:sha3` | crate | Third-party code linked into ring 0. | +| `crate:smallvec` | crate | Third-party code linked into ring 0. | +| `crate:smoltcp` | crate | Third-party code linked into ring 0 where a profile enables it. | +| `crate:spin` | crate | Third-party code linked into ring 0; every kernel lock. | +| `crate:volatile` | crate | Third-party code linked into ring 0. | +| `crate:x25519-dalek` | crate | Third-party code linked into ring 0. | +| `crate:x86_64` | crate | Third-party code linked into ring 0; page tables and descriptor tables. | +| `boot-crate:bitflags` | boot-crate | Third-party code in the bootloader. | +| `boot-crate:blake3` | boot-crate | Third-party code in the bootloader; the kernel measurement. | +| `boot-crate:bootloader_api` | boot-crate | Third-party code in the bootloader. | +| `boot-crate:curve25519-dalek` | boot-crate | Third-party code in the bootloader. | +| `boot-crate:ed25519-dalek` | boot-crate | Third-party code in the bootloader; the kernel signature. | +| `boot-crate:goblin` | boot-crate | Third-party code in the bootloader; ELF parsing of the kernel. | +| `boot-crate:heapless` | boot-crate | Third-party code in the bootloader. | +| `boot-crate:log` | boot-crate | Third-party code in the bootloader. | +| `boot-crate:noto-sans-mono-bitmap` | boot-crate | Third-party code in the bootloader. | +| `boot-crate:r-efi` | boot-crate | Third-party code in the bootloader; UEFI bindings. | +| `boot-crate:sha2` | boot-crate | Third-party code in the bootloader. | +| `boot-crate:spin` | boot-crate | Third-party code in the bootloader. | +| `boot-crate:uefi` | boot-crate | Third-party code in the bootloader; UEFI bindings. | +| `boot-crate:uefi-services` | boot-crate | Third-party code in the bootloader. | +| `boot-crate:uuid` | boot-crate | Third-party code in the bootloader. | +| `boot-crate:xmas-elf` | boot-crate | Third-party code in the bootloader; ELF parsing of the kernel. | +| `boot-crate:zerocopy` | boot-crate | Third-party code in the bootloader. | +| `boot-crate:zeroize` | boot-crate | Third-party code in the bootloader; key material wiping. | +| `prim:crypto/asymmetric/alg_id` | prim | In-tree algorithm identifiers, unproven. | +| `prim:crypto/asymmetric/curve25519` | prim | In-tree Curve25519, unproven. | +| `prim:crypto/asymmetric/ed25519` | prim | In-tree Ed25519, unproven. | +| `prim:crypto/asymmetric/p256` | prim | In-tree P-256, unproven. | +| `prim:crypto/asymmetric/p384` | prim | In-tree P-384, unproven. | +| `prim:crypto/asymmetric/rsa` | prim | In-tree RSA, unproven. | +| `prim:crypto/hash/blake3` | prim | In-tree BLAKE3 glue, unproven. | +| `prim:crypto/hash/sha3` | prim | In-tree SHA-3, unproven. | +| `prim:crypto/hash/sha384` | prim | In-tree SHA-384, unproven. | +| `prim:crypto/hash/sha512` | prim | In-tree SHA-512, unproven. | +| `prim:crypto/hash/unified` | prim | In-tree hash dispatch, unproven. | +| `prim:crypto/pqc/kyber` | prim | In-tree ML-KEM, unproven. | +| `prim:crypto/pqc/mceliece` | prim | In-tree Classic McEliece, unproven. | +| `prim:crypto/pqc/ml_dsa_65` | prim | In-tree ML-DSA-65, the post-quantum half of every signature check, unproven. | +| `prim:crypto/pqc/ntru` | prim | In-tree NTRU, unproven. | +| `prim:crypto/pqc/quantum` | prim | In-tree post-quantum dispatch, unproven. | +| `prim:crypto/pqc/sphincs` | prim | In-tree SPHINCS+, unproven. | +| `prim:crypto/symmetric/aes` | prim | In-tree AES, unproven. | +| `prim:crypto/symmetric/aes_gcm` | prim | In-tree AES-GCM, unproven. | +| `prim:crypto/symmetric/chacha20poly1305` | prim | In-tree ChaCha20-Poly1305, unproven. | +| `prim:crypto/zk` | prim | In-tree zero-knowledge helpers, unproven. | +| `prim:crypto/zk_kernel` | prim | In-tree Pedersen and Sigma proofs for local signing, unproven. | +| `prim:stark-core` | prim | The STARK prover and verifier behind every attestation gate; tested, not proven. | +| `hw:rdrand` | hw | RDRAND and RDSEED return unpredictable values. | +| `hw:smep-smap` | hw | SMEP and SMAP stop ring 0 executing or reading user pages. | +| `hw:nx` | hw | The NX bit stops execution from data pages. | +| `hw:iommu` | hw | VT-d translates and faults device DMA as its tables say. | +| `hw:tpm` | hw | The TPM keeps its counters monotonic and its keys inside. | +| `tool:rustc-nightly-2026-01-16` | tool | The compiler, a nightly, generates what the source says. | +| `tool:lean-v4.15.0` | tool | The Lean toolchain checks proofs soundly. | +| `tool:aeneas` | tool | The extractor from Rust to Lean. | +| `tool:charon` | tool | The MIR front end the extractor reads. | +| `tool:kani` | tool | The bounded model checker behind the Kani harnesses. | +| `tool:verus` | tool | The verifier behind the Verus proofs. | +| `lean-axiom:core.option.Option.ok_or` | lean-axiom | Aeneas's opaque model of `Option::ok_or`, in the closure of the extracted IRQ and policy theorems. | From 19c0be80370f145f16db1ec0c1c27768eca64db8 Mon Sep 17 00:00:00 2001 From: eKisNonos Date: Sun, 27 Sep 2026 02:36:07 +0000 Subject: [PATCH 073/244] scripts: name every control that exists and does not run Gate-shaped functions nothing calls, security policy values nothing reads, and checks that log what they would refuse. 78 today, listed in a shrink-only baseline and printed by name after every kernel build. The self-test plants one of each shape and expects all three. --- .github/workflows/verify.yml | 2 + mk/20-build.mk | 1 + scripts/baselines/unenforced.txt | 78 ++++++++++++++++++++++++++++++++ scripts/check_unenforced.py | 73 ++++++++++++++++++++++++++++++ scripts/unenforced_scan.py | 70 ++++++++++++++++++++++++++++ 5 files changed, 224 insertions(+) create mode 100644 scripts/baselines/unenforced.txt create mode 100755 scripts/check_unenforced.py create mode 100644 scripts/unenforced_scan.py diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml index 44747e41ca..cd514525e0 100644 --- a/.github/workflows/verify.yml +++ b/.github/workflows/verify.yml @@ -117,6 +117,8 @@ jobs: run: python3 scripts/check_attest_params.py - name: Every assumption the security rests on is registered run: python3 tools/nonos-assumptions + - name: No new control that exists and does not run + run: python3 scripts/check_unenforced.py && python3 scripts/check_unenforced.py --self-test # The committed verification/evidence/EVIDENCE.json is a machine-readable inventory of # everything NONOS proves. Regenerate it from the source tree and fail if it diff --git a/mk/20-build.mk b/mk/20-build.mk index c5f3c59cb6..16398a2bec 100644 --- a/mk/20-build.mk +++ b/mk/20-build.mk @@ -872,6 +872,7 @@ define nonos_kernel_build RUSTUP_TOOLCHAIN=$(TOOLCHAIN) \ $(CARGO) build $(KERNEL_BUILD_FLAGS) \ --no-default-features --features $(2) + @$(NONOS_PYTHON) scripts/check_unenforced.py --list endef # Kernel ELF artefact rule, no-features default (resolves to diff --git a/scripts/baselines/unenforced.txt b/scripts/baselines/unenforced.txt new file mode 100644 index 0000000000..043bde2c38 --- /dev/null +++ b/scripts/baselines/unenforced.txt @@ -0,0 +1,78 @@ +src/arch/x86_64/boot/validation/cpu.rs:20 validate_cpu_features +src/arch/x86_64/boot/validation/memory.rs:21 validate_memory +src/arch/x86_64/multiboot/modules_acpi.rs:59 verify_extended_checksum +src/arch/x86_64/pci/device/capabilities_errors.rs:24 check_and_clear_errors +src/arch/x86_64/uefi/crc.rs:67 verify_table +src/arch/x86_64/uefi/secure_boot_status.rs:34 can_modify_keys +src/arch/x86_64/uefi/types/attributes.rs:76 requires_authentication +src/boot/handoff/types/constants.rs:21 validate_cmdline_len +src/capabilities/roles.rs:23 SYSTEM_SERVICE +src/capabilities/roles.rs:25 SANDBOXED_MOD +src/capabilities/roles.rs:27 NETWORK_SERVICE +src/capabilities/roles.rs:29 USER_APP +src/capabilities/roles.rs:31 CRYPTO_SERVICE +src/capabilities/roles.rs:35 DEBUGGER +src/capabilities/token/types/authority_admin.rs:55 can_control_processes +src/crypto/application/nonos_signing.rs:57 verify_manifest_signature +src/crypto/application/nonos_signing.rs:85 verify_manifest_signature +src/crypto/asymmetric/rsa/pss.rs:190 verify_pss_sha384 +src/crypto/asymmetric/rsa/pss.rs:77 verify_pss +src/crypto/core/syscall.rs:51 verify_signature_syscall +src/drivers/pci/security/policy.rs:35 logs +src/drivers/pci/security/policy.rs:46 logs +src/drivers/pci/security/validation.rs:83 verify_bar_not_protected +src/drivers/security/dma.rs:85 validate_sg_list +src/elf/loader/image/image.rs:55 requires_interpreter +src/fs/path/validate.rs:64 require_absolute +src/fs/path/validate.rs:72 require_relative +src/fs/storage/quota.rs:101 check_can_create_file +src/fs/storage/quota.rs:90 check_can_allocate +src/memory/dma/allocator/api.rs:109 validate_dma_address +src/memory/hardening/manager/api.rs:20 validate_memory_permissions +src/memory/hardening/manager/api.rs:43 check_stack_canary +src/memory/hardening/manager/api.rs:46 validate_heap_integrity +src/memory/proof/manager/api.rs:99 verify_memory_proof +src/memory/region/manager/api.rs:71 validate_region +src/memory/safety/manager/api.rs:41 validate_execute +src/memory/safety/manager/stats.rs:22 check_integrity +src/memory/secure_memory/types/security_level.rs:53 requires_secure_scrub +src/process/core/isolation.rs:120 can_signal_process +src/process/core/isolation.rs:141 can_access_shared_memory +src/process/core/isolation.rs:160 can_ptrace_process +src/process/core/isolation.rs:178 enforce_isolation_on_exec +src/process/core/isolation.rs:97 check_isolated_capability +src/process/scheduler/policy_types.rs:98 can_run_on_cpu +src/security/boot/secure_boot/policy.rs:35 logs +src/security/crypto/constant_time/ed25519.rs:21 validate_secret_key +src/security/crypto/constant_time/ed25519.rs:33 validate_signature_format +src/security/crypto/constant_time/x25519.rs:21 validate_shared_secret +src/security/crypto/constant_time/x25519.rs:33 verify_clamping +src/security/crypto_capsule/protocol.rs:47 AEAD_KEY_BYTES +src/security/crypto_capsule/protocol.rs:48 AEAD_NONCE_BYTES +src/security/image_ceiling/admits.rs:58 logs +src/security/kernel_attest.rs:40 verify_kernel_self_attestation +src/security/policy/advanced.rs:209 enforce_wx_policy +src/security/policy/advanced.rs:212 enforce_nx_stack +src/security/policy/capability/types.rs:112 can_delegate +src/security/policy/session/manager.rs:211 check_privilege +src/security/policy/session/types.rs:25 MAX_SESSIONS +src/security/quantum/pqc/engine.rs:100 check_rng_health +src/security/quantum/pqc/engine.rs:117 verify_trust +src/security/quantum/pqc/types.rs:145 enforces_expiry +src/services/caps/check.rs:21 check_service_cap +src/services/caps/check.rs:36 verify_caller_cap +src/syscall/caps/checks/core_exec.rs:21 can_exit +src/syscall/caps/checks/core_exec.rs:29 can_fork +src/syscall/caps/checks/core_exec.rs:33 can_exec +src/syscall/caps/checks/core_exec.rs:37 can_wait +src/syscall/caps/checks/core_exec.rs:41 can_signal +src/syscall/caps/checks/fs.rs:21 can_read +src/syscall/caps/checks/fs.rs:25 can_write +src/syscall/caps/checks/fs.rs:29 can_open_files +src/syscall/caps/checks/fs.rs:33 can_close_files +src/syscall/caps/checks/fs.rs:37 can_stat +src/syscall/caps/checks/fs.rs:41 can_seek +src/syscall/caps/checks/fs.rs:45 can_modify_dirs +src/syscall/caps/checks/fs.rs:49 can_unlink +src/syscall/caps/checks/hardware.rs:25 can_hardware +src/syscall/caps/checks/ipc.rs:21 can_network diff --git a/scripts/check_unenforced.py b/scripts/check_unenforced.py new file mode 100755 index 0000000000..927474d605 --- /dev/null +++ b/scripts/check_unenforced.py @@ -0,0 +1,73 @@ +#!/usr/bin/env python3 +# NONOS Operating System +# Copyright (C) 2026 NONOS Contributors +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +"""Controls that exist and do not run, printed by name, held to shrink. + +Every serious defect here was one: a capability nothing consulted, a gate on +a path nothing took, a ceiling that logged and admitted. The list goes to +zero one fix at a time; scripts/baselines/unenforced.txt may only lose rows. +--list prints every remaining one, which the kernel build does each time. +""" + +import sys +import tempfile +from pathlib import Path + +import gate +from unenforced_scan import unenforced + +BASELINE = Path("scripts/baselines/unenforced.txt") +DECOYS = { + "src/security/decoy.rs": "pub fn verify_decoy() -> bool { true }\n" + "pub const DECOY_LIMIT: u32 = 4;\n" + 'fn f() { log("not enforced, would refuse"); }\n', + "src/user.rs": "use crate::security::decoy::verify_decoy;\n", +} +WANT = ["src/security/decoy.rs:1 verify_decoy", "src/security/decoy.rs:2 DECOY_LIMIT", + "src/security/decoy.rs:3 logs"] + + +def self_test(): + with tempfile.TemporaryDirectory() as d: + root = Path(d) + for rel, text in DECOYS.items(): + (root / rel).parent.mkdir(parents=True, exist_ok=True) + (root / rel).write_text(text) + found = unenforced(root) + if found != WANT: + print(f"unenforced: self-test failed, found {found}") + return 1 + print("unenforced: self-test passed, each of the three shapes was reported") + return 0 + + +def main(): + ap = gate.parser(__doc__) + ap.add_argument("--list", action="store_true", help="print every remaining control by name") + args = ap.parse_args() + if args.self_test: + return self_test() + if args.list: + found = unenforced(args.root) + for s in found: + print(f"[unenforced] {s}") + print(f"[unenforced] {len(found)} controls exist and do not run") + return 0 + return gate.run("unenforced", "a new control that does not run at", unenforced, BASELINE, args) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/unenforced_scan.py b/scripts/unenforced_scan.py new file mode 100644 index 0000000000..b30f2115cb --- /dev/null +++ b/scripts/unenforced_scan.py @@ -0,0 +1,70 @@ +# NONOS Operating System +# Copyright (C) 2026 NONOS Contributors +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +"""The scan behind check_unenforced.py: controls that exist and do not run. + +Three shapes, each one a past defect here: a gate-shaped function nothing +calls, a policy value in a security tree nothing reads, and a check that +logs what it would refuse and then admits. +""" + +import re + +from unreachable_scan import COMMENT, DEFINITION, IMPORT, WORD, sources, unreachable + +GATE = re.compile( + r"^(check|verify|validate|require|ensure|enforce|authori[sz]e|permit|allow|deny|refuse" + r"|reject|guard|gate|admit|is_allowed|may_|can_)" +) +POLICY_TREES = ("src/security", "src/capabilities", "src/syscall/contract", "src/drivers/pci/security") +POLICY = re.compile(r"^\s*pub(?:\([a-z]+\))?\s+(?:const|static)\s+(?:mut\s+)?([A-Z][A-Z0-9_]*)\s*:") +LOGS = re.compile( + r"not enforced|would (have )?(refus|reject|den)|enforce_[a-z_]+\s*:\s*false" + r"|permissive mode|(log|audit)[-_ ]only", + re.I, +) + + +def uncalled_gates(root): + return [s for s in unreachable(root) if GATE.match(s.split(" ", 1)[1])] + + +def unread_policy(root): + defs, seen = [], set() + for rel, lines in sources(root): + policy = str(rel).startswith(POLICY_TREES) + for n, line in enumerate(lines, 1): + m = POLICY.match(line) + if m and policy: + defs.append((m.group(1), f"{rel}:{n}")) + if m or IMPORT.match(line) or COMMENT.match(line) or DEFINITION.match(line): + # A definition names itself; its initialiser may still read others. + seen.update(WORD.findall(line.split("=", 1)[1]) if m and "=" in line else []) + continue + seen.update(re.findall(r"\b[A-Z][A-Z0-9_]*\b", line)) + return [f"{site} {name}" for name, site in defs if name not in seen] + + +def logs_not_refuses(root): + out = [] + for rel, lines in sources(root): + for n, line in enumerate(lines, 1): + if not COMMENT.match(line) and LOGS.search(line): + out.append(f"{rel}:{n} logs") + return out + + +def unenforced(root): + return sorted(uncalled_gates(root) + unread_policy(root) + logs_not_refuses(root)) From 6ea0f363c410e7e7eb7a43922aa9b10266b6e6d1 Mon Sep 17 00:00:00 2001 From: eKisNonos Date: Sun, 27 Sep 2026 02:38:57 +0000 Subject: [PATCH 074/244] tools: a proof coverage ratchet over extracted code Counts ring 0 code lines inside definitions Aeneas extracted that a hand-written theorem names: 163 of 133057 today. It may not shrink; dropping the IRQ refinement file makes it fail. nonos-tcb shares the line counter and budget check, and now fits the file limit. --- mk/40-run.mk | 1 + nonos-verify/src/build.rs | 10 ++++ scripts/baselines/proof-coverage.txt | 1 + tools/nonos-proof-coverage | 63 ++++++++++++++++++++ tools/nonos-tcb | 88 ++++------------------------ tools/ratchets/budget.py | 35 +++++++++++ tools/ratchets/kernel_files.py | 35 +++++++++++ tools/ratchets/proof_cover.py | 66 +++++++++++++++++++++ tools/ratchets/ring0.py | 72 +++++++++++++++++++++++ 9 files changed, 294 insertions(+), 77 deletions(-) create mode 100644 scripts/baselines/proof-coverage.txt create mode 100755 tools/nonos-proof-coverage create mode 100644 tools/ratchets/budget.py create mode 100644 tools/ratchets/kernel_files.py create mode 100644 tools/ratchets/proof_cover.py create mode 100644 tools/ratchets/ring0.py diff --git a/mk/40-run.mk b/mk/40-run.mk index cd5e03559a..21836a5278 100644 --- a/mk/40-run.mk +++ b/mk/40-run.mk @@ -355,6 +355,7 @@ TCB_BUDGET ?= nonos-ci/baselines/tcb-x86_64-capsules.txt .PHONY: nonos-mk-tcb nonos-mk-tcb: @$(NONOS_PYTHON) tools/nonos-tcb --by-module --baseline $(TCB_BUDGET) + @$(NONOS_PYTHON) tools/nonos-proof-coverage --baseline scripts/baselines/proof-coverage.txt MICROKERNEL_BIN := $(TARGET_DIR)/x86_64-nonos/release/nonos-kernel diff --git a/nonos-verify/src/build.rs b/nonos-verify/src/build.rs index c55d600daa..cddca6fff4 100644 --- a/nonos-verify/src/build.rs +++ b/nonos-verify/src/build.rs @@ -56,6 +56,16 @@ pub fn run(root: &str) -> std::io::Result { "ring 0 lines within nonos-ci/baselines/tcb-x86_64-capsules.txt", ); + // The share of ring 0 under a theorem over extracted code; may not shrink. + let proof = + ["tools/nonos-proof-coverage", "--baseline", "scripts/baselines/proof-coverage.txt"]; + let ok = run_logged("python3", &proof, &out.join("proof-coverage.txt")); + rpt.check( + "proof-coverage", + st(ok), + "extracted-code theorem lines at or above scripts/baselines/proof-coverage.txt", + ); + let kbin = "target/x86_64-nonos/release/nonos-kernel"; if Path::new(kbin).exists() { let (_, sz) = capture("size", &[kbin]); diff --git a/scripts/baselines/proof-coverage.txt b/scripts/baselines/proof-coverage.txt new file mode 100644 index 0000000000..9cc2bc3e60 --- /dev/null +++ b/scripts/baselines/proof-coverage.txt @@ -0,0 +1 @@ +163 diff --git a/tools/nonos-proof-coverage b/tools/nonos-proof-coverage new file mode 100755 index 0000000000..fcbfa6a63b --- /dev/null +++ b/tools/nonos-proof-coverage @@ -0,0 +1,63 @@ +#!/usr/bin/env python3 +# NONOS Operating System +# Copyright (C) 2026 NONOS Contributors +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +"""How much of ring 0 a theorem over extracted code constrains. + +A kernel line counts when it is code, the kernel links its file, and it lies +inside a definition Aeneas extracted that a hand-written theorem file names. +The count may grow and may not shrink; the fraction of the TCB is printed +beside it. Models written by hand, however faithful, do not count. +""" + +import argparse +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent / "ratchets")) +import budget # noqa: E402 +from kernel_files import DEPS, kernel_files # noqa: E402 +from proof_cover import covered # noqa: E402 +from ring0 import code_line_numbers, code_lines # noqa: E402 + + +def main(): + ap = argparse.ArgumentParser(description=__doc__.split("\n", 1)[0]) + ap.add_argument("--root", type=Path, default=Path(".")) + ap.add_argument("--lean", type=Path, default=Path("verification/extraction/lean")) + ap.add_argument("--depinfo", type=Path) + ap.add_argument("--target-deps", type=Path, default=DEPS) + ap.add_argument("--baseline", type=Path, help="fail when the count falls below this file's number") + ap.add_argument("--by-file", action="store_true") + a = ap.parse_args() + root = a.root.resolve() + _, files = kernel_files(root, a.depinfo, a.target_deps) + if not files: + return 2 + cover = covered(root, root / a.lean, files, code_line_numbers) + lines = sum(len(v) for v in cover.values()) + tcb = sum(code_lines(f) for f in files) + print(f"[proof] {lines} of {tcb} ring 0 lines under a theorem over extracted code " + f"({100 * lines / tcb:.3f}%), in {len(cover)} files") + if a.by_file: + for f, v in sorted(cover.items()): + print(f"[proof] {f.relative_to(root)} {len(v)}") + if a.baseline: + return budget.held("proof", lines, a.baseline, grows_ok=True) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/nonos-tcb b/tools/nonos-tcb index e0d16c9a23..5ccf8047cd 100755 --- a/tools/nonos-tcb +++ b/tools/nonos-tcb @@ -24,9 +24,8 @@ dep-info for the kernel library says it read. A file inside that set is counted whole, including anything a `cfg` removes from it, so the number is an upper bound at file granularity. -Generated files under OUT_DIR and `include_bytes!` payloads are not source -anyone reviews in the tree and are left out; the count is Rust under src/. -A line counts when it is neither blank nor a comment. +Generated files, `include_bytes!` payloads and path crates such as +nonos-stark are left out: the count is non-comment Rust under src/. """ import argparse @@ -34,84 +33,31 @@ import sys from collections import Counter from pathlib import Path - -def depinfo_sources(depinfo, root): - """The .rs files under root/src that the dep-info lists.""" - text = depinfo.read_text() - first = text.split("\n", 1)[0] - _, _, deps = first.partition(": ") - src = (root / "src").resolve() - out = set() - # Paths with spaces are escaped with a backslash; the kernel has none, so - # a plain split is exact here and anything odd is refused below. - for dep in deps.split(): - p = Path(dep) - if not p.is_absolute(): - p = root / p - p = p.resolve() - if p.suffix == ".rs" and src in p.parents: - out.add(p) - return sorted(out) - - -def code_lines(path): - """Lines that are neither blank nor comment. Block comments may nest in - Rust; the tree does not nest them, and a nested one only miscounts the - lines inside it.""" - n = 0 - in_block = False - for raw in path.read_text(errors="replace").splitlines(): - s = raw.strip() - if in_block: - if "*/" in s: - in_block = False - continue - if not s or s.startswith("//"): - continue - if s.startswith("/*"): - in_block = "*/" not in s - continue - n += 1 - return n - - -def newest_depinfo(target): - found = [] - for d in target.glob("nonos_kernel-*.d"): - # The library's dep-info lists every module; the binary's lists two. - if len(d.read_text().split("\n", 1)[0].split()) > 64: - found.append(d) - if not found: - return None - return max(found, key=lambda d: d.stat().st_mtime) +sys.path.insert(0, str(Path(__file__).resolve().parent / "ratchets")) +import budget # noqa: E402 +from kernel_files import DEPS, kernel_files # noqa: E402 +from ring0 import code_lines # noqa: E402 def main(): ap = argparse.ArgumentParser(description=__doc__.split("\n", 1)[0]) ap.add_argument("--root", type=Path, default=Path("."), help="repository root") ap.add_argument("--depinfo", type=Path, help="kernel library dep-info (.d); default: newest") - ap.add_argument("--target-deps", type=Path, default=Path("target/x86_64-nonos/release/deps")) + ap.add_argument("--target-deps", type=Path, default=DEPS) ap.add_argument("--baseline", type=Path, help="fail when the count exceeds this file's number") ap.add_argument("--by-module", action="store_true", help="print the count per top-level module") a = ap.parse_args() root = a.root.resolve() - depinfo = a.depinfo or newest_depinfo(root / a.target_deps) - if depinfo is None or not depinfo.is_file(): - print("nonos-tcb: no kernel dep-info; build the kernel first", file=sys.stderr) - return 2 - files = depinfo_sources(depinfo, root) + depinfo, files = kernel_files(root, a.depinfo, a.target_deps) if not files: - print(f"nonos-tcb: {depinfo} lists no kernel sources", file=sys.stderr) return 2 per = Counter() - total = 0 for f in files: - n = code_lines(f) - total += n rel = f.relative_to(root / "src") - per[rel.parts[0] if len(rel.parts) > 1 else "(root)"] += n + per[rel.parts[0] if len(rel.parts) > 1 else "(root)"] += code_lines(f) + total = sum(per.values()) print(f"[tcb] {len(files)} files, {total} lines of ring 0 code ({depinfo.name})") if a.by_module: @@ -119,19 +65,7 @@ def main(): print(f"[tcb] {name:24} {n:7}") if a.baseline: - want = a.baseline.read_text().strip() - if not want.isdigit(): - print(f"nonos-tcb: baseline {a.baseline} is not an integer: {want!r}", file=sys.stderr) - return 2 - budget = int(want) - print(f"[tcb] budget {budget}, delta {total - budget:+d}") - if total > budget: - print( - f"::error::ring 0 grew past its budget: {total} > {budget}. " - "Move the code out of the kernel, or justify raising the budget in the PR.", - file=sys.stderr, - ) - return 1 + return budget.held("tcb", total, a.baseline, grows_ok=False) return 0 diff --git a/tools/ratchets/budget.py b/tools/ratchets/budget.py new file mode 100644 index 0000000000..e7074a6628 --- /dev/null +++ b/tools/ratchets/budget.py @@ -0,0 +1,35 @@ +# NONOS Operating System +# Copyright (C) 2026 NONOS Contributors +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +"""A number in CI that moves one way only.""" + +import sys + + +def held(tag, value, baseline, grows_ok): + """0 when `value` has not moved the wrong way past the baseline file's + number, 1 when it has, 2 when the file is not a number.""" + want = baseline.read_text().strip() + if not want.isdigit(): + print(f"{tag}: baseline {baseline} is not an integer: {want!r}", file=sys.stderr) + return 2 + limit = int(want) + print(f"[{tag}] baseline {limit}, delta {value - limit:+d}") + if (value < limit) if grows_ok else (value > limit): + way = "shrank below" if grows_ok else "grew past" + print(f"::error::{tag} {way} its baseline: {value} against {limit}. " + "Fix the change, or justify moving the baseline in the PR.", file=sys.stderr) + return 1 + return 0 diff --git a/tools/ratchets/kernel_files.py b/tools/ratchets/kernel_files.py new file mode 100644 index 0000000000..43a0df748c --- /dev/null +++ b/tools/ratchets/kernel_files.py @@ -0,0 +1,35 @@ +# NONOS Operating System +# Copyright (C) 2026 NONOS Contributors +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +"""The kernel sources a ratchet counts, from the newest library dep-info.""" + +import sys +from pathlib import Path + +from ring0 import depinfo_sources, newest_depinfo + +DEPS = Path("target/x86_64-nonos/release/deps") + + +def kernel_files(root, depinfo=None, target_deps=DEPS): + """(dep-info, sources), or (None, []) with the reason printed.""" + d = depinfo or newest_depinfo(root / target_deps) + if d is None or not d.is_file(): + print("no kernel dep-info; build the kernel first", file=sys.stderr) + return None, [] + files = depinfo_sources(d, root) + if not files: + print(f"{d} lists no kernel sources", file=sys.stderr) + return d, files diff --git a/tools/ratchets/proof_cover.py b/tools/ratchets/proof_cover.py new file mode 100644 index 0000000000..dd407cca51 --- /dev/null +++ b/tools/ratchets/proof_cover.py @@ -0,0 +1,66 @@ +# NONOS Operating System +# Copyright (C) 2026 NONOS Contributors +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +"""Which ring 0 lines a theorem over extracted code constrains. + +Aeneas writes each extracted definition under a doc block naming its Rust +source and line range. A definition counts once a hand-written file (one +Aeneas did not generate) names it next to a theorem; its range then counts +wherever it holds code. +""" + +import re +from pathlib import Path + +BLOCK = re.compile( + r"Source: '([^']+)', lines (\d+):\d+-(\d+):\d+.*?-/\s*(?:@\[[^\]]*\]\s*)*" + r"(?:noncomputable\s+)?(?:def|structure|inductive|axiom)\s+([\w.']+)", + re.S, +) +GENERATED = "THIS FILE WAS AUTOMATICALLY GENERATED BY AENEAS" + + +def extracted(lean_root): + """{lean name: (kernel path under src/, first line, last line)}.""" + out = {} + for p in lean_root.rglob("*.lean"): + text = p.read_text(errors="ignore") + if GENERATED not in text: + continue + for src, a, b, name in BLOCK.findall(text): + if "/src/" in src and not src.startswith("/rustc"): + rel = "src/" + src.rsplit("/src/", 1)[1] + out[name] = (Path(rel), int(a), int(b)) + return out + + +def proved_names(lean_root, names): + """Names a hand-written file with a theorem in it mentions in full.""" + texts = [p.read_text(errors="ignore") for p in lean_root.rglob("*.lean") if ".lake" not in p.parts] + text = "\n".join(t for t in texts if GENERATED not in t and re.search(r"\btheorem\b", t)) + return {n for n in names if re.search(rf"(?. +"""What ring 0 is, from rustc's dep-info, and which of its lines are code.""" + +from pathlib import Path + + +def depinfo_sources(depinfo, root): + """The .rs files under root/src that the dep-info lists.""" + text = depinfo.read_text() + first = text.split("\n", 1)[0] + _, _, deps = first.partition(": ") + src = (root / "src").resolve() + out = set() + # Paths with spaces are escaped with a backslash; the kernel has none, so + # a plain split is exact here and anything odd is refused below. + for dep in deps.split(): + p = Path(dep) + if not p.is_absolute(): + p = root / p + p = p.resolve() + if p.suffix == ".rs" and src in p.parents: + out.add(p) + return sorted(out) + + +def code_line_numbers(path): + """1-based numbers of lines that are neither blank nor comment. Block + comments may nest in Rust; the tree does not nest them, and a nested one + only miscounts the lines inside it.""" + out = set() + in_block = False + for n, raw in enumerate(path.read_text(errors="replace").splitlines(), 1): + s = raw.strip() + if in_block: + in_block = "*/" not in s + continue + if not s or s.startswith("//"): + continue + if s.startswith("/*"): + in_block = "*/" not in s + continue + out.add(n) + return out + + +def code_lines(path): + return len(code_line_numbers(path)) + + +def newest_depinfo(target): + found = [] + for d in target.glob("nonos_kernel-*.d"): + # The library's dep-info lists every module; the binary's lists two. + if len(d.read_text().split("\n", 1)[0].split()) > 64: + found.append(d) + if not found: + return None + return max(found, key=lambda d: d.stat().st_mtime) From 207a33e77b18617d5eb7285ff17d753f64cbe861 Mon Sep 17 00:00:00 2001 From: eKisNonos Date: Sun, 27 Sep 2026 02:49:28 +0000 Subject: [PATCH 075/244] iommu: give each driver capsule its own domain A claimed device leaves the identity domain for its capsule's domain, which maps only MkDmaMap grants at IOVAs below 4 GiB. Unmap now flushes the IOTLB before frames are scrubbed, table writes are flushed when walks are not coherent, and freed domain slots are reused. --- .../x86_64/iommu/domain/destroy_domain.rs | 15 +++- .../iommu/globals/allocate_domain_id.rs | 13 ++-- src/arch/x86_64/iommu/globals/state.rs | 1 - src/arch/x86_64/iommu/mapping/commit.rs | 30 ++++++++ src/arch/x86_64/iommu/mapping/domain_root.rs | 27 +++++++ src/arch/x86_64/iommu/mapping/map_identity.rs | 4 ++ src/arch/x86_64/iommu/mapping/map_range.rs | 20 +++--- src/arch/x86_64/iommu/mapping/mod.rs | 2 + src/arch/x86_64/iommu/mapping/unmap_range.rs | 21 +++--- src/arch/x86_64/iommu/regs/cap/behaviour.rs | 6 ++ src/arch/x86_64/iommu/regs/cap/mod.rs | 2 +- src/arch/x86_64/iommu/tables/frame.rs | 1 + src/arch/x86_64/iommu/tables/mod.rs | 2 + src/arch/x86_64/iommu/tables/publish.rs | 55 +++++++++++++++ src/arch/x86_64/iommu/tables/root/clear.rs | 2 + .../x86_64/iommu/tables/root/context_table.rs | 2 + src/arch/x86_64/iommu/tables/root/set.rs | 2 + src/arch/x86_64/iommu/tables/touched.rs | 46 ++++++++++++ src/arch/x86_64/iommu/tables/walk/create.rs | 2 + src/hardware/broker/claim/claim.rs | 5 ++ src/hardware/broker/claim/release.rs | 7 +- src/hardware/broker/claim/types.rs | 2 + src/hardware/broker/confine/attach.rs | 65 +++++++++++++++++ src/hardware/broker/confine/detach.rs | 51 ++++++++++++++ src/hardware/broker/confine/iova.rs | 39 +++++++++++ src/hardware/broker/confine/map.rs | 68 ++++++++++++++++++ src/hardware/broker/confine/mod.rs | 31 ++++++++ src/hardware/broker/confine/table.rs | 51 ++++++++++++++ src/hardware/broker/dma/map/fail.rs | 45 ++++++++++++ src/hardware/broker/dma/map/install.rs | 8 +++ src/hardware/broker/dma/map/mod.rs | 70 +------------------ src/hardware/broker/dma/map/transaction.rs | 68 ++++++++++++++++++ src/hardware/broker/dma/mod.rs | 1 + src/hardware/broker/dma/release.rs | 37 +++------- src/hardware/broker/dma/scrub.rs | 35 ++++++++++ src/hardware/broker/dma/types.rs | 3 + src/hardware/broker/mod.rs | 1 + src/memory/iommu/backend_x86_64/domain.rs | 17 +++-- src/syscall/microkernel/device.rs | 5 +- 39 files changed, 730 insertions(+), 132 deletions(-) create mode 100644 src/arch/x86_64/iommu/mapping/commit.rs create mode 100644 src/arch/x86_64/iommu/mapping/domain_root.rs create mode 100644 src/arch/x86_64/iommu/tables/publish.rs create mode 100644 src/arch/x86_64/iommu/tables/touched.rs create mode 100644 src/hardware/broker/confine/attach.rs create mode 100644 src/hardware/broker/confine/detach.rs create mode 100644 src/hardware/broker/confine/iova.rs create mode 100644 src/hardware/broker/confine/map.rs create mode 100644 src/hardware/broker/confine/mod.rs create mode 100644 src/hardware/broker/confine/table.rs create mode 100644 src/hardware/broker/dma/map/fail.rs create mode 100644 src/hardware/broker/dma/map/transaction.rs create mode 100644 src/hardware/broker/dma/scrub.rs diff --git a/src/arch/x86_64/iommu/domain/destroy_domain.rs b/src/arch/x86_64/iommu/domain/destroy_domain.rs index 43c03499e3..8b36efc367 100644 --- a/src/arch/x86_64/iommu/domain/destroy_domain.rs +++ b/src/arch/x86_64/iommu/domain/destroy_domain.rs @@ -16,8 +16,14 @@ use super::super::globals::is_present; use super::super::globals::state::STATE; +use super::super::tables::root::clear_context; use super::super::types::{DomainId, VtdError, MAX_VTD_DOMAINS}; +use super::super::unit::invalidate::invalidate_all; +use super::super::unit::report::probed; +/// Devices still bound are denied first, and the caches dropped, before the +/// slot is freed: a freed slot is reused by the next claim, and a device left +/// pointing at it would reach whatever that claim maps. pub fn destroy_domain(id: DomainId) -> Result<(), VtdError> { if !is_present() { return Err(VtdError::NotPresent); @@ -27,10 +33,15 @@ pub fn destroy_domain(id: DomainId) -> Result<(), VtdError> { return Err(VtdError::DomainNotFound); } let mut state = STATE.lock(); - let slot = &mut state.domains[index]; - if !slot.used { + if !state.domains[index].used { return Err(VtdError::DomainNotFound); } + for binding in state.bindings.iter().filter(|b| b.domain == id) { + clear_context(binding.source)?; + } + if let Some(info) = probed() { + invalidate_all(&info.unit, info.ecap)?; + } state.bindings.retain(|binding| binding.domain != id); state.domains[index].used = false; state.domains[index].root = 0; diff --git a/src/arch/x86_64/iommu/globals/allocate_domain_id.rs b/src/arch/x86_64/iommu/globals/allocate_domain_id.rs index 6549e82714..a76361eba9 100644 --- a/src/arch/x86_64/iommu/globals/allocate_domain_id.rs +++ b/src/arch/x86_64/iommu/globals/allocate_domain_id.rs @@ -14,10 +14,15 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -use core::sync::atomic::Ordering; - -use super::state::NEXT_DOMAIN_ID; +use super::super::types::MAX_VTD_DOMAINS; +use super::state::{FIRST_DYNAMIC_DOMAIN_ID, STATE}; +/// The lowest free slot, or `MAX_VTD_DOMAINS` when none is, which +/// `create_domain` refuses. A counter that never went back ran out after 256 +/// claims in one boot however many domains were live, and a driver restarted +/// that often would then find every claim refused. pub fn allocate_domain_id() -> u64 { - NEXT_DOMAIN_ID.fetch_add(1, Ordering::SeqCst) + let state = STATE.lock(); + let first = FIRST_DYNAMIC_DOMAIN_ID as usize; + (first..MAX_VTD_DOMAINS).find(|&i| !state.domains[i].used).unwrap_or(MAX_VTD_DOMAINS) as u64 } diff --git a/src/arch/x86_64/iommu/globals/state.rs b/src/arch/x86_64/iommu/globals/state.rs index ac6e79cc8e..2852c27ac2 100644 --- a/src/arch/x86_64/iommu/globals/state.rs +++ b/src/arch/x86_64/iommu/globals/state.rs @@ -60,5 +60,4 @@ pub(crate) static PAGE_LEVELS: AtomicU8 = AtomicU8::new(0); /// exactly one unit, so one table indexed by bus and function describes them /// all, and a single table is one thing to invalidate. pub(crate) static ROOT_TABLE: AtomicU64 = AtomicU64::new(0); -pub(crate) static NEXT_DOMAIN_ID: AtomicU64 = AtomicU64::new(FIRST_DYNAMIC_DOMAIN_ID); pub(crate) static STATE: Mutex = Mutex::new(VtdState::new()); diff --git a/src/arch/x86_64/iommu/mapping/commit.rs b/src/arch/x86_64/iommu/mapping/commit.rs new file mode 100644 index 0000000000..47226cbbc1 --- /dev/null +++ b/src/arch/x86_64/iommu/mapping/commit.rs @@ -0,0 +1,30 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +use crate::arch::x86_64::iommu::tables::touched::Touched; +use crate::arch::x86_64::iommu::types::VtdError; +use crate::arch::x86_64::iommu::unit::invalidate::invalidate_iotlb_global; +use crate::arch::x86_64::iommu::unit::report::probed; + +/// Make a run of leaf writes the unit's view before returning. An unmap is not +/// done until the IOTLB forgets it: the broker frees the frame next, and a +/// cached translation would let the device write into its next owner. A map +/// needs the same under caching mode, where a not-present entry is cached too. +pub(super) fn commit(touched: Touched) -> Result<(), VtdError> { + touched.finish(); + let info = probed().ok_or(VtdError::NotPresent)?; + invalidate_iotlb_global(&info.unit, info.ecap) +} diff --git a/src/arch/x86_64/iommu/mapping/domain_root.rs b/src/arch/x86_64/iommu/mapping/domain_root.rs new file mode 100644 index 0000000000..13d5db3e79 --- /dev/null +++ b/src/arch/x86_64/iommu/mapping/domain_root.rs @@ -0,0 +1,27 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +use crate::arch::x86_64::iommu::globals::state::VtdState; +use crate::arch::x86_64::iommu::types::{DomainId, VtdError, MAX_VTD_DOMAINS}; + +/// The second-level root of a live domain, read under the caller's lock. +pub(super) fn domain_root(state: &VtdState, domain: DomainId) -> Result { + let index = domain.as_u16() as usize; + if index >= MAX_VTD_DOMAINS || !state.domains[index].used { + return Err(VtdError::DomainNotFound); + } + Ok(state.domains[index].root) +} diff --git a/src/arch/x86_64/iommu/mapping/map_identity.rs b/src/arch/x86_64/iommu/mapping/map_identity.rs index b53265948f..4a5b6a7f62 100644 --- a/src/arch/x86_64/iommu/mapping/map_identity.rs +++ b/src/arch/x86_64/iommu/mapping/map_identity.rs @@ -15,6 +15,7 @@ // along with this program. If not, see . use crate::arch::x86_64::iommu::tables::frame::entries_mut; +use crate::arch::x86_64::iommu::tables::touched::Touched; use crate::arch::x86_64::iommu::tables::sl_pte::{leaf, level_span, SL_LARGE}; use crate::arch::x86_64::iommu::tables::walk::walk_create_to; use crate::arch::x86_64::iommu::types::VtdError; @@ -41,10 +42,13 @@ pub fn map_identity(root: u64, levels: u8, limit: u64, leaf_level: u8) -> Result let large = if leaf_level > 1 { SL_LARGE } else { 0 }; let mut addr = 0u64; + let mut touched = Touched::default(); while addr < end { let slot = walk_create_to(root, addr, levels, leaf_level)?; entries_mut(slot.table_phys)?[slot.index] = leaf(addr, true, true, true) | large; + touched.note(slot.table_phys); addr += span; } + touched.finish(); Ok(end) } diff --git a/src/arch/x86_64/iommu/mapping/map_range.rs b/src/arch/x86_64/iommu/mapping/map_range.rs index 617c12006b..c3b723cefc 100644 --- a/src/arch/x86_64/iommu/mapping/map_range.rs +++ b/src/arch/x86_64/iommu/mapping/map_range.rs @@ -14,15 +14,16 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . +use super::commit::commit; +use super::domain_root::domain_root; use super::validate_range::validate_range; use crate::arch::x86_64::iommu::globals::state::STATE; use crate::arch::x86_64::iommu::globals::{is_enforcing, page_levels}; use crate::arch::x86_64::iommu::tables::frame::entries_mut; use crate::arch::x86_64::iommu::tables::sl_pte::{is_present, leaf}; +use crate::arch::x86_64::iommu::tables::touched::Touched; use crate::arch::x86_64::iommu::tables::walk::walk_create; -use crate::arch::x86_64::iommu::types::{ - DomainId, IommuPageFlags, VtdError, MAX_VTD_DOMAINS, PAGE_SIZE_4K, -}; +use crate::arch::x86_64::iommu::types::{DomainId, IommuPageFlags, VtdError, PAGE_SIZE_4K}; pub fn map_range( domain: DomainId, @@ -44,16 +45,9 @@ pub fn map_range( return Err(VtdError::NoPermissionsRequested); } let levels = page_levels().ok_or(VtdError::DepthUnknown)?; - let index = domain.as_u16() as usize; - if index >= MAX_VTD_DOMAINS { - return Err(VtdError::DomainNotFound); - } let state = STATE.lock(); - if !state.domains[index].used { - return Err(VtdError::DomainNotFound); - } - let root = state.domains[index].root; + let root = domain_root(&state, domain)?; if iova + size as u64 > 1u64 << (12 + 9 * levels as u32) { return Err(VtdError::RangeOutOfBounds); } @@ -65,11 +59,13 @@ pub fn map_range( return Err(VtdError::RangeAlreadyMapped); } } + let mut touched = Touched::default(); for page in 0..pages { let offset = (page * PAGE_SIZE_4K) as u64; let slot = walk_create(root, iova + offset, levels)?; entries_mut(slot.table_phys)?[slot.index] = leaf(phys + offset, flags.read, flags.write, flags.snoop); + touched.note(slot.table_phys); } - Ok(()) + commit(touched) } diff --git a/src/arch/x86_64/iommu/mapping/mod.rs b/src/arch/x86_64/iommu/mapping/mod.rs index 4fc9dd8e07..94add6a6b3 100644 --- a/src/arch/x86_64/iommu/mapping/mod.rs +++ b/src/arch/x86_64/iommu/mapping/mod.rs @@ -14,6 +14,8 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . +mod commit; +mod domain_root; mod map_identity; mod map_range; mod unmap_range; diff --git a/src/arch/x86_64/iommu/mapping/unmap_range.rs b/src/arch/x86_64/iommu/mapping/unmap_range.rs index f5c313de2f..58cef7c494 100644 --- a/src/arch/x86_64/iommu/mapping/unmap_range.rs +++ b/src/arch/x86_64/iommu/mapping/unmap_range.rs @@ -14,15 +14,19 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . +use super::commit::commit; +use super::domain_root::domain_root; use super::validate_range::validate_range; use crate::arch::x86_64::iommu::globals::state::STATE; use crate::arch::x86_64::iommu::globals::{is_enforcing, page_levels}; use crate::arch::x86_64::iommu::tables::frame::entries_mut; use crate::arch::x86_64::iommu::tables::sl_pte::is_present; +use crate::arch::x86_64::iommu::tables::touched::Touched; use crate::arch::x86_64::iommu::tables::walk::walk_lookup; -use crate::arch::x86_64::iommu::types::{DomainId, VtdError, MAX_VTD_DOMAINS, PAGE_SIZE_4K}; +use crate::arch::x86_64::iommu::types::{DomainId, VtdError, PAGE_SIZE_4K}; -/// Entries are cleared, not marked: zero is the state a fresh table has. +/// Entries are cleared, not marked: zero is the state a fresh table has. The +/// range is gone from the device's view when this returns Ok, and not before. /// /// The tables the range hung from stay allocated. Freeing them would race a /// device still walking toward a sibling page. @@ -32,16 +36,9 @@ pub fn unmap_range(domain: DomainId, iova: u64, size: usize) -> Result<(), VtdEr } let pages = validate_range(iova, size)?; let levels = page_levels().ok_or(VtdError::DepthUnknown)?; - let index = domain.as_u16() as usize; - if index >= MAX_VTD_DOMAINS { - return Err(VtdError::DomainNotFound); - } let state = STATE.lock(); - if !state.domains[index].used { - return Err(VtdError::DomainNotFound); - } - let root = state.domains[index].root; + let root = domain_root(&state, domain)?; // A caller naming a range it does not hold does not lose the part it does. for page in 0..pages { @@ -51,11 +48,13 @@ pub fn unmap_range(domain: DomainId, iova: u64, size: usize) -> Result<(), VtdEr _ => return Err(VtdError::RangeNotMapped), } } + let mut touched = Touched::default(); for page in 0..pages { let addr = iova + (page * PAGE_SIZE_4K) as u64; if let Some(slot) = walk_lookup(root, addr, levels)? { entries_mut(slot.table_phys)?[slot.index] = 0; + touched.note(slot.table_phys); } } - Ok(()) + commit(touched) } diff --git a/src/arch/x86_64/iommu/regs/cap/behaviour.rs b/src/arch/x86_64/iommu/regs/cap/behaviour.rs index b475a680f0..8390c3f554 100644 --- a/src/arch/x86_64/iommu/regs/cap/behaviour.rs +++ b/src/arch/x86_64/iommu/regs/cap/behaviour.rs @@ -25,3 +25,9 @@ pub const fn requires_write_buffer_flush(cap: u64) -> bool { pub const fn caching_mode(cap: u64) -> bool { cap & (1 << 7) != 0 } + +/// ECAP.C: the unit snoops CPU caches on a table walk. When clear, a table +/// write sits in a cache line the hardware never reads until it is flushed. +pub const fn page_walk_coherent(ecap: u64) -> bool { + ecap & 1 != 0 +} diff --git a/src/arch/x86_64/iommu/regs/cap/mod.rs b/src/arch/x86_64/iommu/regs/cap/mod.rs index d62109a898..b5ea29e8f4 100644 --- a/src/arch/x86_64/iommu/regs/cap/mod.rs +++ b/src/arch/x86_64/iommu/regs/cap/mod.rs @@ -21,7 +21,7 @@ mod limits; mod pages; pub use agaw::{preferred_levels, AgawLevels}; -pub use behaviour::{caching_mode, requires_write_buffer_flush}; +pub use behaviour::{caching_mode, page_walk_coherent, requires_write_buffer_flush}; pub use fault::{fault_recording_count, fault_recording_offset}; pub use limits::{domain_count, max_address_width}; pub use pages::best_leaf_level; diff --git a/src/arch/x86_64/iommu/tables/frame.rs b/src/arch/x86_64/iommu/tables/frame.rs index d0a39b04c4..efb7ec0839 100644 --- a/src/arch/x86_64/iommu/tables/frame.rs +++ b/src/arch/x86_64/iommu/tables/frame.rs @@ -27,6 +27,7 @@ use super::sl_pte::ENTRIES; pub fn allocate_table() -> Result { let phys = allocate_frame().ok_or(VtdError::PageTableExhausted)?; entries_mut(phys.as_u64())?.fill(0); + super::publish::publish(phys.as_u64()); Ok(phys.as_u64()) } diff --git a/src/arch/x86_64/iommu/tables/mod.rs b/src/arch/x86_64/iommu/tables/mod.rs index aa054e94cc..430a2ba9a8 100644 --- a/src/arch/x86_64/iommu/tables/mod.rs +++ b/src/arch/x86_64/iommu/tables/mod.rs @@ -16,6 +16,8 @@ pub mod context; pub mod frame; +pub mod publish; pub mod root; pub mod sl_pte; +pub mod touched; pub mod walk; diff --git a/src/arch/x86_64/iommu/tables/publish.rs b/src/arch/x86_64/iommu/tables/publish.rs new file mode 100644 index 0000000000..831c87348f --- /dev/null +++ b/src/arch/x86_64/iommu/tables/publish.rs @@ -0,0 +1,55 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! Making a table write visible to a unit that does not snoop CPU caches. + +use crate::arch::x86_64::iommu::regs::cap::page_walk_coherent; +use crate::arch::x86_64::iommu::unit::report::probed; +use crate::memory::addr::PhysAddr; +use crate::memory::unified::phys_to_virt; + +const LINE: usize = 64; +const PAGE: usize = 4096; + +/// Flush the table page at `table_phys` when the unit's walks are not +/// coherent. Without it a unit reading memory directly sees the entry's +/// previous value: a mapping that never appears, or an unmap that never +/// happens while the frame behind it is reused. +pub fn publish(table_phys: u64) { + let Some(info) = probed() else { + return; + }; + if page_walk_coherent(info.ecap) { + return; + } + let Some(virt) = phys_to_virt(PhysAddr::new(table_phys)) else { + return; + }; + for offset in (0..PAGE).step_by(LINE) { + let line = virt.as_u64() as usize + offset; + // SAFETY: eK@nonos.systems - `line` lies inside the directmap view of a + // table frame this module owns; clflush writes back and drops the line + // and changes no memory contents. + unsafe { + core::arch::asm!("clflush [{}]", in(reg) line, options(nostack, preserves_flags)); + } + } + // SAFETY: eK@nonos.systems - a fence has no memory operands; it orders the + // flushes above before whatever invalidation the caller issues next. + unsafe { + core::arch::asm!("mfence", options(nostack, preserves_flags)); + } +} diff --git a/src/arch/x86_64/iommu/tables/root/clear.rs b/src/arch/x86_64/iommu/tables/root/clear.rs index 38d73a98b6..c4e5d6ea67 100644 --- a/src/arch/x86_64/iommu/tables/root/clear.rs +++ b/src/arch/x86_64/iommu/tables/root/clear.rs @@ -18,6 +18,7 @@ use super::context_table::slot_of; use super::table::root_table; use crate::arch::x86_64::iommu::tables::context::{context_index, entry_address, is_present}; use crate::arch::x86_64::iommu::tables::frame::entries_mut; +use crate::arch::x86_64::iommu::tables::publish::publish; use crate::arch::x86_64::iommu::types::{SourceId, VtdError}; /// Deny a device again. The present bit goes first, so the device is denied @@ -37,5 +38,6 @@ pub fn clear_context(source: SourceId) -> Result<(), VtdError> { } entries[slot] = 0; entries[slot + 1] = 0; + publish(table); Ok(()) } diff --git a/src/arch/x86_64/iommu/tables/root/context_table.rs b/src/arch/x86_64/iommu/tables/root/context_table.rs index 7474b9ae3c..190fb19eb2 100644 --- a/src/arch/x86_64/iommu/tables/root/context_table.rs +++ b/src/arch/x86_64/iommu/tables/root/context_table.rs @@ -17,6 +17,7 @@ use super::table::root_table; use crate::arch::x86_64::iommu::tables::context::{entry_address, is_present, root_low}; use crate::arch::x86_64::iommu::tables::frame::{allocate_table, entries_mut}; +use crate::arch::x86_64::iommu::tables::publish::publish; use crate::arch::x86_64::iommu::types::VtdError; /// Root and context entries are 128 bits stored low half first, so entry `i` @@ -41,5 +42,6 @@ pub(super) fn context_table_for(bus: u8) -> Result { // describes is in place first. entries[slot + 1] = 0; entries[slot] = root_low(table); + publish(root); Ok(table) } diff --git a/src/arch/x86_64/iommu/tables/root/set.rs b/src/arch/x86_64/iommu/tables/root/set.rs index 1d4b64f042..3ccb8680e0 100644 --- a/src/arch/x86_64/iommu/tables/root/set.rs +++ b/src/arch/x86_64/iommu/tables/root/set.rs @@ -19,6 +19,7 @@ use crate::arch::x86_64::iommu::tables::context::{ context_high, context_index, context_low, is_present, }; use crate::arch::x86_64::iommu::tables::frame::entries_mut; +use crate::arch::x86_64::iommu::tables::publish::publish; use crate::arch::x86_64::iommu::types::{DomainId, SourceId, VtdError}; /// Point one device at a domain's second-level tables. `address_width` is the @@ -38,5 +39,6 @@ pub fn set_context( } entries[slot + 1] = context_high(domain.as_u16(), address_width); entries[slot] = context_low(sl_root); + publish(table); Ok(()) } diff --git a/src/arch/x86_64/iommu/tables/touched.rs b/src/arch/x86_64/iommu/tables/touched.rs new file mode 100644 index 0000000000..376cefa4b9 --- /dev/null +++ b/src/arch/x86_64/iommu/tables/touched.rs @@ -0,0 +1,46 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! Publishing each table a range of writes touched, once. + +use super::publish::publish; + +/// A run of leaf writes lands in a few tables, entry after entry. Flushing a +/// whole page per entry would cost a page of flushes per 4 KiB mapped, so a +/// table is published when the run moves past it, and the last one at the end. +#[derive(Default)] +pub struct Touched { + current: Option, +} + +impl Touched { + pub fn note(&mut self, table_phys: u64) { + match self.current { + Some(t) if t == table_phys => {} + Some(t) => { + publish(t); + self.current = Some(table_phys); + } + None => self.current = Some(table_phys), + } + } + + pub fn finish(self) { + if let Some(t) = self.current { + publish(t); + } + } +} diff --git a/src/arch/x86_64/iommu/tables/walk/create.rs b/src/arch/x86_64/iommu/tables/walk/create.rs index de9575445c..7e48440c98 100644 --- a/src/arch/x86_64/iommu/tables/walk/create.rs +++ b/src/arch/x86_64/iommu/tables/walk/create.rs @@ -16,6 +16,7 @@ use super::slot::LeafSlot; use crate::arch::x86_64::iommu::tables::frame::{allocate_table, entries_mut}; +use crate::arch::x86_64::iommu::tables::publish::publish; use crate::arch::x86_64::iommu::tables::sl_pte::{entry_address, index_for, is_present, table}; use crate::arch::x86_64::iommu::types::VtdError; @@ -48,6 +49,7 @@ pub fn walk_create_to( } else { let next = allocate_table()?; entries_mut(current)?[index] = table(next); + publish(current); next }; level -= 1; diff --git a/src/hardware/broker/claim/claim.rs b/src/hardware/broker/claim/claim.rs index 0969679c3d..336e99679f 100644 --- a/src/hardware/broker/claim/claim.rs +++ b/src/hardware/broker/claim/claim.rs @@ -30,6 +30,11 @@ pub fn claim(pid: u32, device_id: u64) -> Result { claims.push(Claim { pid, device_id, epoch }); epoch }; + // Confined before it is powered, so the device never runs unconfined. + if crate::hardware::broker::confine::attach(pid, device_id).is_err() { + CLAIMS.lock().retain(|c| !(c.pid == pid && c.device_id == device_id)); + return Err(ClaimError::Unconfined); + } // Bring the device to power state D0 before its driver maps MMIO. Done // outside the claims lock: it touches config space and settles for a moment. crate::hardware::broker::power::power_on_device(device_id); diff --git a/src/hardware/broker/claim/release.rs b/src/hardware/broker/claim/release.rs index 76e0a356de..38fea1efff 100644 --- a/src/hardware/broker/claim/release.rs +++ b/src/hardware/broker/claim/release.rs @@ -27,6 +27,8 @@ pub fn release(pid: u32, device_id: u64) -> Result { } let epoch = claims[idx].epoch; claims.remove(idx); + drop(claims); + crate::hardware::broker::confine::detach(pid, device_id); Ok(epoch) } @@ -37,5 +39,8 @@ pub fn release_all_for_pid(pid: u32) -> usize { let mut claims = CLAIMS.lock(); let before = claims.len(); claims.retain(|c| c.pid != pid); - before - claims.len() + let released = before - claims.len(); + drop(claims); + crate::hardware::broker::confine::detach_all(pid); + released } diff --git a/src/hardware/broker/claim/types.rs b/src/hardware/broker/claim/types.rs index 4705b1aef2..b97f7e26d2 100644 --- a/src/hardware/broker/claim/types.rs +++ b/src/hardware/broker/claim/types.rs @@ -27,4 +27,6 @@ pub enum ClaimError { AlreadyClaimed, NotHolder, NotClaimed, + /// A remapping unit is in service and would not take the device. + Unconfined, } diff --git a/src/hardware/broker/confine/attach.rs b/src/hardware/broker/confine/attach.rs new file mode 100644 index 0000000000..438a2ce669 --- /dev/null +++ b/src/hardware/broker/confine/attach.rs @@ -0,0 +1,65 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +extern crate alloc; + +use alloc::vec::Vec; + +use super::iova::IOVA_BASE; +use super::table::{pci_address, say, Capsule, CAPSULES}; +use crate::memory::iommu::{IommuDomain, IommuError}; + +/// The unit is in service and would not take the device, so the claim is +/// refused rather than granted with a device that reaches all of memory. +pub(in crate::hardware::broker) struct Refused; + +pub(in crate::hardware::broker) fn attach(pid: u32, device_id: u64) -> Result<(), Refused> { + let Some(address) = pci_address(device_id) else { + return Ok(()); + }; + let mut all = CAPSULES.lock(); + let pos = match all.iter().position(|c| c.pid == pid) { + Some(i) => i, + None => match IommuDomain::allocate() { + Ok(domain) => { + all.push(Capsule { pid, domain, devices: Vec::new(), next_iova: IOVA_BASE }); + all.len() - 1 + } + // The posture on most hardware: said per claim, not only at boot. + Err(IommuError::NotInitialized | IommuError::NotSupported) => { + say(b"unconfined: no remapping unit in service, reaches all memory", pid, address); + return Ok(()); + } + Err(_) => { + say(b"refused: no domain left", pid, address); + return Err(Refused); + } + }, + }; + // Out of the identity domain first. Between the two writes the device has + // no context entry, which denies it: the safe side to be on. + let _ = all[pos].domain.detach_device(address); + if all[pos].domain.attach_device(address).is_err() { + say(b"refused: attach failed", pid, address); + if all[pos].devices.is_empty() { + all.remove(pos); + } + return Err(Refused); + } + all[pos].devices.push((device_id, address)); + say(b"confined to its capsule's domain", pid, address); + Ok(()) +} diff --git a/src/hardware/broker/confine/detach.rs b/src/hardware/broker/confine/detach.rs new file mode 100644 index 0000000000..304610d8ca --- /dev/null +++ b/src/hardware/broker/confine/detach.rs @@ -0,0 +1,51 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +extern crate alloc; + +use super::table::{say, CAPSULES}; + +/// Back to denied, not to the identity domain: nothing drives the device now, +/// and the next claim attaches it afresh. The domain goes with the capsule's +/// last device, which denies it everything it still mapped. +pub(in crate::hardware::broker) fn detach(pid: u32, device_id: u64) { + let mut all = CAPSULES.lock(); + let Some(pos) = all.iter().position(|c| c.pid == pid) else { + return; + }; + let Some(i) = all[pos].devices.iter().position(|(d, _)| *d == device_id) else { + return; + }; + let (_, address) = all[pos].devices.remove(i); + if all[pos].domain.detach_device(address).is_err() { + say(b"detach failed; the domain is kept", pid, address); + return; + } + say(b"released and denied", pid, address); + if all[pos].devices.is_empty() { + all.remove(pos); + } +} + +pub(in crate::hardware::broker) fn detach_all(pid: u32) { + let held: alloc::vec::Vec = { + let all = CAPSULES.lock(); + all.iter().filter(|c| c.pid == pid).flat_map(|c| c.devices.iter().map(|(d, _)| *d)).collect() + }; + for device_id in held { + detach(pid, device_id); + } +} diff --git a/src/hardware/broker/confine/iova.rs b/src/hardware/broker/confine/iova.rs new file mode 100644 index 0000000000..a204e8bb63 --- /dev/null +++ b/src/hardware/broker/confine/iova.rs @@ -0,0 +1,39 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +use super::table::Capsule; + +/// Device addresses start above the first megabyte, so a driver that hands a +/// device a zero or small address faults instead of hitting a grant. +pub(super) const IOVA_BASE: u64 = 0x10_0000; +/// Every grant sits below 4 GiB, so a 32-bit descriptor can name any of them. +const IOVA_LIMIT: u64 = 1 << 32; + +/// Take `length` bytes of the capsule's device address space. A bump pointer: +/// grants are rings and staging buffers mapped once, and the top one is given +/// back on unmap, so churn only strands space below a live grant. +pub(super) fn take(c: &mut Capsule, length: u64) -> Option { + let start = c.next_iova; + let end = start.checked_add(length).filter(|&e| e <= IOVA_LIMIT)?; + c.next_iova = end; + Some(start) +} + +pub(super) fn give_back(c: &mut Capsule, iova: u64, length: u64) { + if iova.checked_add(length) == Some(c.next_iova) { + c.next_iova = iova; + } +} diff --git a/src/hardware/broker/confine/map.rs b/src/hardware/broker/confine/map.rs new file mode 100644 index 0000000000..be64703643 --- /dev/null +++ b/src/hardware/broker/confine/map.rs @@ -0,0 +1,68 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +use super::iova; +use super::table::CAPSULES; +use crate::memory::addr::PhysAddr; +use crate::memory::iommu::IommuProtection; + +/// The address a device is given for a grant, and whether it is an IOVA in +/// the capsule's domain. A device no unit confines gets the physical address, +/// and the grant is marked so its teardown knows there is nothing to unmap. +pub(in crate::hardware::broker) fn map( + pid: u32, + device_id: u64, + phys: u64, + length: u64, +) -> Option<(u64, bool)> { + let mut all = CAPSULES.lock(); + let held = |c: &&mut super::table::Capsule| c.devices.iter().any(|(d, _)| *d == device_id); + let Some(c) = all.iter_mut().filter(|c| c.pid == pid).find(held) else { + return Some((phys, false)); + }; + let iova = iova::take(c, length)?; + // SAFETY: eK@nonos.systems - `[phys, phys+length)` is a run the broker just + // allocated and zeroed for this grant, and frees only after `unmap` below + // returns true. `[iova, iova+length)` was taken fresh from this domain's + // allocator, so nothing is mapped there. Both are page aligned: the broker + // refuses a length that is not, and IOVA_BASE is. + let mapped = unsafe { + c.domain.map(iova, PhysAddr::new(phys), length as usize, IommuProtection::READ_WRITE) + }; + if mapped.is_err() { + iova::give_back(c, iova, length); + return None; + } + Some((iova, true)) +} + +/// True once no device can reach the grant, which is when its frames may be +/// scrubbed and handed to someone else. False means they must not be. +pub(in crate::hardware::broker) fn unmap(pid: u32, iova: u64, length: u64, confined: bool) -> bool { + if !confined { + return true; + } + let mut all = CAPSULES.lock(); + let Some(c) = all.iter_mut().find(|c| c.pid == pid) else { + // The capsule's last device was detached, which denied it this too. + return true; + }; + if c.domain.unmap(iova, length as usize).is_err() { + return false; + } + iova::give_back(c, iova, length); + true +} diff --git a/src/hardware/broker/confine/mod.rs b/src/hardware/broker/confine/mod.rs new file mode 100644 index 0000000000..b88b85a592 --- /dev/null +++ b/src/hardware/broker/confine/mod.rs @@ -0,0 +1,31 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! One IOMMU domain per driver capsule. A device a capsule claims leaves the +//! identity domain for the capsule's own, which maps nothing until `MkDmaMap` +//! grants a buffer, so the device reaches that capsule's grants and faults on +//! everything else. Without a unit in service these are no-ops that say so: +//! the device then reaches all of memory, and the boot log states it. + +mod attach; +mod detach; +mod iova; +mod map; +mod table; + +pub(super) use attach::attach; +pub(super) use detach::{detach, detach_all}; +pub(super) use map::{map, unmap}; diff --git a/src/hardware/broker/confine/table.rs b/src/hardware/broker/confine/table.rs new file mode 100644 index 0000000000..caec7858cf --- /dev/null +++ b/src/hardware/broker/confine/table.rs @@ -0,0 +1,51 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +extern crate alloc; + +use alloc::vec::Vec; +use spin::Mutex; + +use crate::memory::iommu::{DeviceAddress, IommuDomain}; + +/// A capsule's domain and what it holds. Dropping the entry frees the domain. +pub(super) struct Capsule { + pub pid: u32, + pub domain: IommuDomain, + pub devices: Vec<(u64, DeviceAddress)>, + pub next_iova: u64, +} + +// Taken before the IOMMU's own lock, never inside it. +pub(super) static CAPSULES: Mutex> = Mutex::new(Vec::new()); + +/// The PCI address of a broker device, or `None` for one no remapping unit +/// sits in front of, such as an ACPI-enumerated controller. +pub(super) fn pci_address(device_id: u64) -> Option { + let handle = crate::hardware::broker::pci_index::lookup(device_id)?; + let a = handle.address; + Some(DeviceAddress::pci(a.bus, a.device, a.function)) +} + +pub(super) fn say(what: &[u8], pid: u32, device: DeviceAddress) { + let serial = crate::sys::serial::print; + serial(b"[VT-D] pid="); + crate::sys::serial::print_dec(pid as u64); + serial(b" device="); + crate::sys::serial::print_hex(device.as_u32() as u64); + serial(b" "); + crate::sys::serial::println(what); +} diff --git a/src/hardware/broker/dma/map/fail.rs b/src/hardware/broker/dma/map/fail.rs new file mode 100644 index 0000000000..c0e4f9d24f --- /dev/null +++ b/src/hardware/broker/dma/map/fail.rs @@ -0,0 +1,45 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +use super::super::types::{DmaMapError, DmaMapResult}; + +pub(super) fn fail(stage: &str, error: DmaMapError) -> Result { + if stage == "alloc" && error == DmaMapError::NoMemory { + let (start, end) = crate::memory::phys::managed_range(); + crate::sys::serial::print(b"[DMA] free-frames="); + crate::sys::serial::print_dec(crate::memory::phys::total_free_frames() as u64); + crate::sys::serial::print(b" max-run="); + crate::sys::serial::print_dec(crate::memory::phys::largest_free_run() as u64); + crate::sys::serial::print(b" range="); + crate::sys::serial::print_hex(start); + crate::sys::serial::print(b".."); + crate::sys::serial::print_hex(end); + crate::sys::serial::println(b""); + } + crate::sys::serial::println(match (stage, error) { + ("validate", DmaMapError::BadLengthForClass) => b"[DMA] validate bad-length-class", + ("validate", DmaMapError::BadLength) => b"[DMA] validate bad-length", + ("validate", DmaMapError::NotClaimed) => b"[DMA] validate not-claimed", + ("validate", DmaMapError::StaleEpoch) => b"[DMA] validate stale-epoch", + ("validate", DmaMapError::UnknownDevice) => b"[DMA] validate unknown-device", + ("alloc", DmaMapError::NoMemory) => b"[DMA] alloc no-memory", + ("install", DmaMapError::NoVaSpace) => b"[DMA] install no-va-space", + ("install", DmaMapError::MapFailed) => b"[DMA] install map-failed", + ("confine", _) => b"[DMA] confine: the capsule's domain would not map it", + _ => b"[DMA] map failed", + }); + Err(error) +} diff --git a/src/hardware/broker/dma/map/install.rs b/src/hardware/broker/dma/map/install.rs index 46cc3c9d67..b7f255ee87 100644 --- a/src/hardware/broker/dma/map/install.rs +++ b/src/hardware/broker/dma/map/install.rs @@ -31,3 +31,11 @@ pub(super) fn install(pages: u64, length: u64, phys_start: u64) -> Result. mod alloc; +mod fail; mod install; +mod transaction; mod validate; -use super::records; -use super::types::{DmaGrant, DmaMapError, DmaMapRequest, DmaMapResult}; - -// `MkDmaMap`: validate -> alloc+zero frames -> install user pages -> -// record. Each step is a single responsibility in its own file; this -// function is the transaction boundary and owns the rollback chain. -pub fn map_for_caller(pid: u32, req: DmaMapRequest) -> Result { - let claim_epoch = match validate::validate(&req, pid) { - Ok(epoch) => epoch, - Err(e) => return fail("validate", e), - }; - let pages = req.length / validate::PAGE_SIZE; - - let phys_start = match alloc::alloc_and_zero(pages, req.length, req.flags) { - Ok(start) => start, - Err(e) => return fail("alloc", e), - }; - - let user_va = match install::install(pages, req.length, phys_start) { - Ok(va) => va, - Err(e) => { - alloc::free(phys_start, pages); - return fail("install", e); - } - }; - - let grant_id = records::allocate_id(); - records::insert(DmaGrant { - grant_id, - pid, - device_id: req.device_id, - claim_epoch, - physical_start: phys_start, - user_va, - length: req.length, - flags: req.flags, - }); - - Ok(DmaMapResult { user_va, device_addr: phys_start, length: req.length, grant_id }) -} - -fn fail(stage: &str, error: DmaMapError) -> Result { - if stage == "alloc" && error == DmaMapError::NoMemory { - let (start, end) = crate::memory::phys::managed_range(); - crate::sys::serial::print(b"[DMA] free-frames="); - crate::sys::serial::print_dec(crate::memory::phys::total_free_frames() as u64); - crate::sys::serial::print(b" max-run="); - crate::sys::serial::print_dec(crate::memory::phys::largest_free_run() as u64); - crate::sys::serial::print(b" range="); - crate::sys::serial::print_hex(start); - crate::sys::serial::print(b".."); - crate::sys::serial::print_hex(end); - crate::sys::serial::println(b""); - } - crate::sys::serial::println(match (stage, error) { - ("validate", DmaMapError::BadLengthForClass) => b"[DMA] validate bad-length-class", - ("validate", DmaMapError::BadLength) => b"[DMA] validate bad-length", - ("validate", DmaMapError::NotClaimed) => b"[DMA] validate not-claimed", - ("validate", DmaMapError::StaleEpoch) => b"[DMA] validate stale-epoch", - ("validate", DmaMapError::UnknownDevice) => b"[DMA] validate unknown-device", - ("alloc", DmaMapError::NoMemory) => b"[DMA] alloc no-memory", - ("install", DmaMapError::NoVaSpace) => b"[DMA] install no-va-space", - ("install", DmaMapError::MapFailed) => b"[DMA] install map-failed", - _ => b"[DMA] map failed", - }); - Err(error) -} +pub use transaction::map_for_caller; diff --git a/src/hardware/broker/dma/map/transaction.rs b/src/hardware/broker/dma/map/transaction.rs new file mode 100644 index 0000000000..cd4fb122e6 --- /dev/null +++ b/src/hardware/broker/dma/map/transaction.rs @@ -0,0 +1,68 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +use super::super::records; +use super::super::types::{DmaGrant, DmaMapError, DmaMapRequest, DmaMapResult}; +use super::fail::fail; +use super::{alloc, install, validate}; + +// `MkDmaMap`: validate -> alloc+zero frames -> install user pages -> +// record. Each step is a single responsibility in its own file; this +// function is the transaction boundary and owns the rollback chain. +pub fn map_for_caller(pid: u32, req: DmaMapRequest) -> Result { + let claim_epoch = match validate::validate(&req, pid) { + Ok(epoch) => epoch, + Err(e) => return fail("validate", e), + }; + let pages = req.length / validate::PAGE_SIZE; + + let phys_start = match alloc::alloc_and_zero(pages, req.length, req.flags) { + Ok(start) => start, + Err(e) => return fail("alloc", e), + }; + + let user_va = match install::install(pages, req.length, phys_start) { + Ok(va) => va, + Err(e) => { + alloc::free(phys_start, pages); + return fail("install", e); + } + }; + + let Some((device_addr, confined)) = + crate::hardware::broker::confine::map(pid, req.device_id, phys_start, req.length) + else { + install::uninstall(user_va, req.length); + alloc::free(phys_start, pages); + return fail("confine", DmaMapError::MapFailed); + }; + + let grant_id = records::allocate_id(); + records::insert(DmaGrant { + grant_id, + pid, + device_id: req.device_id, + claim_epoch, + physical_start: phys_start, + user_va, + length: req.length, + flags: req.flags, + device_addr, + confined, + }); + + Ok(DmaMapResult { user_va, device_addr, length: req.length, grant_id }) +} diff --git a/src/hardware/broker/dma/mod.rs b/src/hardware/broker/dma/mod.rs index f43ee2f8a1..341dc8e10f 100644 --- a/src/hardware/broker/dma/mod.rs +++ b/src/hardware/broker/dma/mod.rs @@ -19,6 +19,7 @@ mod map; mod pool; mod records; mod release; +mod scrub; mod types; mod va; diff --git a/src/hardware/broker/dma/release.rs b/src/hardware/broker/dma/release.rs index 8fd1a65a3b..763a8f8dc0 100644 --- a/src/hardware/broker/dma/release.rs +++ b/src/hardware/broker/dma/release.rs @@ -20,18 +20,15 @@ //! * `MkDeviceRelease` — drains every grant tied to the device //! * process exit — drains every grant the dying pid owns //! -//! Revocation order: scrub the buffer, unmap user pages (when the -//! holder's CR3 is active so the unmap is in-context), free the -//! physical frame back to the allocator. The cross-pid teardown -//! path skips the unmap because dereferencing a foreign address -//! space would walk the wrong page tables; the AS reaper drops -//! those PTEs wholesale. +//! Revocation order: unmap user pages (when the holder's CR3 is +//! active), take the grant from the device's domain, scrub, free. +//! The cross-pid path skips the user unmap because a foreign address +//! space would walk the wrong page tables; the AS reaper drops those. use super::pool; use super::records; use super::types::{DmaError, DmaGrant}; use crate::memory::addr::VirtAddr; -use crate::memory::layout::DIRECTMAP_BASE; use crate::memory::phys::free_contiguous; const PAGE_SIZE: u64 = 4096; @@ -59,10 +56,16 @@ pub fn release_all_for_pid(pid: u32, unmap_pages: bool) -> usize { } fn teardown(g: &DmaGrant, unmap_pages: bool) { - scrub_buffer(g.physical_start, g.length); if unmap_pages { let _ = crate::memory::paging::unmap_user_dma(VirtAddr::new(g.user_va), g.length as usize); } + // The device loses the grant before anyone else can gain the frames. If + // its domain will not give it up, the frames are leaked, never reused. + if !super::super::confine::unmap(g.pid, g.device_addr, g.length, g.confined) { + crate::sys::serial::println(b"[DMA] grant still reachable by its device; frames quarantined"); + return; + } + super::scrub::scrub(g.physical_start, g.length); let pages = (g.length / PAGE_SIZE) as usize; if pool::low32_owns(g.physical_start) { pool::low32_free(g.physical_start, pages); @@ -70,21 +73,3 @@ fn teardown(g: &DmaGrant, unmap_pages: bool) { let _ = free_contiguous(g.physical_start, pages); } } - -// Scrub the page through the kernel direct map before returning -// the frame to the global allocator. The next consumer of this -// frame must not see whatever the previous holder left there. -// -// SAFETY: eK@nonos.systems — `physical_start` came from -// `allocate_frame` and is only ever referenced through the broker -// grant table. The grant is removed from the records before this -// runs, so no other path can race on the same VA. -fn scrub_buffer(physical_start: u64, length: u64) { - let kva = (DIRECTMAP_BASE + physical_start) as *mut u64; - let words = (length / 8) as usize; - unsafe { - for i in 0..words { - core::ptr::write_volatile(kva.add(i), 0); - } - } -} diff --git a/src/hardware/broker/dma/scrub.rs b/src/hardware/broker/dma/scrub.rs new file mode 100644 index 0000000000..65bf0cdce1 --- /dev/null +++ b/src/hardware/broker/dma/scrub.rs @@ -0,0 +1,35 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +use crate::memory::layout::DIRECTMAP_BASE; + +// Scrub the page through the kernel direct map before returning +// the frame to the global allocator. The next consumer of this +// frame must not see whatever the previous holder left there. +// +// SAFETY: eK@nonos.systems — `physical_start` came from +// `allocate_frame` and is only ever referenced through the broker +// grant table. The grant is removed from the records before this +// runs, so no other path can race on the same VA. +pub(super) fn scrub(physical_start: u64, length: u64) { + let kva = (DIRECTMAP_BASE + physical_start) as *mut u64; + let words = (length / 8) as usize; + unsafe { + for i in 0..words { + core::ptr::write_volatile(kva.add(i), 0); + } + } +} diff --git a/src/hardware/broker/dma/types.rs b/src/hardware/broker/dma/types.rs index ba2f0daa10..01b7badfe5 100644 --- a/src/hardware/broker/dma/types.rs +++ b/src/hardware/broker/dma/types.rs @@ -26,6 +26,9 @@ pub struct DmaGrant { pub user_va: u64, pub length: u64, pub flags: u32, + /// What the device was given: an IOVA when `confined`, else `physical_start`. + pub device_addr: u64, + pub confined: bool, } #[derive(Debug, Clone, Copy)] diff --git a/src/hardware/broker/mod.rs b/src/hardware/broker/mod.rs index fc6bd3abe4..32354855ca 100644 --- a/src/hardware/broker/mod.rs +++ b/src/hardware/broker/mod.rs @@ -25,6 +25,7 @@ mod acpi_i2c; // mod census; mod claim; mod class; +mod confine; mod device; pub mod dma; mod grant; diff --git a/src/memory/iommu/backend_x86_64/domain.rs b/src/memory/iommu/backend_x86_64/domain.rs index d1e660f7bb..1830eda7ae 100644 --- a/src/memory/iommu/backend_x86_64/domain.rs +++ b/src/memory/iommu/backend_x86_64/domain.rs @@ -19,6 +19,7 @@ use crate::arch::x86_64::iommu::domain::DomainId as VtdDomainId; use crate::arch::x86_64::iommu::domain::{create_domain, destroy_domain}; use crate::arch::x86_64::iommu::globals::allocate_domain_id; +use crate::arch::x86_64::iommu::types::VtdError; use crate::memory::iommu::{DomainId, IommuError}; use super::enforced; @@ -28,13 +29,17 @@ use super::enforced; /// this backend must never let a caller believe it has. pub(crate) fn allocate_domain() -> Result { enforced::require()?; - let raw_id = allocate_domain_id(); - if raw_id > u16::MAX as u64 { - return Err(IommuError::DomainExhausted); + // A slot found free can be taken by a racing claim before it is created; + // the loser looks again rather than failing a claim that had room. + for _ in 0..4 { + let raw = u16::try_from(allocate_domain_id()).map_err(|_| IommuError::DomainExhausted)?; + match create_domain(VtdDomainId::new(raw)) { + Ok(()) => return Ok(DomainId::new(raw)), + Err(VtdError::DomainAlreadyExists) => continue, + Err(_) => return Err(IommuError::DomainExhausted), + } } - let vtd_id = VtdDomainId::new(raw_id as u16); - create_domain(vtd_id).map_err(|_| IommuError::DomainExhausted)?; - Ok(DomainId::new(raw_id as u16)) + Err(IommuError::DomainExhausted) } /// Teardown is deliberately ungated: a domain can only exist if allocation diff --git a/src/syscall/microkernel/device.rs b/src/syscall/microkernel/device.rs index c3c8f795ce..916ce6d8db 100644 --- a/src/syscall/microkernel/device.rs +++ b/src/syscall/microkernel/device.rs @@ -77,6 +77,7 @@ pub fn sys_device_claim(device_id: u64) -> i64 { Err(ClaimError::AlreadyClaimed) => ERRNO_BUSY, Err(ClaimError::UnknownDevice) => ERRNO_NODEV, Err(ClaimError::NotHolder) | Err(ClaimError::NotClaimed) => ERRNO_INVAL, + Err(ClaimError::Unconfined) => ERRNO_PERM, } } @@ -102,6 +103,8 @@ pub fn sys_device_release(device_id: u64) -> i64 { } Err(ClaimError::NotClaimed) => ERRNO_NODEV, Err(ClaimError::NotHolder) => ERRNO_PERM, - Err(ClaimError::AlreadyClaimed) | Err(ClaimError::UnknownDevice) => ERRNO_INVAL, + Err(ClaimError::AlreadyClaimed) + | Err(ClaimError::UnknownDevice) + | Err(ClaimError::Unconfined) => ERRNO_INVAL, } } From 24e72e6e6e95e6775f7780568b9f25195bdd51cb Mon Sep 17 00:00:00 2001 From: eKisNonos Date: Sun, 27 Sep 2026 09:14:48 +0000 Subject: [PATCH 076/244] paging: read the asid and root of the cpu asking, not the last one active_asid and translate_address used one manager-wide value, the last cpu to switch, so a fault or loader on another cpu used the wrong process. A cpu now records its asid before loading CR3, fenced against the shootdown's target selection. Shootdown split up. --- .../paging/manager/address_space/switch.rs | 10 +- src/memory/paging/manager/api/query.rs | 8 +- src/memory/paging/manager/core/query.rs | 4 - src/memory/paging/manager/core/types.rs | 2 - src/memory/paging/manager/shootdown.rs | 299 ------------------ .../paging/manager/shootdown/broadcast.rs | 57 ++++ src/memory/paging/manager/shootdown/flush.rs | 60 ++++ src/memory/paging/manager/shootdown/handle.rs | 46 +++ src/memory/paging/manager/shootdown/mod.rs | 38 +++ src/memory/paging/manager/shootdown/report.rs | 71 +++++ .../paging/manager/shootdown/request.rs | 39 +++ src/memory/paging/manager/shootdown/select.rs | 65 ++++ src/memory/paging/manager/shootdown/send.rs | 43 +++ src/memory/paging/manager/shootdown/wait.rs | 55 ++++ src/memory/paging/manager/translation/walk.rs | 6 +- 15 files changed, 491 insertions(+), 312 deletions(-) delete mode 100644 src/memory/paging/manager/shootdown.rs create mode 100644 src/memory/paging/manager/shootdown/broadcast.rs create mode 100644 src/memory/paging/manager/shootdown/flush.rs create mode 100644 src/memory/paging/manager/shootdown/handle.rs create mode 100644 src/memory/paging/manager/shootdown/mod.rs create mode 100644 src/memory/paging/manager/shootdown/report.rs create mode 100644 src/memory/paging/manager/shootdown/request.rs create mode 100644 src/memory/paging/manager/shootdown/select.rs create mode 100644 src/memory/paging/manager/shootdown/send.rs create mode 100644 src/memory/paging/manager/shootdown/wait.rs diff --git a/src/memory/paging/manager/address_space/switch.rs b/src/memory/paging/manager/address_space/switch.rs index 7b61c964e8..29d797eeb7 100644 --- a/src/memory/paging/manager/address_space/switch.rs +++ b/src/memory/paging/manager/address_space/switch.rs @@ -31,13 +31,13 @@ impl PagingManager { // nothing for the switch. The asid is still tracked on the CPU below, // where the shootdown broadcaster does use it. let root = address_space.cr3_value.as_u64(); + // Recorded on this cpu before CR3 is loaded, and fenced against the + // broadcaster's fence: set after, a shootdown between the load and the + // store would skip a cpu already caching the entries it replaces. + crate::smp::percpu::set_active_asid(asid); + core::sync::atomic::fence(core::sync::atomic::Ordering::SeqCst); crate::arch::paging::write_root(root, (root & 0xFFF) as u16); self.active_page_table = Some(address_space.cr3_value); - self.active_asid = Some(asid); - // Record on the calling CPU which asid is now executing. - // The TLB shootdown broadcaster reads this to scope per-asid - // invalidations to the cores actually running that CR3. - crate::smp::percpu::set_active_asid(asid); Ok(()) } } diff --git a/src/memory/paging/manager/api/query.rs b/src/memory/paging/manager/api/query.rs index 3af5f26c70..28a683f38b 100644 --- a/src/memory/paging/manager/api/query.rs +++ b/src/memory/paging/manager/api/query.rs @@ -47,6 +47,12 @@ pub fn address_spaces_count() -> usize { lock_responsive(&PAGING_MANAGER).address_spaces_count() } +// The calling cpu's asid. One manager-wide value was whichever cpu switched +// last, so on more than one cpu a loader asked for "the active address space" +// could be handed another cpu's and map an image into the wrong process. pub fn active_asid() -> Option { - lock_responsive(&PAGING_MANAGER).active_asid() + if !lock_responsive(&PAGING_MANAGER).is_initialized() { + return None; + } + Some(crate::smp::percpu::active_asid()) } diff --git a/src/memory/paging/manager/core/query.rs b/src/memory/paging/manager/core/query.rs index b33ae007a6..849caff20b 100644 --- a/src/memory/paging/manager/core/query.rs +++ b/src/memory/paging/manager/core/query.rs @@ -26,10 +26,6 @@ impl PagingManager { self.active_page_table } - pub fn active_asid(&self) -> Option { - self.active_asid - } - pub fn mappings_count(&self) -> usize { self.mappings.len() } diff --git a/src/memory/paging/manager/core/types.rs b/src/memory/paging/manager/core/types.rs index a1c6f47ae4..7ae2f81927 100644 --- a/src/memory/paging/manager/core/types.rs +++ b/src/memory/paging/manager/core/types.rs @@ -28,7 +28,6 @@ pub struct PagingManager { /// shootdown wrappers in `manager::shootdown` to scope per-asid /// invalidations. `None` before any process has been dispatched /// (boot's kernel page tables, no user CR3 active). - pub(crate) active_asid: Option, pub(crate) mappings: BTreeMap, pub(crate) address_spaces: BTreeMap, pub(crate) next_asid: u32, @@ -39,7 +38,6 @@ impl PagingManager { pub const fn new() -> Self { Self { active_page_table: None, - active_asid: None, mappings: BTreeMap::new(), address_spaces: BTreeMap::new(), next_asid: FIRST_USER_ASID, diff --git a/src/memory/paging/manager/shootdown.rs b/src/memory/paging/manager/shootdown.rs deleted file mode 100644 index e6364def70..0000000000 --- a/src/memory/paging/manager/shootdown.rs +++ /dev/null @@ -1,299 +0,0 @@ -// NONOS Operating System -// Copyright (C) 2026 NONOS Contributors -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -//! Asid-scoped TLB shootdown for every page-table mutation site in -//! the paging manager. Always issues the local `invlpg` first; on -//! multi-CPU runtime it then IPIs the peer CPUs running the same -//! asid (or every online CPU for a kernel-half flush). On single-CPU -//! runtime the broadcast block is skipped. Timeout policy is fail- -//! hard: a stale TLB entry would back freed DMA or MMIO, so an ack -//! that does not arrive inside the shootdown budget (`shootdown_timeout_ticks`) -//! triggers a panic-IPI broadcast and halts the originator. - -use core::sync::atomic::{AtomicU32, AtomicU64, Ordering}; -use spin::Mutex; - -use super::super::tlb; -use crate::arch::interrupt_controller::Ipi; -use crate::memory::addr::VirtAddr; -use crate::memory::paging::constants::PAGE_SIZE_4K; -use crate::smp::cpus_online; -use crate::smp::percpu::ASID_NONE; - -/// `0` is the sentinel for "kernel half" or "no asid scoping". A -/// flush issued with `asid == ASID_KERNEL` reaches every online CPU -/// because the kernel half is shared across every address space. -pub const ASID_KERNEL: u32 = 0; - -/// Target bound on cross-CPU wait, in wall-clock milliseconds, converted to -/// ticks against the calibrated counter frequency by `shootdown_timeout_ticks`. -/// Far longer than any healthy `invlpg` cycle even under a descheduled peer -/// vCPU. Tuned upwards is fine; tuned to "wait forever" is forbidden. -const SHOOTDOWN_TIMEOUT_MS: u64 = 50; - -/// Tick budget used when the computed budget comes back `0` (uncalibrated, -/// or a frequency too low to clear one millisecond at this resolution). At -/// least 50ms on any CPU up to 5 GHz. -const SHOOTDOWN_TIMEOUT_FALLBACK_TICKS: u64 = 250_000_000; - -static SHOOTDOWN_LOCK: Mutex<()> = Mutex::new(()); -static REQ_VA: AtomicU64 = AtomicU64::new(0); -static REQ_PAGES: AtomicU32 = AtomicU32::new(0); -static REQ_PENDING_ACKS: AtomicU32 = AtomicU32::new(0); - -#[inline] -pub fn flush_tlb_one_smp(va: VirtAddr, asid: u32) { - tlb::invalidate_page(va); - if cpus_online() <= 1 { - return; - } - broadcast(va, 1, asid); -} - -#[inline] -pub fn flush_tlb_range_smp(start: VirtAddr, page_count: usize, asid: u32) { - if page_count == 0 { - return; - } - if page_count > 32 { - flush_tlb_all_smp(asid); - return; - } - for i in 0..page_count { - let va = VirtAddr::new(start.as_u64() + (i * PAGE_SIZE_4K) as u64); - tlb::invalidate_page(va); - } - if cpus_online() <= 1 { - return; - } - broadcast(start, page_count as u32, asid); -} - -#[inline] -pub fn flush_tlb_all_smp(asid: u32) { - tlb::invalidate_all(); - if cpus_online() <= 1 { - return; - } - // Encode "flush whole TLB" as page_count == 0 in the request - // slot; the IPI handler treats that as `invalidate_all`. - broadcast(VirtAddr::new(0), 0, asid); -} - -fn broadcast(va: VirtAddr, page_count: u32, asid: u32) { - // Serve any round already in flight while waiting for our turn. Page-table - // mutation sites reach here with interrupts masked, so a cpu that simply - // blocked on the lock could not answer the holder's IPI, and the two would - // wait on each other until the timeout below halted the machine. - let _guard = loop { - if let Some(guard) = SHOOTDOWN_LOCK.try_lock() { - break guard; - } - handle_shootdown_ipi(); - core::hint::spin_loop(); - }; - - let self_cpu = crate::smp::cpu_id(); - let mut targets: u32 = 0; - let mut selected = [0u64; crate::smp::MAX_CPUS.div_ceil(64)]; - /* - * Every cpu slot, filtered by whether it is running. Not `0..cpus_online()`: - * that is a population count, while cpu numbers are handed out once per AP - * attempted and are not reused when one fails. With a single failed AP the - * live numbers are sparse, so counting up to the population both targets a - * slot that never started, which can never acknowledge, and skips a cpu - * that is running, which never gets the IPI. The wait below then always - * reaches its deadline and halts the machine. - */ - for cpu in 0..crate::smp::MAX_CPUS { - if cpu == self_cpu || !crate::smp::cpu_is_online(cpu) { - continue; - } - let Some(d) = crate::smp::percpu::get(cpu) else { - continue; - }; - if !cpu_should_flush(d, asid) { - continue; - } - selected[cpu / 64] |= 1u64 << (cpu % 64); - targets += 1; - } - if targets == 0 { - return; - } - - REQ_VA.store(va.as_u64(), Ordering::Release); - REQ_PAGES.store(page_count, Ordering::Release); - REQ_PENDING_ACKS.store(targets, Ordering::SeqCst); - - /* - * Mark and send from the set chosen above rather than re-deriving it, and - * only now that the request and the ack count are published. A cpu serves - * this round by hand the moment it sees its own mark, from the lock spin - * above or from `lock_responsive`, with no ipi involved; marking before - * the count was armed let that cpu pay an ack into a count of zero, which - * wrapped and was then overwritten by the arming store, so the ack was - * owed by nobody and the wait below always reached its deadline. Deriving - * the set twice would be its own bug: a cpu that came online in between - * would be marked without being counted. - */ - for cpu in 0..crate::smp::MAX_CPUS { - if selected[cpu / 64] & (1u64 << (cpu % 64)) == 0 { - continue; - } - let Some(d) = crate::smp::percpu::get(cpu) else { - continue; - }; - d.tlb_flush_pending.store(1, Ordering::Release); - let _ = crate::arch::interrupt_controller::send_ipi(d.apic_id, Ipi::TlbShootdown); - } - wait_for_acks(); -} - -#[inline] -fn cpu_should_flush(data: &crate::smp::percpu::PerCpuData, asid: u32) -> bool { - if asid == ASID_KERNEL { - return true; - } - let active = data.active_asid.load(Ordering::Acquire); - active != ASID_NONE && active == asid -} - -/// Flush for the round in progress, if this cpu is one of its targets. -/// -/// Driven by the TlbShootdown vector, and also called directly by a cpu -/// spinning for the lock in `broadcast`. The pending flag makes it safe either -/// way: it is what says the round applies to us, and clearing it before the -/// ack means neither path can acknowledge twice. -pub fn handle_shootdown_ipi() { - let me = crate::smp::percpu::current(); - if me.tlb_flush_pending.swap(0, Ordering::AcqRel) == 0 { - return; - } - let pages = REQ_PAGES.load(Ordering::Acquire); - if pages == 0 { - tlb::invalidate_all(); - } else { - let base = VirtAddr::new(REQ_VA.load(Ordering::Acquire)); - for i in 0..pages as usize { - let va = VirtAddr::new(base.as_u64() + (i * PAGE_SIZE_4K) as u64); - tlb::invalidate_page(va); - } - } - REQ_PENDING_ACKS.fetch_sub(1, Ordering::Release); -} - -fn shootdown_timeout_ticks() -> u64 { - let ticks = crate::sys::timer::tsc::tsc_frequency() / 1000 * SHOOTDOWN_TIMEOUT_MS; - if ticks == 0 { - return SHOOTDOWN_TIMEOUT_FALLBACK_TICKS; - } - ticks -} - -fn wait_for_acks() { - let budget = shootdown_timeout_ticks(); - let deadline = read_tsc().wrapping_add(budget); - while REQ_PENDING_ACKS.load(Ordering::Acquire) > 0 { - if read_tsc() > deadline { - let outstanding = REQ_PENDING_ACKS.load(Ordering::Acquire); - if outstanding == 0 { - return; - } - let mut line = crate::sys::serial::Line::new(); - line.str(b"[FATAL] TLB shootdown timeout outstanding=").dec(outstanding as u64); - line.end(); - report_stuck(); - crate::smp::send_panic_ipi(); - crate::arch::halt_loop(); - } - core::hint::spin_loop(); - } -} - -#[inline] -fn read_tsc() -> u64 { - // SAFETY: eK@nonos.systems — rdtsc has no side effects and is - // unconditionally available on every x86_64 CPU NØNOS supports. - crate::arch::read_time_counter() -} - -/// What every CPU looked like when the round gave up, printed before the halt. -/// -/// A timeout says only that an acknowledgement did not arrive. Which CPU owed -/// it, whether that CPU was ever marked as a target, whether it is halted in -/// its idle loop or inside an interrupt handler, and whether it has taken a -/// timer interrupt since it came up are what separate "the IPI was never -/// delivered" from "the IPI was delivered and the CPU was in no position to -/// run it". -/// -/// Each of those has to be read from something that is actually written. This -/// used to name interrupt-masking depth as well, and printed a field nothing -/// maintains. -fn report_stuck() { - let mut head = crate::sys::serial::Line::new(); - head.str(b"[SMP] acks outstanding=").dec(REQ_PENDING_ACKS.load(Ordering::Acquire) as u64); - head.end(); - for cpu in 0..crate::smp::MAX_CPUS { - if !crate::smp::cpu_is_online(cpu) { - continue; - } - let (Some(d), Some(desc)) = (crate::smp::percpu::get(cpu), crate::smp::get_cpu(cpu)) else { - continue; - }; - /* - * One line per cpu, built whole. These are printed while the other - * cpus are still running and printing, and a dump that interleaves - * with them is unreadable exactly when it is needed. - * - * `irq_depth` comes from `interrupts::safety`, which the live handlers - * maintain. This used to print `smp::percpu::irq_nesting` beside an - * `interrupt_disable_depth`, and nothing writes either of them: - * `enter_irq`, `leave_irq` and `in_irq` have no callers, and the - * disable depth is only ever read here. Both columns were zero on - * every cpu of every dump this kernel has ever produced, which reads - * as a measurement and is a constant. The disable depth is gone rather - * than reported, since there is nothing behind it to report. - */ - let mut l = crate::sys::serial::Line::new(); - l.str(b"[SMP] cpu=").dec(cpu as u64); - l.str(b" apic=").dec(d.apic_id as u64); - l.str(b" pending=").dec(d.tlb_flush_pending.load(Ordering::Acquire) as u64); - l.str(b" irq_depth=").dec(crate::interrupts::safety::depth_of(cpu) as u64); - l.str(b" asid=").dec(d.active_asid.load(Ordering::Acquire) as u64); - l.str(b" idle=").dec(u64::from(desc.idle.load(Ordering::Acquire))); - l.str(b" idle_cycles=").dec(desc.idle_cycles.load(Ordering::Acquire)); - // Zero means this cpu has never taken a timer interrupt, which - // separates "did not answer this round" from "has not answered - // anything since it came up". Those need different fixes and the dump - // could not tell them apart. - l.str(b" ticked=").dec(u64::from(d.last_tick_tsc.load(Ordering::Acquire) != 0)); - // Where it was when it stopped answering. A halted CPU and one - // spinning on a lock with interrupts masked are the same silence from - // here, and they are not the same defect. - l.str(b" at=").str(desc.stage().as_str().as_bytes()); - // What that CPU's own APIC had in service when it last looked. A vector - // stuck here blocks its whole priority class and everything below it, - // while leaving higher classes working, which is what a CPU taking - // IPIs at 0x40 and no timer at 0x20 looks like from outside. - match desc.in_service_seen.load(Ordering::Acquire) { - 0 => l.str(b" isr=unread"), - 1 => l.str(b" isr=none"), - v => l.str(b" isr=").hex((v - 2) as u64), - }; - l.end(); - } -} diff --git a/src/memory/paging/manager/shootdown/broadcast.rs b/src/memory/paging/manager/shootdown/broadcast.rs new file mode 100644 index 0000000000..486744376b --- /dev/null +++ b/src/memory/paging/manager/shootdown/broadcast.rs @@ -0,0 +1,57 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +use core::sync::atomic::Ordering; + +use super::handle::handle_shootdown_ipi; +use super::request::{REQ_PAGES, REQ_PENDING_ACKS, REQ_VA, SHOOTDOWN_LOCK}; +use super::select::select; +use super::send::mark_and_send; +use super::wait::wait_for_acks; +use crate::memory::addr::VirtAddr; + +pub(super) fn broadcast(va: VirtAddr, page_count: u32, asid: u32) { + // Serve any round already in flight while waiting for our turn. Page-table + // mutation sites reach here with interrupts masked, so a cpu that simply + // blocked on the lock could not answer the holder's IPI, and the two would + // wait on each other until the timeout below halted the machine. + let _guard = loop { + if let Some(guard) = SHOOTDOWN_LOCK.try_lock() { + break guard; + } + handle_shootdown_ipi(); + core::hint::spin_loop(); + }; + + /* + * Paired with the fence a cpu takes between recording its asid and loading + * CR3 (`switch_address_space`). The page table writes this round flushes + * are already done; either that cpu's asid is seen below, or its CR3 load + * comes after those writes and it cannot have cached the old entries. + */ + core::sync::atomic::fence(Ordering::SeqCst); + let (selected, targets) = select(asid); + if targets == 0 { + return; + } + + REQ_VA.store(va.as_u64(), Ordering::Release); + REQ_PAGES.store(page_count, Ordering::Release); + REQ_PENDING_ACKS.store(targets, Ordering::SeqCst); + + mark_and_send(&selected); + wait_for_acks(); +} diff --git a/src/memory/paging/manager/shootdown/flush.rs b/src/memory/paging/manager/shootdown/flush.rs new file mode 100644 index 0000000000..dfc4b55d18 --- /dev/null +++ b/src/memory/paging/manager/shootdown/flush.rs @@ -0,0 +1,60 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +use super::broadcast::broadcast; +use crate::memory::addr::VirtAddr; +use crate::memory::paging::constants::PAGE_SIZE_4K; +use crate::memory::paging::tlb; +use crate::smp::cpus_online; + +#[inline] +pub fn flush_tlb_one_smp(va: VirtAddr, asid: u32) { + tlb::invalidate_page(va); + if cpus_online() <= 1 { + return; + } + broadcast(va, 1, asid); +} + +#[inline] +pub fn flush_tlb_range_smp(start: VirtAddr, page_count: usize, asid: u32) { + if page_count == 0 { + return; + } + if page_count > 32 { + flush_tlb_all_smp(asid); + return; + } + for i in 0..page_count { + let va = VirtAddr::new(start.as_u64() + (i * PAGE_SIZE_4K) as u64); + tlb::invalidate_page(va); + } + if cpus_online() <= 1 { + return; + } + broadcast(start, page_count as u32, asid); +} + +#[inline] +pub fn flush_tlb_all_smp(asid: u32) { + tlb::invalidate_all(); + if cpus_online() <= 1 { + return; + } + // Encode "flush whole TLB" as page_count == 0 in the request + // slot; the IPI handler treats that as `invalidate_all`. + broadcast(VirtAddr::new(0), 0, asid); +} diff --git a/src/memory/paging/manager/shootdown/handle.rs b/src/memory/paging/manager/shootdown/handle.rs new file mode 100644 index 0000000000..740fe40b8b --- /dev/null +++ b/src/memory/paging/manager/shootdown/handle.rs @@ -0,0 +1,46 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +use core::sync::atomic::Ordering; + +use super::request::{REQ_PAGES, REQ_PENDING_ACKS, REQ_VA}; +use crate::memory::addr::VirtAddr; +use crate::memory::paging::constants::PAGE_SIZE_4K; +use crate::memory::paging::tlb; + +/// Flush for the round in progress, if this cpu is one of its targets. +/// +/// Driven by the TlbShootdown vector, and also called directly by a cpu +/// spinning for the lock in `broadcast`. The pending flag makes it safe either +/// way: it is what says the round applies to us, and clearing it before the +/// ack means neither path can acknowledge twice. +pub fn handle_shootdown_ipi() { + let me = crate::smp::percpu::current(); + if me.tlb_flush_pending.swap(0, Ordering::AcqRel) == 0 { + return; + } + let pages = REQ_PAGES.load(Ordering::Acquire); + if pages == 0 { + tlb::invalidate_all(); + } else { + let base = VirtAddr::new(REQ_VA.load(Ordering::Acquire)); + for i in 0..pages as usize { + let va = VirtAddr::new(base.as_u64() + (i * PAGE_SIZE_4K) as u64); + tlb::invalidate_page(va); + } + } + REQ_PENDING_ACKS.fetch_sub(1, Ordering::Release); +} diff --git a/src/memory/paging/manager/shootdown/mod.rs b/src/memory/paging/manager/shootdown/mod.rs new file mode 100644 index 0000000000..d5f9b3a4f8 --- /dev/null +++ b/src/memory/paging/manager/shootdown/mod.rs @@ -0,0 +1,38 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + + +//! Asid-scoped TLB shootdown for every page-table mutation site in +//! the paging manager. Always issues the local `invlpg` first; on +//! multi-CPU runtime it then IPIs the peer CPUs running the same +//! asid (or every online CPU for a kernel-half flush). On single-CPU +//! runtime the broadcast block is skipped. Timeout policy is fail- +//! hard: a stale TLB entry would back freed DMA or MMIO, so an ack +//! that does not arrive inside the shootdown budget (`shootdown_timeout_ticks`) +//! triggers a panic-IPI broadcast and halts the originator. + +mod broadcast; +mod flush; +mod handle; +mod report; +mod request; +mod select; +mod send; +mod wait; + +pub use flush::{flush_tlb_all_smp, flush_tlb_one_smp, flush_tlb_range_smp}; +pub use handle::handle_shootdown_ipi; +pub use request::ASID_KERNEL; diff --git a/src/memory/paging/manager/shootdown/report.rs b/src/memory/paging/manager/shootdown/report.rs new file mode 100644 index 0000000000..3850e7dd10 --- /dev/null +++ b/src/memory/paging/manager/shootdown/report.rs @@ -0,0 +1,71 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +use core::sync::atomic::Ordering; + +use super::request::REQ_PENDING_ACKS; + +/// What every CPU looked like when the round gave up, printed before the halt. +/// A timeout says only that an ack did not arrive; which cpu owed it, whether +/// it was marked, and whether it is idle or in a handler separate "the IPI was +/// never delivered" from "the cpu was in no position to run it". +pub(super) fn report_stuck() { + let mut head = crate::sys::serial::Line::new(); + head.str(b"[SMP] acks outstanding=").dec(REQ_PENDING_ACKS.load(Ordering::Acquire) as u64); + head.end(); + for cpu in 0..crate::smp::MAX_CPUS { + if !crate::smp::cpu_is_online(cpu) { + continue; + } + let (Some(d), Some(desc)) = (crate::smp::percpu::get(cpu), crate::smp::get_cpu(cpu)) else { + continue; + }; + /* + * One line per cpu, built whole, so it does not interleave with the + * other cpus still printing. `irq_depth` comes from + * `interrupts::safety`, which the live handlers maintain; the old + * `irq_nesting` and disable-depth columns had no writers and read + * zero on every dump, so they are gone rather than reported. + */ + let mut l = crate::sys::serial::Line::new(); + l.str(b"[SMP] cpu=").dec(cpu as u64); + l.str(b" apic=").dec(d.apic_id as u64); + l.str(b" pending=").dec(d.tlb_flush_pending.load(Ordering::Acquire) as u64); + l.str(b" irq_depth=").dec(crate::interrupts::safety::depth_of(cpu) as u64); + l.str(b" asid=").dec(d.active_asid.load(Ordering::Acquire) as u64); + l.str(b" idle=").dec(u64::from(desc.idle.load(Ordering::Acquire))); + l.str(b" idle_cycles=").dec(desc.idle_cycles.load(Ordering::Acquire)); + // Zero means this cpu has never taken a timer interrupt, which + // separates "did not answer this round" from "has not answered + // anything since it came up". Those need different fixes and the dump + // could not tell them apart. + l.str(b" ticked=").dec(u64::from(d.last_tick_tsc.load(Ordering::Acquire) != 0)); + // Where it was when it stopped answering. A halted CPU and one + // spinning on a lock with interrupts masked are the same silence from + // here, and they are not the same defect. + l.str(b" at=").str(desc.stage().as_str().as_bytes()); + // What that CPU's own APIC had in service when it last looked. A vector + // stuck here blocks its whole priority class and everything below it, + // while leaving higher classes working, which is what a CPU taking + // IPIs at 0x40 and no timer at 0x20 looks like from outside. + match desc.in_service_seen.load(Ordering::Acquire) { + 0 => l.str(b" isr=unread"), + 1 => l.str(b" isr=none"), + v => l.str(b" isr=").hex((v - 2) as u64), + }; + l.end(); + } +} diff --git a/src/memory/paging/manager/shootdown/request.rs b/src/memory/paging/manager/shootdown/request.rs new file mode 100644 index 0000000000..50d873362e --- /dev/null +++ b/src/memory/paging/manager/shootdown/request.rs @@ -0,0 +1,39 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +use core::sync::atomic::{AtomicU32, AtomicU64}; +use spin::Mutex; + +/// `0` is the sentinel for "kernel half" or "no asid scoping". A +/// flush issued with `asid == ASID_KERNEL` reaches every online CPU +/// because the kernel half is shared across every address space. +pub const ASID_KERNEL: u32 = 0; + +/// Target bound on cross-CPU wait, in wall-clock milliseconds, converted to +/// ticks against the calibrated counter frequency by `shootdown_timeout_ticks`. +/// Far longer than any healthy `invlpg` cycle even under a descheduled peer +/// vCPU. Tuned upwards is fine; tuned to "wait forever" is forbidden. +pub(super) const SHOOTDOWN_TIMEOUT_MS: u64 = 50; + +/// Tick budget used when the computed budget comes back `0` (uncalibrated, +/// or a frequency too low to clear one millisecond at this resolution). At +/// least 50ms on any CPU up to 5 GHz. +pub(super) const SHOOTDOWN_TIMEOUT_FALLBACK_TICKS: u64 = 250_000_000; + +pub(super) static SHOOTDOWN_LOCK: Mutex<()> = Mutex::new(()); +pub(super) static REQ_VA: AtomicU64 = AtomicU64::new(0); +pub(super) static REQ_PAGES: AtomicU32 = AtomicU32::new(0); +pub(super) static REQ_PENDING_ACKS: AtomicU32 = AtomicU32::new(0); diff --git a/src/memory/paging/manager/shootdown/select.rs b/src/memory/paging/manager/shootdown/select.rs new file mode 100644 index 0000000000..b77a9e2594 --- /dev/null +++ b/src/memory/paging/manager/shootdown/select.rs @@ -0,0 +1,65 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +use core::sync::atomic::Ordering; + +use super::request::ASID_KERNEL; +use crate::smp::percpu::ASID_NONE; + +const WORDS: usize = crate::smp::MAX_CPUS.div_ceil(64); + +/// The cpus a round for `asid` must reach, and how many. +pub(super) fn select(asid: u32) -> ([u64; WORDS], u32) { + let self_cpu = crate::smp::cpu_id(); + let mut targets: u32 = 0; + let mut selected = [0u64; WORDS]; + /* + * Every cpu slot, filtered by whether it is running. Not `0..cpus_online()`: + * that is a population count, while cpu numbers are handed out once per AP + * attempted and are not reused when one fails. With a single failed AP the + * live numbers are sparse, so counting up to the population both targets a + * slot that never started, which can never acknowledge, and skips a cpu + * that is running, which never gets the IPI. The wait in `broadcast` always + * reaches its deadline and halts the machine. + */ + for cpu in 0..crate::smp::MAX_CPUS { + if cpu == self_cpu || !crate::smp::cpu_is_online(cpu) { + continue; + } + let Some(d) = crate::smp::percpu::get(cpu) else { + continue; + }; + if !cpu_should_flush(d, asid) { + continue; + } + selected[cpu / 64] |= 1u64 << (cpu % 64); + targets += 1; + } + (selected, targets) +} + +/// Only a cpu running the asid can hold its entries: CR3 is loaded untagged +/// (PCID 0), which drops every non-global entry, so a cpu that switched away +/// holds none. With PCIDs this must become every cpu that has run the asid +/// since its last flush, or a tagged stale entry survives the switch back. +#[inline] +fn cpu_should_flush(data: &crate::smp::percpu::PerCpuData, asid: u32) -> bool { + if asid == ASID_KERNEL { + return true; + } + let active = data.active_asid.load(Ordering::Acquire); + active != ASID_NONE && active == asid +} diff --git a/src/memory/paging/manager/shootdown/send.rs b/src/memory/paging/manager/shootdown/send.rs new file mode 100644 index 0000000000..5e81b9598a --- /dev/null +++ b/src/memory/paging/manager/shootdown/send.rs @@ -0,0 +1,43 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +use core::sync::atomic::Ordering; + +use crate::arch::interrupt_controller::Ipi; + +/* + * Mark and send from the set `select` chose rather than re-deriving it, and + * only once `broadcast` has published the request and the ack count. A cpu serves + * this round by hand the moment it sees its own mark, from the lock spin + * in `broadcast` or from `lock_responsive`, with no ipi involved; marking before + * the count was armed let that cpu pay an ack into a count of zero, which + * wrapped and was then overwritten by the arming store, so the ack was + * owed by nobody and the wait below always reached its deadline. Deriving + * the set twice would be its own bug: a cpu that came online in between + * would be marked without being counted. + */ +pub(super) fn mark_and_send(selected: &[u64]) { + for cpu in 0..crate::smp::MAX_CPUS { + if selected[cpu / 64] & (1u64 << (cpu % 64)) == 0 { + continue; + } + let Some(d) = crate::smp::percpu::get(cpu) else { + continue; + }; + d.tlb_flush_pending.store(1, Ordering::Release); + let _ = crate::arch::interrupt_controller::send_ipi(d.apic_id, Ipi::TlbShootdown); + } +} diff --git a/src/memory/paging/manager/shootdown/wait.rs b/src/memory/paging/manager/shootdown/wait.rs new file mode 100644 index 0000000000..48d8013a8c --- /dev/null +++ b/src/memory/paging/manager/shootdown/wait.rs @@ -0,0 +1,55 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +use core::sync::atomic::Ordering; + +use super::report::report_stuck; +use super::request::{REQ_PENDING_ACKS, SHOOTDOWN_TIMEOUT_FALLBACK_TICKS, SHOOTDOWN_TIMEOUT_MS}; + +fn shootdown_timeout_ticks() -> u64 { + let ticks = crate::sys::timer::tsc::tsc_frequency() / 1000 * SHOOTDOWN_TIMEOUT_MS; + if ticks == 0 { + return SHOOTDOWN_TIMEOUT_FALLBACK_TICKS; + } + ticks +} + +pub(super) fn wait_for_acks() { + let budget = shootdown_timeout_ticks(); + let deadline = read_tsc().wrapping_add(budget); + while REQ_PENDING_ACKS.load(Ordering::Acquire) > 0 { + if read_tsc() > deadline { + let outstanding = REQ_PENDING_ACKS.load(Ordering::Acquire); + if outstanding == 0 { + return; + } + let mut line = crate::sys::serial::Line::new(); + line.str(b"[FATAL] TLB shootdown timeout outstanding=").dec(outstanding as u64); + line.end(); + report_stuck(); + crate::smp::send_panic_ipi(); + crate::arch::halt_loop(); + } + core::hint::spin_loop(); + } +} + +#[inline] +fn read_tsc() -> u64 { + // SAFETY: eK@nonos.systems — rdtsc has no side effects and is + // unconditionally available on every x86_64 CPU NØNOS supports. + crate::arch::read_time_counter() +} diff --git a/src/memory/paging/manager/translation/walk.rs b/src/memory/paging/manager/translation/walk.rs index 86f5d3eaa3..ba925e58f8 100644 --- a/src/memory/paging/manager/translation/walk.rs +++ b/src/memory/paging/manager/translation/walk.rs @@ -29,7 +29,11 @@ impl PagingManager { let l2_idx = pd_index(va_val); let l1_idx = pt_index(va_val); let offset = page_offset(va_val); - let cr3 = self.active_page_table.ok_or(PagingError::NoActivePageTable)?; + // This cpu's CR3; the manager's record is whichever cpu loaded one last. + let cr3 = Some(crate::arch::paging::read_root() & !0xFFF).filter(|&r| r != 0); + let cr3 = PhysAddr::new(cr3.ok_or(PagingError::NoActivePageTable)?); + // SAFETY: eK@nonos.systems - every table address comes from CR3 or a + // present entry, and the directmap maps all physical memory. unsafe { let l4_table = &*((layout::DIRECTMAP_BASE + cr3.as_u64()) as *const [u64; PAGE_TABLE_ENTRIES]); From 1d4eb38aea7f865ded77de5ce7ec88e224fd552b Mon Sep 17 00:00:00 2001 From: eKisNonos Date: Sun, 27 Sep 2026 09:16:10 +0000 Subject: [PATCH 077/244] xhci: assert Memory Space with Bus Master The broker ORs allowed bits into the Command register, so bus master was already set; a controller firmware left undecoded still dropped every MMIO access. i2c-pci and rtl8821ce already set both. --- userland/capsule_driver_xhci/src/setup/pci.rs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/userland/capsule_driver_xhci/src/setup/pci.rs b/userland/capsule_driver_xhci/src/setup/pci.rs index 222ce7237d..b235c7e8e3 100644 --- a/userland/capsule_driver_xhci/src/setup/pci.rs +++ b/userland/capsule_driver_xhci/src/setup/pci.rs @@ -14,12 +14,17 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -use nonos_libc::{mk_pci_config_write, MK_PCI_CFG_COMMAND, MK_PCI_CMD_BUS_MASTER}; +use nonos_libc::{ + mk_pci_config_write, MK_PCI_CFG_COMMAND, MK_PCI_CMD_BUS_MASTER, MK_PCI_CMD_MEMORY_SPACE, +}; use crate::error::{XhciError, XhciResult}; pub fn enable_bus_master(device_id: u64, claim_epoch: u64) -> XhciResult<()> { - let r = mk_pci_config_write(device_id, claim_epoch, MK_PCI_CFG_COMMAND, MK_PCI_CMD_BUS_MASTER); + // Memory Space too: firmware that never used the controller can leave it + // clear, and then every register access drops without an error. + let bits = MK_PCI_CMD_BUS_MASTER | MK_PCI_CMD_MEMORY_SPACE; + let r = mk_pci_config_write(device_id, claim_epoch, MK_PCI_CFG_COMMAND, bits); if r < 0 { return Err(XhciError::BrokerCallFailed(r)); } From b3f612197bf4eb29742287992d5638140688575b Mon Sep 17 00:00:00 2001 From: eKisNonos Date: Sun, 27 Sep 2026 09:30:34 +0000 Subject: [PATCH 078/244] mk: the shipping image runs every core zerostate builds with nonos-smp. The four-cpu lane from #539 reaches [SMP-PROOF] cpu_count=4, and a race one core hides is still a race. --- mk/20-build.mk | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/mk/20-build.mk b/mk/20-build.mk index 16398a2bec..893340e796 100644 --- a/mk/20-build.mk +++ b/mk/20-build.mk @@ -1246,6 +1246,9 @@ nonos-mk-arm-gui: nonos-mk-check-deps nonos-mk-ensure-signing-key --no-default-features \ --features microkernel-desktop-base$(_boot_comma)nonos-arch-preview$(_boot_comma)nonos-stark-attest +# The image that ships runs every core it finds. Real machines have several, +# and a race only one core hides is still a race; the four-cpu QEMU lane +# (nonos-mk-run-smp-serial-log) is where it shows first. # nonos-mk-zerostate: the canonical NONOS image. The whole ZeroState system in # one build: every capsule and driver, the transparent STARK spawn gate # enforced, dual Ed25519 + ML-DSA-65 signing, the anti-rollback index bound into @@ -1256,7 +1259,7 @@ nonos-mk-zerostate: nonos-mk-all-capsules-attested \ $(driver-iwlwifi_ARTIFACTS) $(driver-rtl8821ce_ARTIFACTS) \ nonos-mk-verify-desktop-gui-capsules \ nonos-mk-check-deps nonos-mk-ensure-signing-key - $(call nonos_kernel_build,zerostate: microkernel-full-gui + nonos-stark-attest,microkernel-full-gui$(_boot_comma)nonos-stark-attest) + $(call nonos_kernel_build,zerostate: microkernel-full-gui + nonos-stark-attest + nonos-smp,microkernel-full-gui$(_boot_comma)nonos-stark-attest$(_boot_comma)nonos-smp) nonos-mk-input-probe-inject-prod: $(proof-io_ARTIFACTS) \ $(driver-ps2-input_ARTIFACTS) $(driver-virtio-gpu_ARTIFACTS) \ From 05eabcce5bc363cc6c385f304fc6be6c47f1d7b6 Mon Sep 17 00:00:00 2001 From: eKisNonos Date: Sun, 27 Sep 2026 09:34:26 +0000 Subject: [PATCH 079/244] process: the timer never waits on a lock boot holds with interrupts on Boot inserts into the process table with interrupts enabled, and the alarm tick read-locks it on the same cpu. Table writers now mask, and the alarm tick scans with try_read into a fixed array, no allocation. Sleep, run queue and heap were already masked. --- src/process/alarm.rs | 8 ++-- src/process/core/table/access.rs | 50 ++++++++++++++++++++++ src/process/core/table/alarm_scan.rs | 41 ++++++++++++++++++ src/process/core/table/current_pid.rs | 49 +++++++++++++++++++++ src/process/core/table/mod.rs | 3 ++ src/process/core/table/ops.rs | 2 + src/process/core/table/types.rs | 61 +-------------------------- 7 files changed, 151 insertions(+), 63 deletions(-) create mode 100644 src/process/core/table/access.rs create mode 100644 src/process/core/table/alarm_scan.rs create mode 100644 src/process/core/table/current_pid.rs diff --git a/src/process/alarm.rs b/src/process/alarm.rs index d92dde3091..c91ac9ef00 100644 --- a/src/process/alarm.rs +++ b/src/process/alarm.rs @@ -23,9 +23,9 @@ use crate::process::signal::{send_signal, SIGALRM}; // Walk the process table and deliver SIGALRM to any PCB whose alarm // timestamp has expired. Called from the kernel timer IRQ tick. pub fn tick() { - for pcb in crate::process::get_process_table().get_all_processes() { - if pcb.check_alarm_expired() { - let _ = send_signal(pcb.pid, SIGALRM as u32); - } + let mut due = [0; 32]; + let n = crate::process::get_process_table().expired_alarms(&mut due); + for &pid in &due[..n] { + let _ = send_signal(pid, SIGALRM as u32); } } diff --git a/src/process/core/table/access.rs b/src/process/core/table/access.rs new file mode 100644 index 0000000000..788a4af309 --- /dev/null +++ b/src/process/core/table/access.rs @@ -0,0 +1,50 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +use super::super::pcb::ProcessControlBlock; +use super::super::types::Pid; +use super::types::ProcessTable; +use alloc::{sync::Arc, vec::Vec}; + +impl ProcessTable { + // Masked, because the timer walks this table: boot inserts with interrupts + // on, and a tick taking the read side on this cpu would spin forever. + pub fn add(&self, pcb: Arc) { + let _irq = crate::interrupts::disable_interrupts_guard(); + self.inner.write().push(pcb); + } + pub fn get_all_processes(&self) -> Vec> { + self.inner.read().clone() + } + pub fn find_by_pid(&self, pid: Pid) -> Option> { + self.inner.read().iter().find(|p| p.pid == pid).cloned() + } + pub fn is_active_name(&self, name: &str) -> bool { + self.inner.read().iter().any(|p| p.name.lock().as_str() == name) + } + pub fn is_active_pid(&self, pid: u64) -> bool { + self.inner.read().iter().any(|p| p.pid as u64 == pid) + } + pub fn get_children_of(&self, parent_pid: Pid) -> Vec> { + self.inner.read().iter().filter(|p| p.parent_pid() == parent_pid).cloned().collect() + } + pub fn has_children(&self, pid: Pid) -> bool { + self.inner.read().iter().any(|p| p.parent_pid() == pid) + } + pub fn get_process(&self, pid: Pid) -> Option> { + self.find_by_pid(pid) + } +} diff --git a/src/process/core/table/alarm_scan.rs b/src/process/core/table/alarm_scan.rs new file mode 100644 index 0000000000..99a6d6ae11 --- /dev/null +++ b/src/process/core/table/alarm_scan.rs @@ -0,0 +1,41 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +use super::super::types::Pid; +use super::types::ProcessTable; + +impl ProcessTable { + /// Pids whose alarm has expired, for the timer interrupt. Never waits and + /// never allocates: a writer busy on another cpu costs this tick nothing, + /// the alarm is caught on the next, and a heap lock held by the code this + /// interrupt broke into is never touched. + pub fn expired_alarms(&self, out: &mut [Pid]) -> usize { + let Some(table) = self.inner.try_read() else { + return 0; + }; + let mut n = 0; + for pcb in table.iter() { + if n == out.len() { + break; + } + if pcb.check_alarm_expired() { + out[n] = pcb.pid; + n += 1; + } + } + n + } +} diff --git a/src/process/core/table/current_pid.rs b/src/process/core/table/current_pid.rs new file mode 100644 index 0000000000..b20a4e6616 --- /dev/null +++ b/src/process/core/table/current_pid.rs @@ -0,0 +1,49 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +use core::sync::atomic::{AtomicU32, Ordering}; + +const INIT_PID: AtomicU32 = AtomicU32::new(0); + +pub struct CurrentPid { + slots: [AtomicU32; crate::smp::MAX_CPUS], +} + +impl CurrentPid { + pub const fn new() -> Self { + Self { slots: [INIT_PID; crate::smp::MAX_CPUS] } + } + + #[inline] + fn slot(&self) -> &AtomicU32 { + &self.slots[crate::smp::cpu_id()] + } + + #[inline] + pub fn load(&self, order: Ordering) -> u32 { + self.slot().load(order) + } + + #[inline] + pub fn store(&self, value: u32, order: Ordering) { + self.slot().store(value, order); + } + + #[inline] + pub fn swap(&self, value: u32, order: Ordering) -> u32 { + self.slot().swap(value, order) + } +} diff --git a/src/process/core/table/mod.rs b/src/process/core/table/mod.rs index 9fe8cee168..a1cca9d3b7 100644 --- a/src/process/core/table/mod.rs +++ b/src/process/core/table/mod.rs @@ -14,9 +14,12 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . +mod access; +mod alarm_scan; mod build_pcb; mod claim; mod create; +mod current_pid; mod inherit; mod ops; mod pid_alloc; diff --git a/src/process/core/table/ops.rs b/src/process/core/table/ops.rs index 7293e0c57d..93a17cff48 100644 --- a/src/process/core/table/ops.rs +++ b/src/process/core/table/ops.rs @@ -20,11 +20,13 @@ use core::sync::atomic::Ordering; impl ProcessTable { pub fn terminate_process(&self, pid: Pid) -> Result<(), &'static str> { + let irq = crate::interrupts::disable_interrupts_guard(); let mut inner = self.inner.write(); if let Some(pos) = inner.iter().position(|p| p.pid == pid) { *inner[pos].state.lock() = ProcessState::Terminated(0); inner.remove(pos); drop(inner); + drop(irq); crate::sched::remove_from_run_queue(pid); // The registry states what is running, so a process that has // stopped must leave it or every later attestation overstates diff --git a/src/process/core/table/types.rs b/src/process/core/table/types.rs index 30841ed22a..142c09a708 100644 --- a/src/process/core/table/types.rs +++ b/src/process/core/table/types.rs @@ -18,72 +18,15 @@ use super::super::pcb::ProcessControlBlock; use super::super::types::Pid; use alloc::{sync::Arc, vec::Vec}; use core::sync::atomic::{AtomicU32, Ordering}; -use spin::RwLock; - -const INIT_PID: AtomicU32 = AtomicU32::new(0); - -pub struct CurrentPid { - slots: [AtomicU32; crate::smp::MAX_CPUS], -} -impl CurrentPid { - pub const fn new() -> Self { - Self { slots: [INIT_PID; crate::smp::MAX_CPUS] } - } - - #[inline] - fn slot(&self) -> &AtomicU32 { - &self.slots[crate::smp::cpu_id()] - } - - #[inline] - pub fn load(&self, order: Ordering) -> u32 { - self.slot().load(order) - } - - #[inline] - pub fn store(&self, value: u32, order: Ordering) { - self.slot().store(value, order); - } - - #[inline] - pub fn swap(&self, value: u32, order: Ordering) -> u32 { - self.slot().swap(value, order) - } -} +pub use super::current_pid::CurrentPid; +use spin::RwLock; #[derive(Default)] pub struct ProcessTable { pub(super) inner: RwLock>>, } -impl ProcessTable { - pub fn add(&self, pcb: Arc) { - self.inner.write().push(pcb); - } - pub fn get_all_processes(&self) -> Vec> { - self.inner.read().clone() - } - pub fn find_by_pid(&self, pid: Pid) -> Option> { - self.inner.read().iter().find(|p| p.pid == pid).cloned() - } - pub fn is_active_name(&self, name: &str) -> bool { - self.inner.read().iter().any(|p| p.name.lock().as_str() == name) - } - pub fn is_active_pid(&self, pid: u64) -> bool { - self.inner.read().iter().any(|p| p.pid as u64 == pid) - } - pub fn get_children_of(&self, parent_pid: Pid) -> Vec> { - self.inner.read().iter().filter(|p| p.parent_pid() == parent_pid).cloned().collect() - } - pub fn has_children(&self, pid: Pid) -> bool { - self.inner.read().iter().any(|p| p.parent_pid() == pid) - } - pub fn get_process(&self, pid: Pid) -> Option> { - self.find_by_pid(pid) - } -} - pub static PROCESS_TABLE: ProcessTable = ProcessTable { inner: RwLock::new(Vec::new()) }; pub static CURRENT_PID: CurrentPid = CurrentPid::new(); pub(super) static NEXT_PID: AtomicU32 = AtomicU32::new(1); From 8c1c1aa91c25927e60dd244b82666300e6d1b4ac Mon Sep 17 00:00:00 2001 From: eKisNonos Date: Sun, 27 Sep 2026 09:38:56 +0000 Subject: [PATCH 080/244] linux: map executable pages from the bytes that were proved An executable file mapping proved the file, then read it a second time into the guest, so a file rewritten in between was mapped executable unproved. It is now filled from the verified copy. The tampered probe covers refusal; the race itself has no test yet. --- .../src/linux/call/mem/map_exec.rs | 19 +++--- .../src/linux/call/mem/map_file.rs | 32 +++++----- .../src/linux/call/mem/map_fill.rs | 58 +++++++++++++++++++ .../capsule_linux/src/linux/call/mem/mod.rs | 1 + 4 files changed, 83 insertions(+), 27 deletions(-) create mode 100644 userland/capsule_linux/src/linux/call/mem/map_fill.rs diff --git a/userland/capsule_linux/src/linux/call/mem/map_exec.rs b/userland/capsule_linux/src/linux/call/mem/map_exec.rs index fe62d05d61..6fc8c12f64 100644 --- a/userland/capsule_linux/src/linux/call/mem/map_exec.rs +++ b/userland/capsule_linux/src/linux/call/mem/map_exec.rs @@ -16,24 +16,25 @@ //! Proving a file before any of its pages become executable. +use alloc::vec::Vec; + use crate::linux::file::{key, store_read}; use crate::linux::guest::{Guest, Kind}; /// The same ceiling the exec path reads an image under. const MAX_IMAGE: u32 = 64 << 20; -/// Whether `fd` names a file this machine has agreed to execute. -pub fn proven(guest: &Guest, fd: u64) -> bool { - let Some(entry) = guest.fds.get(fd as usize).filter(|f| f.kind == Kind::File) else { - return false; - }; +/// The bytes of `fd`'s file, if this machine has agreed to execute them. The +/// mapping is filled from these, not read again: a second read could see a +/// file rewritten after it was proved, and map those bytes executable. +pub fn proven(guest: &Guest, fd: u64) -> Option> { + let entry = guest.fds.get(fd as usize).filter(|f| f.kind == Kind::File)?; /* * A descriptor's path was normalised when it was opened, so it * needs no resolving here, only confining. */ let at = &entry.path; - let Ok(bytes) = store_read(&key(at), MAX_IMAGE) else { - return false; - }; - crate::linux::attest::verify(at, &bytes).is_ok() + let bytes = store_read(&key(at), MAX_IMAGE).ok()?; + crate::linux::attest::verify(at, &bytes).ok()?; + Some(bytes) } diff --git a/userland/capsule_linux/src/linux/call/mem/map_file.rs b/userland/capsule_linux/src/linux/call/mem/map_file.rs index ca7a3a7bc8..2961e91233 100644 --- a/userland/capsule_linux/src/linux/call/mem/map_file.rs +++ b/userland/capsule_linux/src/linux/call/mem/map_file.rs @@ -17,10 +17,10 @@ //! A private file mapping. use crate::linux::abi::errno; -use crate::linux::file::pread64; use crate::linux::guest::Guest; use super::map_exec::proven; +use super::map_fill::{fill_from, fill_read}; use super::map_req::MapReq; use super::prot::PROT_EXEC; use super::prot_span::protect_span; @@ -32,34 +32,30 @@ pub fn file(guest: &mut Guest, req: &MapReq, at: u64, span: u64) -> u64 { * half-filled span left behind by a late refusal is memory the guest still * holds and did not ask to keep. */ - if req.prot & PROT_EXEC != 0 && !proven(guest, req.fd) { + let proved = (req.prot & PROT_EXEC != 0).then(|| proven(guest, req.fd)); + if let Some(None) = proved { return errno::fail(errno::EPERM); } if guest.map(at, span, true, false) < 0 { return errno::fail(errno::ENOMEM); } - let mut done = 0u64; - while done < req.len { - let n = pread64(guest, req.fd, at + done, req.len - done, req.off + done) as i64; - if n < 0 { + if let Some(Some(bytes)) = proved { + if fill_from(guest, &bytes, req, at) < 0 { return errno::fail(errno::EACCES); } - if n == 0 { - /* - * Short of the requested span: the rest of the mapping is the - * zeroes the fresh frames already hold, which is what a segment's - * bss is. - */ - break; - } - done += n as u64; + return finish(guest, req, at, span); } - if protect_span(guest, at, span, req.prot) < 0 { + if fill_read(guest, req, at) < 0 { return errno::fail(errno::EACCES); } // Not proved, since nothing asked to run it: it stays that way. - if req.prot & PROT_EXEC == 0 { - guest.mark_unproven(at, span); + guest.mark_unproven(at, span); + finish(guest, req, at, span) +} + +fn finish(guest: &mut Guest, req: &MapReq, at: u64, span: u64) -> u64 { + if protect_span(guest, at, span, req.prot) < 0 { + return errno::fail(errno::EACCES); } if req.fixed().is_none() { guest.mmap_next += span; diff --git a/userland/capsule_linux/src/linux/call/mem/map_fill.rs b/userland/capsule_linux/src/linux/call/mem/map_fill.rs new file mode 100644 index 0000000000..4b5e1fd812 --- /dev/null +++ b/userland/capsule_linux/src/linux/call/mem/map_fill.rs @@ -0,0 +1,58 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! Filling an executable mapping from the bytes that were proved. + +use crate::linux::file::pread64; +use crate::linux::guest::Guest; + +use super::map_req::MapReq; + +/// Copy the proved file's `[off, off + len)` to `at`. Past the end of the file +/// the fresh frames already read as zero, which is a segment's bss. +pub(super) fn fill_from(guest: &Guest, bytes: &[u8], req: &MapReq, at: u64) -> i64 { + let Ok(off) = usize::try_from(req.off) else { + return 0; + }; + if off >= bytes.len() { + return 0; + } + let len = usize::try_from(req.len).unwrap_or(usize::MAX); + let end = off.saturating_add(len).min(bytes.len()); + guest.write(at, &bytes[off..end]) +} + +/// Read `[off, off + len)` of the file into `at`, for a mapping nothing will +/// run. Negative on the first failed read. +pub(super) fn fill_read(guest: &mut Guest, req: &MapReq, at: u64) -> i64 { + let mut done = 0u64; + while done < req.len { + let n = pread64(guest, req.fd, at + done, req.len - done, req.off + done) as i64; + if n < 0 { + return n; + } + if n == 0 { + /* + * Short of the requested span: the rest of the mapping is the + * zeroes the fresh frames already hold, which is what a segment's + * bss is. + */ + break; + } + done += n as u64; + } + 0 +} diff --git a/userland/capsule_linux/src/linux/call/mem/mod.rs b/userland/capsule_linux/src/linux/call/mem/mod.rs index 9aae9430c6..c6b5a8dd8d 100644 --- a/userland/capsule_linux/src/linux/call/mem/mod.rs +++ b/userland/capsule_linux/src/linux/call/mem/mod.rs @@ -21,6 +21,7 @@ mod map; mod map_anon; mod map_exec; mod map_file; +mod map_fill; mod map_req; mod memory; mod prot; From bb829a1531ccb185eea6f08f217cbccc1d6fbf78 Mon Sep 17 00:00:00 2001 From: eKisNonos Date: Sun, 27 Sep 2026 09:42:15 +0000 Subject: [PATCH 081/244] linux_guests: a dynamically linked program under the real musl loader dyn links against a proved libprobe.so through ld-musl, itself proved, then dlopens a copy with one byte flipped and the good copy's proofs, which must fail. The suite runs it as a child. Test images now trust Debian's musl, named in the register; the build host needs musl-tools. --- userland/linux_guests/GuestFiles.mk | 11 ++++++ userland/linux_guests/Guests.mk | 26 ++++++++++--- userland/linux_guests/c/dyn.c | 31 +++++++++++++++ userland/linux_guests/c/probe_lib.c | 12 ++++++ userland/linux_guests/src/bin/suite.rs | 3 +- userland/linux_guests/src/dyn_probe.rs | 54 ++++++++++++++++++++++++++ userland/linux_guests/src/lib.rs | 1 + verification/ASSUMPTIONS.md | 1 + 8 files changed, 132 insertions(+), 7 deletions(-) create mode 100644 userland/linux_guests/c/dyn.c create mode 100644 userland/linux_guests/c/probe_lib.c create mode 100644 userland/linux_guests/src/dyn_probe.rs diff --git a/userland/linux_guests/GuestFiles.mk b/userland/linux_guests/GuestFiles.mk index a4a5edeb7f..75a4184554 100644 --- a/userland/linux_guests/GuestFiles.mk +++ b/userland/linux_guests/GuestFiles.mk @@ -30,3 +30,14 @@ LINUX_GUEST_STORE_ENTRIES += --entry /linux/bin/tampered=$(LINUX_GUEST_TAMPERED) --entry /linux/bin/tampered.nonos_id_cert.bin=$(linux-guest-suite_CERT) \ --entry /linux/bin/tampered.manifest.bin=$(linux-guest-suite_MANIFEST) \ --entry /linux/bin/tampered.zk_trailer.bin=$(linux-guest-suite_ATTESTATION) + +# libprobe.so with one byte flipped, beside the good library's proofs: the +# library a dynamic program is refused when it asks for it. +LINUX_GUEST_BAD_LIB := $(TARGET_DIR)/linux-guests/libprobe_bad.so +$(LINUX_GUEST_BAD_LIB): $(linux-guest-libprobe_BIN) tools/nonos-flip-byte + @mkdir -p $(@D) && $(NONOS_PYTHON) tools/nonos-flip-byte $< $@ +LINUX_GUEST_STORE_DEPS += $(LINUX_GUEST_BAD_LIB) +LINUX_GUEST_STORE_ENTRIES += --entry /linux/lib/libprobe_bad.so=$(LINUX_GUEST_BAD_LIB) \ + --entry /linux/lib/libprobe_bad.so.nonos_id_cert.bin=$(linux-guest-libprobe_CERT) \ + --entry /linux/lib/libprobe_bad.so.manifest.bin=$(linux-guest-libprobe_MANIFEST) \ + --entry /linux/lib/libprobe_bad.so.zk_trailer.bin=$(linux-guest-libprobe_ATTESTATION) diff --git a/userland/linux_guests/Guests.mk b/userland/linux_guests/Guests.mk index 08a2ea2ae8..fd7794dc3f 100644 --- a/userland/linux_guests/Guests.mk +++ b/userland/linux_guests/Guests.mk @@ -31,8 +31,9 @@ $(NONOS_BAKED_TRUST_DIR)/keys/guest_%_publisher_mldsa65.pub: | $(CAPSULE_SIGN_BI mv .keys/guest_$*_publisher_$$alg.pub $(NONOS_BAKED_TRUST_DIR)/keys/; \ done -# name, service port, reply port[, prebuilt ELF]. The enrolled copy is named -# guest_, so its certificate and trailer cannot collide with a capsule's. +# name, service port, reply port[, prebuilt ELF[, guest path]]. The enrolled +# copy is named guest_, so its certificate and trailer cannot collide +# with a capsule's. The guest path defaults to /bin/. define LINUX_GUEST CAPSULE_SLUG := linux-guest-$(1) CAPSULE_HANDLE := linux.guest.$(1) @@ -53,10 +54,10 @@ nonos-mk-check-linux-guest-$(1)-keys: \ $(NONOS_BAKED_TRUST_DIR)/keys/guest_$(1)_publisher_ed25519.pub \ $(NONOS_BAKED_TRUST_DIR)/keys/guest_$(1)_publisher_mldsa65.pub LINUX_GUEST_STORE_DEPS += $$(linux-guest-$(1)_ARTIFACTS) $$(linux-guest-$(1)_ATTESTATION) -LINUX_GUEST_STORE_ENTRIES += --entry /linux/bin/$(1)=$$(linux-guest-$(1)_BIN) \ - --entry /linux/bin/$(1).nonos_id_cert.bin=$$(linux-guest-$(1)_CERT) \ - --entry /linux/bin/$(1).manifest.bin=$$(linux-guest-$(1)_MANIFEST) \ - --entry /linux/bin/$(1).zk_trailer.bin=$$(linux-guest-$(1)_ATTESTATION) +LINUX_GUEST_STORE_ENTRIES += --entry /linux$(or $(5),/bin/$(1))=$$(linux-guest-$(1)_BIN) \ + --entry /linux$(or $(5),/bin/$(1)).nonos_id_cert.bin=$$(linux-guest-$(1)_CERT) \ + --entry /linux$(or $(5),/bin/$(1)).manifest.bin=$$(linux-guest-$(1)_MANIFEST) \ + --entry /linux$(or $(5),/bin/$(1)).zk_trailer.bin=$$(linux-guest-$(1)_ATTESTATION) endef $(eval $(call LINUX_GUEST,suite,4950,4951)) @@ -65,4 +66,17 @@ $(eval $(call LINUX_GUEST,reader,4954,4955)) # Alpine's static busybox, the one app.linux embeds, as a program from the store. $(eval $(call LINUX_GUEST,busybox,4956,4957,userland/capsule_linux/guests/busybox.elf)) +# Tier 2: a dynamically linked program, its library, and musl's loader, which +# is also its libc. Each is proved like any program; the loader refuses a +# library whose bytes were not. +LINUX_GUESTS_C := $(TARGET_DIR)/linux-guests/c +MUSL_LIBC := /usr/lib/x86_64-linux-musl/libc.so +$(LINUX_GUESTS_C)/libprobe.so: $(LINUX_GUESTS_DIR)/c/probe_lib.c + @mkdir -p $(@D) && musl-gcc -shared -fPIC -O2 -o $@ $< +$(LINUX_GUESTS_C)/dyn: $(LINUX_GUESTS_DIR)/c/dyn.c $(LINUX_GUESTS_C)/libprobe.so + @musl-gcc -O2 -o $@ $< -L$(LINUX_GUESTS_C) -lprobe +$(eval $(call LINUX_GUEST,dyn,4958,4959,$(LINUX_GUESTS_C)/dyn)) +$(eval $(call LINUX_GUEST,libprobe,4960,4961,$(LINUX_GUESTS_C)/libprobe.so,/lib/libprobe.so)) +$(eval $(call LINUX_GUEST,ldmusl,4962,4963,$(MUSL_LIBC),/lib/ld-musl-x86_64.so.1)) + include $(LINUX_GUESTS_DIR)/GuestFiles.mk diff --git a/userland/linux_guests/c/dyn.c b/userland/linux_guests/c/dyn.c new file mode 100644 index 0000000000..b39b094244 --- /dev/null +++ b/userland/linux_guests/c/dyn.c @@ -0,0 +1,31 @@ +/* + * NONOS Operating System + * Copyright (C) 2026 NONOS Contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +/* + * Tier 2: a program the real musl loader brings up, relocating it against a + * proved libprobe.so. It then asks for a copy of that library with one byte + * flipped, carrying the good copy's proofs, which the loader must fail to map. + * Exit 0: linked and refused. 2: the tampered copy loaded. 3: a wrong answer. + */ +#include +#include + +int nonos_probe_value(void); + +int main(void) { + int v = nonos_probe_value(); + printf("[GUEST] dyn: libprobe.so answered %#x\n", v); + if (v != 0x4e4f) { + return 3; + } + void *bad = dlopen("/lib/libprobe_bad.so", RTLD_NOW); + if (bad != NULL) { + printf("[GUEST] dyn ESCAPED: the tampered library loaded\n"); + return 2; + } + printf("[GUEST] dyn refused the tampered library: %s\n", dlerror()); + return 0; +} diff --git a/userland/linux_guests/c/probe_lib.c b/userland/linux_guests/c/probe_lib.c new file mode 100644 index 0000000000..6af188c95d --- /dev/null +++ b/userland/linux_guests/c/probe_lib.c @@ -0,0 +1,12 @@ +/* + * NONOS Operating System + * Copyright (C) 2026 NONOS Contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +/* + * The shared library the dynamic guest links against. Its answer is a value + * the program checks, so a loader that mapped the wrong bytes shows up as a + * wrong answer rather than a silent success. + */ +int nonos_probe_value(void) { return 0x4e4f; } diff --git a/userland/linux_guests/src/bin/suite.rs b/userland/linux_guests/src/bin/suite.rs index 4473cdd062..754efc0040 100644 --- a/userland/linux_guests/src/bin/suite.rs +++ b/userland/linux_guests/src/bin/suite.rs @@ -24,7 +24,7 @@ use std::process::ExitCode; use nonos_linux_guests::report::Report; use nonos_linux_guests::{ - bounds_probe, exec_probe, fs_probe, life_probe, native_probe, proc_probe, sep_probe, + bounds_probe, dyn_probe, exec_probe, fs_probe, life_probe, native_probe, proc_probe, sep_probe, }; fn main() -> ExitCode { @@ -36,6 +36,7 @@ fn main() -> ExitCode { ("proc", proc_self), ("separation", sep_probe::scan), ("exec", exec_probe::scan), + ("dynamic", dyn_probe::scan), ] { let mut r = Report::new(guest); scan(&mut r); diff --git a/userland/linux_guests/src/dyn_probe.rs b/userland/linux_guests/src/dyn_probe.rs new file mode 100644 index 0000000000..9784f17ef9 --- /dev/null +++ b/userland/linux_guests/src/dyn_probe.rs @@ -0,0 +1,54 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! Tier 2 from the suite: run the dynamically linked guest as a child and +//! read its verdict from the exit status. + +use crate::exec_probe::p; +use crate::report::{Report, Seen}; +use crate::sys::{call, out}; + +const FORK: u64 = 57; +const EXECVE: u64 = 59; +const WAIT4: u64 = 61; +const EXIT_GROUP: u64 = 231; + +pub fn scan(r: &mut Report) { + let child = call(FORK, [0; 6]); + if child == 0 { + let argv = [p("/bin/dyn\0"), 0u64]; + let rc = call(EXECVE, [argv[0], argv.as_ptr() as u64, 0, 0, 0, 0]); + let _ = call(EXIT_GROUP, [(100 + (-rc).clamp(0, 100)) as u64, 0, 0, 0, 0, 0]); + } + let mut status = 0i32; + let _ = call(WAIT4, [child as u64, &mut status as *mut i32 as u64, 0, 0, 0, 0]); + let note = match (status >> 8) & 0xff { + 0 => "linked against a proved library and refused the tampered one", + 2 => { + r.check("load a tampered library", Seen::Escaped("dlopen succeeded".into())); + return; + } + 3 => "ran, but the library gave a wrong answer", + code if code >= 100 => "did not start: the loader or a library was refused", + _ => "ended some other way", + }; + // Only a program that ran to the dlopen saw the refusal; EPERM is what the + // personality gives an executable mapping of unproved bytes. + if status >> 8 & 0xff == 0 { + r.check("load a tampered library", Seen::Refused(-1)); + } + out(format!("[GUEST] dyn note: the dynamic program {note}\n").as_bytes()); +} diff --git a/userland/linux_guests/src/lib.rs b/userland/linux_guests/src/lib.rs index 737350674f..207a2b565f 100644 --- a/userland/linux_guests/src/lib.rs +++ b/userland/linux_guests/src/lib.rs @@ -18,6 +18,7 @@ pub mod arg; pub mod bounds_probe; +pub mod dyn_probe; pub mod exec_child; pub mod exec_probe; pub mod fs_paths; diff --git a/verification/ASSUMPTIONS.md b/verification/ASSUMPTIONS.md index bf625a3d6e..f27204e89b 100644 --- a/verification/ASSUMPTIONS.md +++ b/verification/ASSUMPTIONS.md @@ -16,6 +16,7 @@ have no detector. This file is one list by design, so it is longer than the | `stated:fri-soundness` | stated | The FRI proximity bound the STARK soundness figures use holds at these parameters. | | `stated:lean-kernel` | stated | Lean's kernel and its three standard axioms (propext, Classical.choice, Quot.sound) are sound. | | `stated:extraction` | stated | Charon and Aeneas lower MIR faithfully, so an extracted definition is the kernel function. | +| `stated:debian-musl` | stated | The musl loader and libc the Tier 2 test image carries are Debian's musl 1.2.4 build, trusted as packaged. | | `stated:alpine-busybox` | stated | The busybox guest test images carry is Alpine's static build, trusted as built by Alpine. | | `crate:bitflags` | crate | Third-party code linked into ring 0. | | `crate:bitvec` | crate | Third-party code linked into ring 0. | From 4768585a61ac65f6ac3231a2b6fe595de6e40ed9 Mon Sep 17 00:00:00 2001 From: eKisNonos Date: Sun, 27 Sep 2026 09:48:41 +0000 Subject: [PATCH 082/244] input: believe an input frame only from the input router's pid Any capsule with IPC could send an NINP frame to any pid, and apps checked only the magic, so one app could type into another. Apps on app_skeleton and the SDK now compare the kernel-recorded sender with the owner of input_router, looked up again before refusing. --- .../app_skeleton/src/discover/from_router.rs | 42 +++++++++++++++++++ userland/app_skeleton/src/discover/mod.rs | 2 + userland/app_skeleton/src/runner/drain_ipc.rs | 4 ++ userland/nonos_service/src/lib.rs | 2 +- userland/nonos_service/src/lookup.rs | 12 +++++- userland/sdk/nonos_desktop/src/input/drain.rs | 4 ++ userland/sdk/nonos_desktop/src/input/mod.rs | 1 + .../sdk/nonos_desktop/src/input/router.rs | 36 ++++++++++++++++ 8 files changed, 101 insertions(+), 2 deletions(-) create mode 100644 userland/app_skeleton/src/discover/from_router.rs create mode 100644 userland/sdk/nonos_desktop/src/input/router.rs diff --git a/userland/app_skeleton/src/discover/from_router.rs b/userland/app_skeleton/src/discover/from_router.rs new file mode 100644 index 0000000000..69ea8694f6 --- /dev/null +++ b/userland/app_skeleton/src/discover/from_router.rs @@ -0,0 +1,42 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! Whether a delivery came from the input router. +//! +//! Any process that may use IPC may send a frame to any pid's inbox, and an +//! input frame is recognised by its magic alone. Without this check a capsule +//! could type into whichever app it liked. The kernel records the true sender, +//! so the router's pid is what separates routed input from forged input. + +use core::sync::atomic::{AtomicU32, Ordering}; + +use super::lookup_service::lookup_service; + +static ROUTER_PID: AtomicU32 = AtomicU32::new(0); + +/// True only when `sender` is the pid that owns the `input_router` service. +/// A router that restarted is looked up again before a frame is refused. +pub fn from_router(sender: u32) -> bool { + let known = ROUTER_PID.load(Ordering::Acquire); + if known != 0 && known == sender { + return true; + } + let Some(router) = lookup_service(b"input_router") else { + return false; + }; + ROUTER_PID.store(router.pid, Ordering::Release); + router.pid == sender +} diff --git a/userland/app_skeleton/src/discover/mod.rs b/userland/app_skeleton/src/discover/mod.rs index 5c0c3d5be8..fd080b41d3 100644 --- a/userland/app_skeleton/src/discover/mod.rs +++ b/userland/app_skeleton/src/discover/mod.rs @@ -14,11 +14,13 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . +mod from_router; mod lookup; mod lookup_service; mod peers; mod require; +pub use from_router::from_router; pub use lookup::lookup_port; pub use lookup_service::lookup_service; pub use peers::{Peers, ServicePeer}; diff --git a/userland/app_skeleton/src/runner/drain_ipc.rs b/userland/app_skeleton/src/runner/drain_ipc.rs index a0604063cc..9d13fbfeef 100644 --- a/userland/app_skeleton/src/runner/drain_ipc.rs +++ b/userland/app_skeleton/src/runner/drain_ipc.rs @@ -70,6 +70,10 @@ pub(super) fn drain( ControlOutcome::NotControl => {} } let Some(event) = parse_delivery(&rx[..n as usize]) else { continue }; + // Routed input only: a frame any other process sent is not the user. + if !crate::discover::from_router(sender) { + continue; + } let event = decorations::normalize(event); click_focus::handle(event, wm_port, window_id, request_id); match decorations::handle(width, height, maximized, event) { diff --git a/userland/nonos_service/src/lib.rs b/userland/nonos_service/src/lib.rs index 63e3c661b9..2aaa25124e 100644 --- a/userland/nonos_service/src/lib.rs +++ b/userland/nonos_service/src/lib.rs @@ -19,5 +19,5 @@ mod lookup; mod register; -pub use lookup::lookup; +pub use lookup::{lookup, owner}; pub use register::register; diff --git a/userland/nonos_service/src/lookup.rs b/userland/nonos_service/src/lookup.rs index 1b38eb5a1d..670fc3ede8 100644 --- a/userland/nonos_service/src/lookup.rs +++ b/userland/nonos_service/src/lookup.rs @@ -17,6 +17,16 @@ use nonos_abi::{syscall, N_SERVICE_LOOKUP}; pub fn lookup(name: &[u8]) -> Option { + raw(name).map(|(port, _)| port) +} + +/// The pid that registered `name`: what a receiver compares a message's +/// kernel-recorded sender against before believing it came from that service. +pub fn owner(name: &[u8]) -> Option { + raw(name).map(|(_, pid)| pid) +} + +fn raw(name: &[u8]) -> Option<(u32, u32)> { let mut port: u32 = 0; let mut pid: u32 = 0; let rc = syscall( @@ -33,5 +43,5 @@ pub fn lookup(name: &[u8]) -> Option { if rc < 0 || pid == 0 || port == 0 { return None; } - Some(port) + Some((port, pid)) } diff --git a/userland/sdk/nonos_desktop/src/input/drain.rs b/userland/sdk/nonos_desktop/src/input/drain.rs index 99099ea9a1..3e15e8c417 100644 --- a/userland/sdk/nonos_desktop/src/input/drain.rs +++ b/userland/sdk/nonos_desktop/src/input/drain.rs @@ -19,6 +19,7 @@ use nonos_ipc::recv_from; use super::super::wire::NINP_MAGIC; use super::parse::parse_event; +use super::router::from_router; const INBOX: u64 = 0; const RECV_BLOCK: u64 = 0; @@ -47,6 +48,9 @@ pub fn drain_input(out: &mut [InputEvent]) -> usize { if u32::from_le_bytes([rx[0], rx[1], rx[2], rx[3]]) != NINP_MAGIC { continue; } + if !from_router(sender) { + continue; + } if let Some(event) = parse_event(&rx[HDR..FRAME]) { out[count] = event; count += 1; diff --git a/userland/sdk/nonos_desktop/src/input/mod.rs b/userland/sdk/nonos_desktop/src/input/mod.rs index 4bd6bb8c26..80d49264c1 100644 --- a/userland/sdk/nonos_desktop/src/input/mod.rs +++ b/userland/sdk/nonos_desktop/src/input/mod.rs @@ -16,5 +16,6 @@ mod drain; mod parse; +mod router; pub use drain::drain_input; diff --git a/userland/sdk/nonos_desktop/src/input/router.rs b/userland/sdk/nonos_desktop/src/input/router.rs new file mode 100644 index 0000000000..e48128e9d5 --- /dev/null +++ b/userland/sdk/nonos_desktop/src/input/router.rs @@ -0,0 +1,36 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! Whether an input frame came from the input router. Any IPC-capable process +//! can send to any pid's inbox, so the magic proves nothing; the sender the +//! kernel recorded does. + +use core::sync::atomic::{AtomicU32, Ordering}; + +static ROUTER_PID: AtomicU32 = AtomicU32::new(0); + +pub(super) fn from_router(sender: u32) -> bool { + let known = ROUTER_PID.load(Ordering::Acquire); + if known != 0 && known == sender { + return true; + } + // Looked up again before refusing, so a restarted router is followed. + let Some(pid) = nonos_service::owner(b"input_router") else { + return false; + }; + ROUTER_PID.store(pid, Ordering::Release); + pid == sender +} From 0020a259493fc98770e35242846c464258615602 Mon Sep 17 00:00:00 2001 From: eKisNonos Date: Sun, 27 Sep 2026 09:49:34 +0000 Subject: [PATCH 083/244] linux: wl_output, and Wayland input from the router on focus A client is told the compositor's real mode, scale 1 and done before it draws. Input no longer drains the machine-wide ring: the shim subscribes to the input router, reads deliveries from its own inbox while its surface has focus, and drops frames not sent by the router. --- .../src/linux/wayland/handlers.rs | 1 + .../capsule_linux/src/linux/wayland/input.rs | 56 +++++++++++------- .../capsule_linux/src/linux/wayland/mod.rs | 1 + .../capsule_linux/src/linux/wayland/object.rs | 1 + .../capsule_linux/src/linux/wayland/output.rs | 59 +++++++++++++++++++ .../src/linux/wayland/registry.rs | 1 + .../capsule_linux/src/linux/wayland/scene.rs | 4 +- 7 files changed, 102 insertions(+), 21 deletions(-) create mode 100644 userland/capsule_linux/src/linux/wayland/output.rs diff --git a/userland/capsule_linux/src/linux/wayland/handlers.rs b/userland/capsule_linux/src/linux/wayland/handlers.rs index f3bc0a3415..19b27dce9c 100644 --- a/userland/capsule_linux/src/linux/wayland/handlers.rs +++ b/userland/capsule_linux/src/linux/wayland/handlers.rs @@ -70,6 +70,7 @@ pub fn bind(guest: &mut Guest, args: &mut Args<'_>) { */ Object::Shm => crate::linux::wayland::shm::formats(guest, id), Object::Seat => seat_caps(guest, id), + Object::Output => super::output::announce(guest, id), _ => {} } } diff --git a/userland/capsule_linux/src/linux/wayland/input.rs b/userland/capsule_linux/src/linux/wayland/input.rs index 83ae31ab06..c1591467d1 100644 --- a/userland/capsule_linux/src/linux/wayland/input.rs +++ b/userland/capsule_linux/src/linux/wayland/input.rs @@ -14,46 +14,62 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . - //! NONOS input events, as Wayland sees them. +//! +//! Routed, not drained: the personality subscribes to the input router like +//! any app and gets only what arrives while its surface has focus. It holds no +//! InputSource, and a frame from anyone but the router is not believed. -use nonos_libc::{mk_input_event_drain, InputEvent}; +use nonos_app_skeleton::clients::input_router::subscribe; +use nonos_app_skeleton::discover::{from_router, lookup_port}; +use nonos_app_skeleton::input::{InputEvent, InputKind}; +use nonos_libc::mk_ipc_recv_from; use crate::linux::guest::Guest; use super::input_key::key; use super::input_send::{button, motion}; -/// Events taken in one pass. A deeper backlog is drained on the next. +/// Key down and up, absolute pointer, button down and up. +const KINDS: u32 = 0x6B; +const OWN_INBOX: u64 = 0; +const NOWAIT: u64 = 1; +const NINP_MAGIC: u32 = 0x4E49_4E50; +const HEADER: usize = 8; +/// Frames taken in one pass. A deeper backlog is drained on the next. const BATCH: usize = 32; -const KEY_DOWN: u16 = 0; -const KEY_UP: u16 = 1; -const POINTER_ABS: u16 = 3; -const BUTTON_DOWN: u16 = 5; -const BUTTON_UP: u16 = 6; - pub fn pump(guest: &mut Guest) { if guest.scene.pointer.is_none() && guest.scene.keyboard.is_none() { return; } - let mut events = [InputEvent::default(); BATCH]; - let n = mk_input_event_drain(events.as_mut_ptr(), BATCH as u64); - if n <= 0 { - return; + if !guest.scene.subscribed { + guest.scene.subscribed = + lookup_port(b"input_router").is_some_and(|port| subscribe(port, 1, KINDS).is_ok()); } - for event in events.iter().take(n as usize) { - deliver(guest, event); + let mut rx = [0u8; HEADER + 32]; + for _ in 0..BATCH { + let mut sender = 0u32; + let n = mk_ipc_recv_from(OWN_INBOX, rx.as_mut_ptr(), rx.len(), NOWAIT, &mut sender); + if n < rx.len() as i64 { + return; + } + if u32::from_le_bytes([rx[0], rx[1], rx[2], rx[3]]) != NINP_MAGIC || !from_router(sender) { + continue; + } + if let Some(event) = InputEvent::from_delivery(&rx[HEADER..]) { + deliver(guest, &event); + } } } fn deliver(guest: &mut Guest, event: &InputEvent) { match event.kind { - KEY_DOWN => key(guest, event.code, 1), - KEY_UP => key(guest, event.code, 0), - POINTER_ABS => motion(guest, event.x, event.y), - BUTTON_DOWN => button(guest, event.code, 1), - BUTTON_UP => button(guest, event.code, 0), + InputKind::KeyDown => key(guest, event.code, 1), + InputKind::KeyUp => key(guest, event.code, 0), + InputKind::PointerAbs => motion(guest, event.x, event.y), + InputKind::ButtonDown => button(guest, event.code, 1), + InputKind::ButtonUp => button(guest, event.code, 0), _ => {} } } diff --git a/userland/capsule_linux/src/linux/wayland/mod.rs b/userland/capsule_linux/src/linux/wayland/mod.rs index 874b1d63fe..00b8deebd2 100644 --- a/userland/capsule_linux/src/linux/wayland/mod.rs +++ b/userland/capsule_linux/src/linux/wayland/mod.rs @@ -38,6 +38,7 @@ mod surface; mod xdg; mod state; mod out; +mod output; mod registry; mod route; mod serve; diff --git a/userland/capsule_linux/src/linux/wayland/object.rs b/userland/capsule_linux/src/linux/wayland/object.rs index 59bbec153a..81c656da7d 100644 --- a/userland/capsule_linux/src/linux/wayland/object.rs +++ b/userland/capsule_linux/src/linux/wayland/object.rs @@ -35,6 +35,7 @@ pub enum Object { Seat, Pointer, Keyboard, + Output, } pub struct Objects { diff --git a/userland/capsule_linux/src/linux/wayland/output.rs b/userland/capsule_linux/src/linux/wayland/output.rs new file mode 100644 index 0000000000..426f167752 --- /dev/null +++ b/userland/capsule_linux/src/linux/wayland/output.rs @@ -0,0 +1,59 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! `wl_output`: the screen a client asks about before it draws. Clients size +//! their first buffer from the mode and scale, so they are told the display +//! the compositor actually drives, and then `done`. + +use nonos_app_skeleton::clients::compositor::display_info; +use nonos_app_skeleton::discover::lookup_port; + +use crate::linux::guest::Guest; + +use super::out::Event; + +const GEOMETRY: u16 = 0; +const MODE: u16 = 1; +const DONE: u16 = 2; +const SCALE: u16 = 3; +const MODE_CURRENT_PREFERRED: u32 = 0x3; +const REFRESH_MHZ: u32 = 60_000; +// What a client is told when the compositor cannot be asked; better a +// plausible screen than none, since a client with no mode draws nothing. +const FALLBACK: (u32, u32) = (1280, 800); + +pub fn announce(guest: &mut Guest, id: u32) { + let (w, h) = lookup_port(b"compositor") + .and_then(|port| display_info(port, 1).ok()) + .map(|d| (d.width, d.height)) + .unwrap_or(FALLBACK); + // Physical size at 96 dpi; nothing reports the panel's real size. + let mm = |px: u32| px.saturating_mul(254) / 960; + let to = &mut guest.display.to_client; + Event::new(id, GEOMETRY) + .u32(0) + .u32(0) + .u32(mm(w)) + .u32(mm(h)) + .u32(0) + .string(b"NONOS") + .string(b"compositor") + .u32(0) + .send(to); + Event::new(id, MODE).u32(MODE_CURRENT_PREFERRED).u32(w).u32(h).u32(REFRESH_MHZ).send(to); + Event::new(id, SCALE).u32(1).send(to); + Event::new(id, DONE).send(to); +} diff --git a/userland/capsule_linux/src/linux/wayland/registry.rs b/userland/capsule_linux/src/linux/wayland/registry.rs index efb660c644..c90eb54606 100644 --- a/userland/capsule_linux/src/linux/wayland/registry.rs +++ b/userland/capsule_linux/src/linux/wayland/registry.rs @@ -31,6 +31,7 @@ pub const GLOBALS: &[Global] = &[ Global { name: 2, interface: b"wl_shm", version: 1, object: Object::Shm }, Global { name: 3, interface: b"xdg_wm_base", version: 2, object: Object::XdgBase }, Global { name: 4, interface: b"wl_seat", version: 5, object: Object::Seat }, + Global { name: 5, interface: b"wl_output", version: 2, object: Object::Output }, ]; pub fn by_name(name: u32) -> Option<&'static Global> { diff --git a/userland/capsule_linux/src/linux/wayland/scene.rs b/userland/capsule_linux/src/linux/wayland/scene.rs index 8331c7c17e..0c579bafd5 100644 --- a/userland/capsule_linux/src/linux/wayland/scene.rs +++ b/userland/capsule_linux/src/linux/wayland/scene.rs @@ -14,7 +14,6 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . - //! The client's scene, and the NONOS surface it ends up on. use alloc::vec::Vec; @@ -36,6 +35,8 @@ pub struct Scene { pub keyboard: Option, pub pointer_entered: bool, pub keyboard_entered: bool, + /// Whether the input router has taken this personality's subscription. + pub subscribed: bool, } impl Scene { @@ -51,6 +52,7 @@ impl Scene { keyboard: None, pointer_entered: false, keyboard_entered: false, + subscribed: false, } } From 3e1348c757f015e3ee1b4b0d8c124497a25bdcd5 Mon Sep 17 00:00:00 2001 From: eKisNonos Date: Sun, 27 Sep 2026 09:51:02 +0000 Subject: [PATCH 084/244] linux: give app.linux what a window needs, and present via compositor The personality registered a guest's surface without GfxCreate and presented it with GfxPresent, a scarce bit it does not hold, so no Wayland frame could appear. It now holds GfxQuery and GfxCreate like any app and commits damage to the compositor. Guests still hold none. --- src/userspace/capsule_linux/spawn.rs | 3 +++ userland/capsule_linux/Capsule.mk | 7 +++++-- userland/capsule_linux/src/linux/wayland/present.rs | 11 +++++++++-- 3 files changed, 17 insertions(+), 4 deletions(-) diff --git a/src/userspace/capsule_linux/spawn.rs b/src/userspace/capsule_linux/spawn.rs index 06493bee24..9e64cc316b 100644 --- a/src/userspace/capsule_linux/spawn.rs +++ b/src/userspace/capsule_linux/spawn.rs @@ -41,6 +41,9 @@ pub const LINUX_CAPS: u64 = Capability::CoreExec.bit() | Capability::Memory.bit() | Capability::Crypto.bit() | Capability::Debug.bit() + // A guest's Wayland surface, registered and presented like any window. + | Capability::GraphicsDisplayQuery.bit() + | Capability::GraphicsSurfaceCreate.bit() | Capability::ForeignExec.bit() | Capability::LocalSign.bit(); diff --git a/userland/capsule_linux/Capsule.mk b/userland/capsule_linux/Capsule.mk index f5fe147388..e4aa0bcd30 100644 --- a/userland/capsule_linux/Capsule.mk +++ b/userland/capsule_linux/Capsule.mk @@ -17,7 +17,10 @@ # which lapses at the next boot. # # = CoreExec 0x1 | IPC 0x8 | Memory 0x10 | Crypto 0x20 | Debug 0x100 -# | ForeignExec 0x100000000 | LocalSign 0x200000000 = 0x300000139 +# | GfxQuery 0x800 | GfxCreate 0x1000 +# | ForeignExec 0x100000000 | LocalSign 0x200000000 = 0x300001939 +# GfxQuery and GfxCreate are what any windowed app holds, for a guest's +# Wayland surface; it presents through the compositor, so no GfxPresent. CAPSULE_SLUG := linux CAPSULE_HANDLE := app.linux @@ -28,7 +31,7 @@ CAPSULE_FEATURE := nonos-capsule-linux CAPSULE_NAMESPACE := systems.nonos.app.linux CAPSULE_SERVICE_ENDPOINT := service:4936:app.linux CAPSULE_REPLY_ENDPOINT := reply:4937:endpoint.app.linux.reply -CAPSULE_REQUIRED_CAPS := 0x300000139 +CAPSULE_REQUIRED_CAPS := 0x300001939 CAPSULE_KERNEL_MIRROR := src/userspace/capsule_linux include nonos-mk/capsule.mk diff --git a/userland/capsule_linux/src/linux/wayland/present.rs b/userland/capsule_linux/src/linux/wayland/present.rs index 3e574c4699..dc6e258d9c 100644 --- a/userland/capsule_linux/src/linux/wayland/present.rs +++ b/userland/capsule_linux/src/linux/wayland/present.rs @@ -16,6 +16,9 @@ //! Getting the client's pixels onto a NONOS surface. +use nonos_app_skeleton::clients::compositor::damage_commit; +use nonos_app_skeleton::discover::lookup_port; + use crate::linux::guest::Guest; use super::present_surface::surface; @@ -39,8 +42,12 @@ pub fn present(guest: &mut Guest, buffer: u32) { return; }; guest.scene.pixels[..bytes].copy_from_slice(&src); - if let Some(handle) = surface(&mut guest.scene, width, height, stride) { - let _ = nonos_libc::mk_surface_present_rect(handle, 0, 0, width, height); + // Presented by the compositor, as every other app's window is; the + // personality holds no GfxPresent and needs none. + if surface(&mut guest.scene, width, height, stride).is_some() { + if let Some(port) = lookup_port(b"compositor") { + let _ = damage_commit(port, guest.scene.next_serial(), 0, 0, width, height); + } } } From 8b03d878b867f5a3aab47cd4f83ace51b4eb03be Mon Sep 17 00:00:00 2001 From: eKisNonos Date: Sun, 27 Sep 2026 09:52:08 +0000 Subject: [PATCH 085/244] linux: persist an installed program and its proof as they land Installs were written to the store's RAM copy and undone by reboot. Each entry and its minted trailer now persist through vfs, and a failed persist fails the install. After reboot nothing is trusted: exec proves the program again, under a local root a human re-confirms. --- userland/capsule_linux/src/linux/file/mod.rs | 2 ++ .../src/linux/file/store_durable.rs | 35 +++++++++++++++++++ .../capsule_linux/src/linux/install/enrol.rs | 4 +-- .../src/linux/install/place_entry.rs | 4 +-- 4 files changed, 41 insertions(+), 4 deletions(-) create mode 100644 userland/capsule_linux/src/linux/file/store_durable.rs diff --git a/userland/capsule_linux/src/linux/file/mod.rs b/userland/capsule_linux/src/linux/file/mod.rs index fd16e766f8..83e5a5b377 100644 --- a/userland/capsule_linux/src/linux/file/mod.rs +++ b/userland/capsule_linux/src/linux/file/mod.rs @@ -42,6 +42,7 @@ mod root; mod seek; mod slot; mod store; +mod store_durable; mod store_name; mod timerfd; mod timerfd_read; @@ -68,6 +69,7 @@ pub use resolve::{key, visible}; pub use seek::lseek; pub use slot::{install, MAX_FDS}; pub use store::{read as store_read, write as store_write}; +pub use store_durable::write_durable as store_write_durable; pub use timerfd::{timerfd_create, timerfd_settime}; pub use timerfd_read::read as timerfd_read; pub use write::write; diff --git a/userland/capsule_linux/src/linux/file/store_durable.rs b/userland/capsule_linux/src/linux/file/store_durable.rs new file mode 100644 index 0000000000..26791932f6 --- /dev/null +++ b/userland/capsule_linux/src/linux/file/store_durable.rs @@ -0,0 +1,35 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! A store write that survives the next boot. +//! +//! The store is staged into RAM at boot, so a plain write is undone by +//! restarting. An installed program and the proof beside it are persisted as +//! they are written; nothing about them is trusted after the reboot, since +//! exec proves a program again every time it runs. + +use nonos_app_skeleton::clients::vfs; +use nonos_libc::mk_getpid; + +use super::root::Key; +use super::store::write; + +pub fn write_durable(at: &Key, data: &[u8]) -> Result<(), &'static str> { + write(at, data)?; + // A file that landed but did not persist is reported, not kept silently + // in RAM as if it had been installed. + vfs::persist(mk_getpid(), at.as_bytes()) +} diff --git a/userland/capsule_linux/src/linux/install/enrol.rs b/userland/capsule_linux/src/linux/install/enrol.rs index d2dbc46c73..12677a933b 100644 --- a/userland/capsule_linux/src/linux/install/enrol.rs +++ b/userland/capsule_linux/src/linux/install/enrol.rs @@ -20,7 +20,7 @@ use alloc::vec::Vec; use nonos_libc::{mk_local_sign, mk_local_sign_len}; -use crate::linux::file::{key, store_write}; +use crate::linux::file::{key, store_write_durable}; use crate::linux::attest_paths::beside; @@ -41,5 +41,5 @@ pub fn vouch(path: &[u8], image: &[u8]) -> bool { }; trailer.truncate(got); let at = beside(path, b".zk_trailer.bin"); - store_write(&key(&at), &trailer).is_ok() + store_write_durable(&key(&at), &trailer).is_ok() } diff --git a/userland/capsule_linux/src/linux/install/place_entry.rs b/userland/capsule_linux/src/linux/install/place_entry.rs index 8b40fb68ec..c8959c0034 100644 --- a/userland/capsule_linux/src/linux/install/place_entry.rs +++ b/userland/capsule_linux/src/linux/install/place_entry.rs @@ -19,7 +19,7 @@ use nonos_libc::mk_debug; -use crate::linux::file::{key, store_write, visible}; +use crate::linux::file::{key, store_write_durable, visible}; use super::enrol::vouch; use super::tar::Entry; @@ -38,7 +38,7 @@ pub(super) fn one(entry: &Entry) -> bool { return false; } let at = visible(b"/", &entry.name); - if store_write(&key(&at), &entry.body).is_err() { + if store_write_durable(&key(&at), &entry.body).is_err() { return false; } if is_elf(&entry.body) { From 2ee15eb0fdacf270cd67a5e47f7995aeab74229d Mon Sep 17 00:00:00 2001 From: eKisNonos Date: Sun, 27 Sep 2026 09:52:53 +0000 Subject: [PATCH 086/244] mk: capture every networked run, and a tool to read what it sent QEMU_NET_CAPTURE now defaults to target/qemu-net.pcap, empty opts out. tools/nonos-pcap-egress lists each destination and DNS name queried and exits 1 on one outside --allow; a synthetic DNS query to an unlisted host fails it and passes once allowed. --- mk/10-qemu.mk | 5 ++- tools/nonos-pcap-egress | 74 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 78 insertions(+), 1 deletion(-) create mode 100755 tools/nonos-pcap-egress diff --git a/mk/10-qemu.mk b/mk/10-qemu.mk index 89aebe8b11..d22b5f5a1c 100644 --- a/mk/10-qemu.mk +++ b/mk/10-qemu.mk @@ -65,7 +65,10 @@ QEMU_SMP ?= 4 QEMU_HOST_SSH_PORT ?= 2222 QEMU_HOST_HTTP_PORT ?= 8080 QEMU_NET_MODE ?= nat -QEMU_NET_CAPTURE ?= +# Every networked run is captured, so what a boot sent is on disk rather than +# inferred from the code; tools/nonos-pcap-egress summarises it. Set it empty +# to run without one. +QEMU_NET_CAPTURE ?= $(TARGET_DIR)/qemu-net.pcap QEMU_SERIAL_LOG ?= $(TARGET_DIR)/qemu-serial.log QEMU_SMP_SERIAL_LOG ?= $(TARGET_DIR)/qemu-smp-serial.log QEMU_IOMMU_SERIAL_LOG ?= $(TARGET_DIR)/qemu-iommu-serial.log diff --git a/tools/nonos-pcap-egress b/tools/nonos-pcap-egress new file mode 100755 index 0000000000..e06fb9bb4f --- /dev/null +++ b/tools/nonos-pcap-egress @@ -0,0 +1,74 @@ +#!/usr/bin/env python3 +# NONOS Operating System +# Copyright (C) 2026 NONOS Contributors +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +"""Every destination a guest reached, from a QEMU filter-dump capture. + +What a boot sent, read off the wire instead of the code: each IPv4 destination +with a count, every DNS name queried, exit 1 on a destination outside --allow. +""" + +import argparse +import struct +import sys +from collections import Counter + + +def frames(data): + magic = struct.unpack_from("" + off = 24 + while off + 16 <= len(data): + _, _, incl, _ = struct.unpack_from(endian + "IIII", data, off) + yield data[off + 16 : off + 16 + incl] + off += 16 + incl + + +def dns_name(udp): + if len(udp) < 20: + return None + labels, i = [], 20 + while i < len(udp) and udp[i] != 0 and len(labels) < 64: + n = udp[i] + labels.append(udp[i + 1 : i + 1 + n].decode("ascii", "replace")) + i += 1 + n + return ".".join(labels) or None + + +def main(): + ap = argparse.ArgumentParser(description=__doc__.split("\n", 1)[0]) + ap.add_argument("pcap") + ap.add_argument("--allow", action="append", default=[], help="an IPv4 destination that is expected") + a = ap.parse_args() + dests, names = Counter(), Counter() + for f in frames(open(a.pcap, "rb").read()): + if len(f) < 34 or f[12:14] != b"\x08\x00": + continue + ihl = (f[14] & 0xF) * 4 + dst = ".".join(str(b) for b in f[30:34]) + dests[dst] += 1 + l4 = f[14 + ihl :] + if f[23] == 17 and len(l4) >= 4 and struct.unpack_from(">H", l4, 2)[0] == 53: + if name := dns_name(l4): + names[name] += 1 + for dst, n in dests.most_common(): + print(f"[egress] {dst:15} {n:6} packets{'' if dst in a.allow else ' UNEXPECTED'}") + for name, n in names.most_common(): + print(f"[egress] dns {name} x{n}") + return 1 if set(dests) - set(a.allow) else 0 + + +if __name__ == "__main__": + sys.exit(main()) From 3699b07aca382dcd53d63e23c36cba7a0d86296d Mon Sep 17 00:00:00 2001 From: eKisNonos Date: Sun, 27 Sep 2026 09:54:56 +0000 Subject: [PATCH 087/244] procstat: say less about other processes than MkAttestEntries does Any token could read every process's capability mask and its live syscall, IPC and fault counts, a timing channel on the focused app. Without AttestRead or ProcessControl a caller now sees others' pid, state, name and parent only. The attest app gains AttestRead for it. --- src/syscall/microkernel/mod.rs | 1 + src/syscall/microkernel/procstat.rs | 5 +- src/syscall/microkernel/procstat_redact.rs | 53 ++++++++++++++++++++++ src/userspace/capsule_attest/spawn.rs | 2 +- userland/capsule_attest/Capsule.mk | 6 ++- 5 files changed, 63 insertions(+), 4 deletions(-) create mode 100644 src/syscall/microkernel/procstat_redact.rs diff --git a/src/syscall/microkernel/mod.rs b/src/syscall/microkernel/mod.rs index 590edd7d65..5d96683bcf 100644 --- a/src/syscall/microkernel/mod.rs +++ b/src/syscall/microkernel/mod.rs @@ -59,6 +59,7 @@ pub mod procstat_entry; pub mod procstat_fill; pub mod procstat_header; pub mod procstat_header_fill; +pub mod procstat_redact; pub mod spawn_instance; pub mod stdout_write; pub mod store_write; diff --git a/src/syscall/microkernel/procstat.rs b/src/syscall/microkernel/procstat.rs index f7056cc06a..5842ccee75 100644 --- a/src/syscall/microkernel/procstat.rs +++ b/src/syscall/microkernel/procstat.rs @@ -27,6 +27,7 @@ use super::procstat_entry::ProcStatEntry; use super::procstat_fill::entry_for; use super::procstat_header::ProcStatHeader; use super::procstat_header_fill::header_for; +use super::procstat_redact::{sees_all, visible}; use crate::usercopy::{validate_user_write, write_user_value}; pub use super::procstat_entry::PROC_NAME_LEN; @@ -46,8 +47,10 @@ pub fn sys_proc_stat(buf_ptr: u64, max_entries: u64) -> i64 { return ERRNO_FAULT; } let mut dst = buf_ptr + size_of::() as u64; + let caller = crate::process::current_pid().unwrap_or(0); + let all = sees_all(); for pid in pids.iter().take(to_write) { - if write_user_value(dst, &entry_for(*pid, now_ms)).is_err() { + if write_user_value(dst, &visible(entry_for(*pid, now_ms), caller, all)).is_err() { return ERRNO_FAULT; } dst += size_of::() as u64; diff --git a/src/syscall/microkernel/procstat_redact.rs b/src/syscall/microkernel/procstat_redact.rs new file mode 100644 index 0000000000..7bcf2de594 --- /dev/null +++ b/src/syscall/microkernel/procstat_redact.rs @@ -0,0 +1,53 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! What `MkProcStat` shows a caller about processes other than itself. +//! +//! Any valid token may call it, so it must say less than `MkAttestEntries`, +//! which needs AttestRead. Another process's capability mask is exactly what +//! that call gates, and its live counters are a timing channel: the IPC count +//! of the focused app rises with each keystroke. A caller without AttestRead +//! or ProcessControl sees another process's identity and state, and nothing it +//! does. + +use super::procstat_entry::ProcStatEntry; +use crate::capabilities::Capability; + +/// Whether the calling process may read every field of every entry. +pub(super) fn sees_all() -> bool { + let token = crate::syscall::caps::current_caps_or_default(); + token.is_valid() + && (token.grants(Capability::AttestRead) || token.grants(Capability::ProcessControl)) +} + +/// `e` as the caller may see it. +pub(super) fn visible(mut e: ProcStatEntry, caller: u32, all: bool) -> ProcStatEntry { + if all || e.pid == caller { + return e; + } + e.run_ticks = 0; + e.caps = 0; + e.mem_kb = 0; + e.syscalls = 0; + e.ipc_tx = 0; + e.ipc_rx = 0; + e.faults = 0; + e.switches = 0; + e.user_ticks = 0; + e.mapped_kb = 0; + e.vma_count = 0; + e +} diff --git a/src/userspace/capsule_attest/spawn.rs b/src/userspace/capsule_attest/spawn.rs index 44658b8115..c4d1db1488 100644 --- a/src/userspace/capsule_attest/spawn.rs +++ b/src/userspace/capsule_attest/spawn.rs @@ -31,7 +31,7 @@ const SERVICE_PORT: u32 = 4444; const REPLY_INBOX: &str = "endpoint.attest.reply"; const REPLY_PORT: u32 = 4445; const TARGET_TRIPLE: &str = env!("NONOS_USER_TARGET"); -const REQUIRED_CAPS: u64 = 0x19; +const REQUIRED_CAPS: u64 = 0x8000_0019; pub fn spawn_attest_capsule() -> Result<(), SpawnError> { let trust_anchor = decode_trust_anchor(BAKED_TRUST_ANCHOR_POLICY) diff --git a/userland/capsule_attest/Capsule.mk b/userland/capsule_attest/Capsule.mk index f681535b4b..2d07ccde6e 100644 --- a/userland/capsule_attest/Capsule.mk +++ b/userland/capsule_attest/Capsule.mk @@ -7,10 +7,12 @@ CAPSULE_FEATURE := nonos-capsule-attest CAPSULE_NAMESPACE := systems.nonos.attest CAPSULE_SERVICE_ENDPOINT := service:4444:attest CAPSULE_REPLY_ENDPOINT := reply:4445:endpoint.attest.reply -# CoreExec | IPC | Memory = 0x01 | 0x08 | 0x10 = 0x19 +# CoreExec | IPC | Memory | AttestRead = 0x01 | 0x08 | 0x10 | 0x80000000 +# = 0x80000019. AttestRead because it shows every live capsule's capability +# mask, which MkProcStat now hands only to a holder of it. # Debug deliberately absent: capsule_attest would lose all credibility # if it emitted MkDebug markers. The NO LOGS posture is the point. -CAPSULE_REQUIRED_CAPS := 0x19 +CAPSULE_REQUIRED_CAPS := 0x80000019 CAPSULE_KERNEL_MIRROR := src/userspace/capsule_attest include nonos-mk/capsule.mk From 3ba33225a23db1e6e6183e919f1d00517a6d9348 Mon Sep 17 00:00:00 2001 From: eKisNonos Date: Sun, 27 Sep 2026 09:59:16 +0000 Subject: [PATCH 088/244] stark_proofs: the digest form of the gate binds the same member Bumps stark-attest to the CLI and web switch. The digest verifier the wasm gate runs accepts the enrolled member's digest and refuses a neighbour's, as the image form does. --- stark-attest | 2 +- userland/stark_proofs/src/public_leaf_tests.rs | 14 +++++++++++++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/stark-attest b/stark-attest index 39827e11b9..d6b60b4170 160000 --- a/stark-attest +++ b/stark-attest @@ -1 +1 @@ -Subproject commit 39827e11b971b38828184efc939acf4e73677fc7 +Subproject commit d6b60b4170501a627e2c4fb4a6d84cc2d19ed5b4 diff --git a/userland/stark_proofs/src/public_leaf_tests.rs b/userland/stark_proofs/src/public_leaf_tests.rs index ce02694590..0a6228b9db 100644 --- a/userland/stark_proofs/src/public_leaf_tests.rs +++ b/userland/stark_proofs/src/public_leaf_tests.rs @@ -6,7 +6,8 @@ //! the image itself. use crate::crypto::stark::air::{ - build_public_trailer, verify_public_trailer, MeasuredSet, Poseidon, RATE, + build_public_trailer, verify_public_trailer, verify_public_trailer_digest, MeasuredSet, + Poseidon, RATE, }; use crate::crypto::stark::attest_params::LOG_ROUNDS; use crate::crypto::stark::field::Fp; @@ -59,3 +60,14 @@ fn another_images_slot_does_not_admit_a_rogue() { let forged = build_public_trailer(&s, 2, &ctx).unwrap_or_default(); assert!(!verify_public_trailer(&root_bytes(s.root()), DEPTH, ROGUE, &forged, &ctx)); } + +// The web gate's form: the member's digest instead of its bytes, same verdicts. +#[test] +fn the_digest_form_binds_the_same_member() { + let (s, img) = (set(true), &images()[2]); + let (ctx, root) = (context(img, 7), root_bytes(set(true).root())); + let t = build_public_trailer(&s, 2, &ctx).unwrap_or_default(); + assert!(verify_public_trailer_digest(&root, DEPTH, blake3::hash(img).as_bytes(), &t, &ctx)); + let other = blake3::hash(&images()[3]); + assert!(!verify_public_trailer_digest(&root, DEPTH, other.as_bytes(), &t, &ctx)); +} From 81e8dd45cd7573cdde76e3f751554760cf66e70f Mon Sep 17 00:00:00 2001 From: eKisNonos Date: Sun, 27 Sep 2026 10:11:23 +0000 Subject: [PATCH 089/244] linux: name wl_output among unserved objects, drop a dead re-export The Object match in the unserved table did not cover Output, which stopped the capsule compiling; store_write had no user left after installs moved to the durable write. --- userland/capsule_linux/src/linux/file/mod.rs | 2 +- userland/capsule_linux/src/linux/wayland/unserved.rs | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/userland/capsule_linux/src/linux/file/mod.rs b/userland/capsule_linux/src/linux/file/mod.rs index 83e5a5b377..4673eba1b6 100644 --- a/userland/capsule_linux/src/linux/file/mod.rs +++ b/userland/capsule_linux/src/linux/file/mod.rs @@ -68,7 +68,7 @@ pub use rename::rename; pub use resolve::{key, visible}; pub use seek::lseek; pub use slot::{install, MAX_FDS}; -pub use store::{read as store_read, write as store_write}; +pub use store::read as store_read; pub use store_durable::write_durable as store_write_durable; pub use timerfd::{timerfd_create, timerfd_settime}; pub use timerfd_read::read as timerfd_read; diff --git a/userland/capsule_linux/src/linux/wayland/unserved.rs b/userland/capsule_linux/src/linux/wayland/unserved.rs index 782bd1cc75..b5ef7f7ff6 100644 --- a/userland/capsule_linux/src/linux/wayland/unserved.rs +++ b/userland/capsule_linux/src/linux/wayland/unserved.rs @@ -35,6 +35,7 @@ pub fn name(what: Object) -> &'static [u8] { Object::Seat => b"wl_seat", Object::Pointer => b"wl_pointer", Object::Keyboard => b"wl_keyboard", + Object::Output => b"wl_output", } } From 7bd3a56234dcffacffac9408270977950cc6a7b4 Mon Sep 17 00:00:00 2001 From: eKisNonos Date: Sun, 27 Sep 2026 10:16:46 +0000 Subject: [PATCH 090/244] coverage: syscall and Wayland numbers that may only rise 107 of 373 x86_64 syscalls served, 5 of 24 globals foot and GTK 4 look for advertised. Both run in CI against a baseline. The personality now prints served and unserved call counts when a guest family ends, which tools/nonos-linux-coverage turns into coverage weighted by real use. --- .github/workflows/verify.yml | 4 + scripts/baselines/linux-syscalls.txt | 1 + scripts/baselines/wayland-globals.txt | 1 + tools/nonos-linux-coverage | 186 ++------- tools/nonos-wayland-coverage | 64 +++ tools/ratchets/linux_calls.py | 42 ++ userland/capsule_linux/abi/wayland-wanted.txt | 43 ++ .../capsule_linux/abi/x86_64-syscalls.txt | 376 ++++++++++++++++++ .../capsule_linux/src/linux/serve/dispatch.rs | 1 + .../src/linux/serve/loop_impl.rs | 1 + userland/capsule_linux/src/linux/serve/mod.rs | 1 + .../capsule_linux/src/linux/serve/tally.rs | 40 ++ .../capsule_linux/src/linux/serve/unserved.rs | 1 + 13 files changed, 617 insertions(+), 144 deletions(-) create mode 100644 scripts/baselines/linux-syscalls.txt create mode 100644 scripts/baselines/wayland-globals.txt create mode 100755 tools/nonos-wayland-coverage create mode 100644 tools/ratchets/linux_calls.py create mode 100644 userland/capsule_linux/abi/wayland-wanted.txt create mode 100644 userland/capsule_linux/abi/x86_64-syscalls.txt create mode 100644 userland/capsule_linux/src/linux/serve/tally.rs diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml index cd514525e0..c8002a1936 100644 --- a/.github/workflows/verify.yml +++ b/.github/workflows/verify.yml @@ -119,6 +119,10 @@ jobs: run: python3 tools/nonos-assumptions - name: No new control that exists and does not run run: python3 scripts/check_unenforced.py && python3 scripts/check_unenforced.py --self-test + - name: Linux syscall coverage does not fall + run: python3 tools/nonos-linux-coverage --baseline scripts/baselines/linux-syscalls.txt + - name: Wayland coverage does not fall + run: python3 tools/nonos-wayland-coverage --baseline scripts/baselines/wayland-globals.txt # The committed verification/evidence/EVIDENCE.json is a machine-readable inventory of # everything NONOS proves. Regenerate it from the source tree and fail if it diff --git a/scripts/baselines/linux-syscalls.txt b/scripts/baselines/linux-syscalls.txt new file mode 100644 index 0000000000..e34885bbc6 --- /dev/null +++ b/scripts/baselines/linux-syscalls.txt @@ -0,0 +1 @@ +107 diff --git a/scripts/baselines/wayland-globals.txt b/scripts/baselines/wayland-globals.txt new file mode 100644 index 0000000000..7ed6ff82de --- /dev/null +++ b/scripts/baselines/wayland-globals.txt @@ -0,0 +1 @@ +5 diff --git a/tools/nonos-linux-coverage b/tools/nonos-linux-coverage index 7781badba5..be8d042ab2 100755 --- a/tools/nonos-linux-coverage +++ b/tools/nonos-linux-coverage @@ -14,160 +14,58 @@ # # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . -"""Which packages the personality can actually run, and what stops the rest. +"""Syscall coverage of the Linux personality, as a number that may only rise. -A listing that installs and then dies on its first unimplemented syscall -is worse than one that was never offered, so this answers the question -before anyone clicks: disassemble every executable in every fetched -package, find the immediate loaded into eax ahead of each `syscall`, and -compare that set against what the personality's dispatch tables answer. - -The analysis is deliberately static and deliberately pessimistic. A -number reached only on a path the program never takes still counts as -required here, because nothing in the binary says which paths run. So a -package this reports as covered is covered; one it reports as blocked -may still work, and the named syscall is where to look first. +Served is every syscall number a serve table answers, resolved through the +personality's own constants; defined is the x86_64 table in +userland/capsule_linux/abi/x86_64-syscalls.txt. With --serial, the counts a +guest's exit line reports give coverage weighted by what programs called, and +every unserved call they hit is named. """ import argparse -import io import re -import subprocess import sys -import tarfile -import zlib from collections import Counter from pathlib import Path -# `mov $N, %eax` close enough before a syscall to be its number. objdump -# writes the immediate in hex with a $ prefix. -MOV_EAX = re.compile(r"mov\s+\$0x([0-9a-f]+),%eax") -SYSCALL = re.compile(r"\bsyscall\b") - -# How far back to look. A compiler may schedule a few instructions -# between loading the number and making the call. -WINDOW = 12 - - -def payload(apk: Path) -> bytes: - """The tar stream inside an apk. - - An apk is several gzip members end to end: a signature, a control - segment, then the data. `tarfile.open(r:gz)` stops after the first, - which holds no files at all, so reading one that way finds nothing - and looks exactly like a package with no binaries in it. Every - member is inflated and concatenated instead. - """ - raw = apk.read_bytes() - out, at = bytearray(), 0 - while at < len(raw): - d = zlib.decompressobj(47) - try: - out += d.decompress(raw[at:]) - except zlib.error: - break - if d.unused_data == raw[at:]: - break - at = len(raw) - len(d.unused_data) - return bytes(out) - - -def executables(apk: Path, into: Path) -> list: - """Every ELF in the package, unpacked to a scratch directory.""" - out = [] - try: - with tarfile.open(fileobj=io.BytesIO(payload(apk))) as t: - for member in t.getmembers(): - if not member.isfile() or member.size < 128: - continue - f = t.extractfile(member) - if f is None: - continue - head = f.read(4) - if head != b"\x7fELF": - continue - f.seek(0) - at = into / member.name.replace("/", "_") - at.write_bytes(f.read()) - out.append(at) - except (tarfile.TarError, OSError, EOFError): - # Alpine's apk is a concatenated stream; a truncated tail after - # the payload is normal and not a reason to discard what parsed. - pass - return out - - -def numbers_used(elf: Path) -> set: - try: - text = subprocess.run( - ["objdump", "-d", "--no-show-raw-insn", str(elf)], - stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, timeout=120, - ).stdout.decode(errors="replace") - except (OSError, subprocess.SubprocessError): - return set() - lines = text.splitlines() - used, recent = set(), [] - for line in lines: - m = MOV_EAX.search(line) - if m: - recent.append(int(m.group(1), 16)) - recent = recent[-WINDOW:] - continue - if SYSCALL.search(line) and recent: - used.add(recent[-1]) - return used - - -def main() -> int: - ap = argparse.ArgumentParser(description=__doc__) - ap.add_argument("--cache", type=Path, default=Path("target/market-cache")) - ap.add_argument("--served", type=Path, required=True, - help="file of syscall numbers the personality answers") - ap.add_argument("--scratch", type=Path, default=Path("target/coverage-scratch")) - ap.add_argument("--names", type=Path, - help="capsule_linux abi/nr.rs, to name the gaps") - args = ap.parse_args() - - served = {int(x) for x in args.served.read_text().split()} - naming = {} - if args.names and args.names.exists(): - for name, num in re.findall(r"pub const ([A-Z0-9_]+): u64 = (\d+);", - args.names.read_text()): - naming[int(num)] = name.lower() - - args.scratch.mkdir(parents=True, exist_ok=True) - covered, blocked, missing = [], [], Counter() - for apk in sorted(args.cache.glob("*.apk")): - used = set() - for elf in executables(apk, args.scratch): - used |= numbers_used(elf) - elf.unlink(missing_ok=True) - if not used: - continue - gap = used - served - name = apk.name.rsplit("-", 2)[0] - if gap: - blocked.append((name, sorted(gap))) - missing.update(gap) - else: - covered.append(name) - - total = len(covered) + len(blocked) - if total == 0: - # Finding no syscalls in 76 packages means the reader is broken, - # not that the packages are empty. Saying "0 of 0 covered" here - # reads as a clean pass, which is the worst possible answer. - print("no binaries were read; the extractor or objdump is not working", - file=sys.stderr) - return 2 - print(f"{len(covered)} of {total} packages need nothing the personality lacks\n") - if covered: - print("runs today:", ", ".join(sorted(covered))) - print(f"\nmost common gaps across {len(blocked)} blocked packages:") - for num, count in missing.most_common(20): - print(f" {count:3} packages need {num:4} {naming.get(num, '(unnamed)')}") +sys.path.insert(0, str(Path(__file__).resolve().parent / "ratchets")) +from linux_calls import defined, served # noqa: E402 + +UNSERVED = re.compile(rb"\[LINUX\] unserved (\S+)") +TOTALS = re.compile(rb"\[LINUX\] calls served=(\d+) unserved=(\d+)") + + +def main(): + ap = argparse.ArgumentParser(description=__doc__.split("\n", 1)[0]) + ap.add_argument("--root", type=Path, default=Path(".")) + ap.add_argument("--baseline", type=Path, help="fail when fewer syscalls are served than this") + ap.add_argument("--serial", type=Path, action="append", default=[], help="a boot's serial log") + ap.add_argument("--list", action="store_true", help="name every unserved syscall") + a = ap.parse_args() + table, have = defined(a.root), served(a.root) + have &= set(table) + print(f"[syscalls] {len(have)} of {len(table)} served ({100 * len(have) / len(table):.1f}%)") + if a.list: + for n in sorted(set(table) - have): + print(f"[syscalls] unserved {n:3} {table[n]}") + asked, calls, missed = Counter(), 0, 0 + for log in a.serial: + text = log.read_bytes() + asked.update(m.decode(errors="replace") for m in UNSERVED.findall(text)) + for s, u in TOTALS.findall(text): + calls, missed = calls + int(s), missed + int(u) + if calls + missed: + print(f"[syscalls] weighted: {calls} of {calls + missed} calls served ({100 * calls / (calls + missed):.2f}%)") + for name, n in asked.most_common(): + print(f"[syscalls] asked for, unserved: {name} x{n}") + if a.baseline: + want = int(a.baseline.read_text().strip()) + if len(have) < want: + print(f"::error::syscall coverage fell: {len(have)} < {want}", file=sys.stderr) + return 1 return 0 if __name__ == "__main__": - raise SystemExit(main()) + sys.exit(main()) diff --git a/tools/nonos-wayland-coverage b/tools/nonos-wayland-coverage new file mode 100755 index 0000000000..6eedfaac22 --- /dev/null +++ b/tools/nonos-wayland-coverage @@ -0,0 +1,64 @@ +#!/usr/bin/env python3 +# NONOS Operating System +# Copyright (C) 2026 NONOS Contributors +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +"""Wayland coverage: the globals the shim advertises against what clients want. + +Advertised comes from the shim's registry; wanted from +userland/capsule_linux/abi/wayland-wanted.txt. With --serial, every request a +client made that the shim could not serve is counted from its log. +""" + +import argparse +import re +import sys +from collections import Counter, defaultdict +from pathlib import Path + +REGISTRY = Path("userland/capsule_linux/src/linux/wayland/registry.rs") +WANTED = Path("userland/capsule_linux/abi/wayland-wanted.txt") +GLOBAL = re.compile(r'interface: b"(\w+)"') +UNSERVED = re.compile(rb"\[WAYLAND\] unserved (\S+)") + + +def main(): + ap = argparse.ArgumentParser(description=__doc__.split("\n", 1)[0]) + ap.add_argument("--root", type=Path, default=Path(".")) + ap.add_argument("--baseline", type=Path, help="fail when fewer wanted globals are advertised") + ap.add_argument("--serial", type=Path, action="append", default=[]) + a = ap.parse_args() + have = set(GLOBAL.findall((a.root / REGISTRY).read_text())) + wants = defaultdict(set) + for line in (a.root / WANTED).read_text().splitlines(): + if line and not line.startswith("#"): + client, iface = line.split() + wants[client].add(iface) + every = set().union(*wants.values()) + print(f"[wayland] {len(have & every)} of {len(every)} wanted globals advertised") + for client, want in sorted(wants.items()): + print(f"[wayland] {client}: {len(have & want)} of {len(want)}; missing {' '.join(sorted(want - have))}") + asked = Counter() + for log in a.serial: + asked.update(m.decode(errors="replace") for m in UNSERVED.findall(log.read_bytes())) + for req, n in asked.most_common(): + print(f"[wayland] unserved request {req} x{n}") + if a.baseline and len(have & every) < int(a.baseline.read_text().strip()): + print("::error::Wayland coverage fell", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/ratchets/linux_calls.py b/tools/ratchets/linux_calls.py new file mode 100644 index 0000000000..a5e429267a --- /dev/null +++ b/tools/ratchets/linux_calls.py @@ -0,0 +1,42 @@ +# NONOS Operating System +# Copyright (C) 2026 NONOS Contributors +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +"""The syscalls the Linux personality serves, and the ones x86_64 defines.""" + +import re +from pathlib import Path + +LINUX = Path("userland/capsule_linux") +CONST = re.compile(r"pub const ([A-Z0-9_]+): u64 = (\d+);") +USE = re.compile(r"\bn[pr]::([A-Z0-9_]+)") + + +def served(root): + numbers = {} + for p in (root / LINUX / "src/linux/abi").glob("nr*.rs"): + numbers.update({k: int(v) for k, v in CONST.findall(p.read_text())}) + used = set() + for p in (root / LINUX / "src/linux/serve").glob("*.rs"): + used |= set(USE.findall(p.read_text())) + return {numbers[u] for u in used if u in numbers} + + +def defined(root): + out = {} + for line in (root / LINUX / "abi/x86_64-syscalls.txt").read_text().splitlines(): + if line and not line.startswith("#"): + n, name = line.split() + out[int(n)] = name + return out diff --git a/userland/capsule_linux/abi/wayland-wanted.txt b/userland/capsule_linux/abi/wayland-wanted.txt new file mode 100644 index 0000000000..8fd592cb4e --- /dev/null +++ b/userland/capsule_linux/abi/wayland-wanted.txt @@ -0,0 +1,43 @@ +# Globals real Wayland clients look for, client then interface. From reading +# foot's registry handler and GTK 4's gdkdisplay-wayland.c; not checked +# against a running client yet. The denominator of Wayland coverage. +foot wl_compositor +foot wl_subcompositor +foot wl_shm +foot xdg_wm_base +foot wl_seat +foot wl_output +foot zxdg_output_manager_v1 +foot wl_data_device_manager +foot zwp_primary_selection_device_manager_v1 +foot zxdg_decoration_manager_v1 +foot wp_presentation +foot xdg_activation_v1 +foot wp_viewporter +foot wp_fractional_scale_manager_v1 +foot zwp_text_input_manager_v3 +foot wp_cursor_shape_manager_v1 +foot wp_single_pixel_buffer_manager_v1 +gtk4 wl_compositor +gtk4 wl_subcompositor +gtk4 wl_shm +gtk4 xdg_wm_base +gtk4 wl_seat +gtk4 wl_output +gtk4 zxdg_output_manager_v1 +gtk4 wl_data_device_manager +gtk4 zwp_primary_selection_device_manager_v1 +gtk4 zxdg_decoration_manager_v1 +gtk4 wp_presentation +gtk4 xdg_activation_v1 +gtk4 wp_viewporter +gtk4 wp_fractional_scale_manager_v1 +gtk4 zwp_text_input_manager_v3 +gtk4 wp_single_pixel_buffer_manager_v1 +gtk4 zwp_linux_dmabuf_v1 +gtk4 zwp_pointer_gestures_v1 +gtk4 zwp_tablet_manager_v2 +gtk4 zwp_keyboard_shortcuts_inhibit_manager_v1 +gtk4 zxdg_exporter_v2 +gtk4 zxdg_importer_v2 +gtk4 gtk_shell1 diff --git a/userland/capsule_linux/abi/x86_64-syscalls.txt b/userland/capsule_linux/abi/x86_64-syscalls.txt new file mode 100644 index 0000000000..ddefdc3e69 --- /dev/null +++ b/userland/capsule_linux/abi/x86_64-syscalls.txt @@ -0,0 +1,376 @@ +# Every x86_64 Linux syscall, number then name, from the kernel's +# asm/unistd_64.h (Debian linux-libc-dev). The denominator of syscall +# coverage: tools/nonos-linux-coverage reads it. +0 read +1 write +2 open +3 close +4 stat +5 fstat +6 lstat +7 poll +8 lseek +9 mmap +10 mprotect +11 munmap +12 brk +13 rt_sigaction +14 rt_sigprocmask +15 rt_sigreturn +16 ioctl +17 pread64 +18 pwrite64 +19 readv +20 writev +21 access +22 pipe +23 select +24 sched_yield +25 mremap +26 msync +27 mincore +28 madvise +29 shmget +30 shmat +31 shmctl +32 dup +33 dup2 +34 pause +35 nanosleep +36 getitimer +37 alarm +38 setitimer +39 getpid +40 sendfile +41 socket +42 connect +43 accept +44 sendto +45 recvfrom +46 sendmsg +47 recvmsg +48 shutdown +49 bind +50 listen +51 getsockname +52 getpeername +53 socketpair +54 setsockopt +55 getsockopt +56 clone +57 fork +58 vfork +59 execve +60 exit +61 wait4 +62 kill +63 uname +64 semget +65 semop +66 semctl +67 shmdt +68 msgget +69 msgsnd +70 msgrcv +71 msgctl +72 fcntl +73 flock +74 fsync +75 fdatasync +76 truncate +77 ftruncate +78 getdents +79 getcwd +80 chdir +81 fchdir +82 rename +83 mkdir +84 rmdir +85 creat +86 link +87 unlink +88 symlink +89 readlink +90 chmod +91 fchmod +92 chown +93 fchown +94 lchown +95 umask +96 gettimeofday +97 getrlimit +98 getrusage +99 sysinfo +100 times +101 ptrace +102 getuid +103 syslog +104 getgid +105 setuid +106 setgid +107 geteuid +108 getegid +109 setpgid +110 getppid +111 getpgrp +112 setsid +113 setreuid +114 setregid +115 getgroups +116 setgroups +117 setresuid +118 getresuid +119 setresgid +120 getresgid +121 getpgid +122 setfsuid +123 setfsgid +124 getsid +125 capget +126 capset +127 rt_sigpending +128 rt_sigtimedwait +129 rt_sigqueueinfo +130 rt_sigsuspend +131 sigaltstack +132 utime +133 mknod +134 uselib +135 personality +136 ustat +137 statfs +138 fstatfs +139 sysfs +140 getpriority +141 setpriority +142 sched_setparam +143 sched_getparam +144 sched_setscheduler +145 sched_getscheduler +146 sched_get_priority_max +147 sched_get_priority_min +148 sched_rr_get_interval +149 mlock +150 munlock +151 mlockall +152 munlockall +153 vhangup +154 modify_ldt +155 pivot_root +156 _sysctl +157 prctl +158 arch_prctl +159 adjtimex +160 setrlimit +161 chroot +162 sync +163 acct +164 settimeofday +165 mount +166 umount2 +167 swapon +168 swapoff +169 reboot +170 sethostname +171 setdomainname +172 iopl +173 ioperm +174 create_module +175 init_module +176 delete_module +177 get_kernel_syms +178 query_module +179 quotactl +180 nfsservctl +181 getpmsg +182 putpmsg +183 afs_syscall +184 tuxcall +185 security +186 gettid +187 readahead +188 setxattr +189 lsetxattr +190 fsetxattr +191 getxattr +192 lgetxattr +193 fgetxattr +194 listxattr +195 llistxattr +196 flistxattr +197 removexattr +198 lremovexattr +199 fremovexattr +200 tkill +201 time +202 futex +203 sched_setaffinity +204 sched_getaffinity +205 set_thread_area +206 io_setup +207 io_destroy +208 io_getevents +209 io_submit +210 io_cancel +211 get_thread_area +212 lookup_dcookie +213 epoll_create +214 epoll_ctl_old +215 epoll_wait_old +216 remap_file_pages +217 getdents64 +218 set_tid_address +219 restart_syscall +220 semtimedop +221 fadvise64 +222 timer_create +223 timer_settime +224 timer_gettime +225 timer_getoverrun +226 timer_delete +227 clock_settime +228 clock_gettime +229 clock_getres +230 clock_nanosleep +231 exit_group +232 epoll_wait +233 epoll_ctl +234 tgkill +235 utimes +236 vserver +237 mbind +238 set_mempolicy +239 get_mempolicy +240 mq_open +241 mq_unlink +242 mq_timedsend +243 mq_timedreceive +244 mq_notify +245 mq_getsetattr +246 kexec_load +247 waitid +248 add_key +249 request_key +250 keyctl +251 ioprio_set +252 ioprio_get +253 inotify_init +254 inotify_add_watch +255 inotify_rm_watch +256 migrate_pages +257 openat +258 mkdirat +259 mknodat +260 fchownat +261 futimesat +262 newfstatat +263 unlinkat +264 renameat +265 linkat +266 symlinkat +267 readlinkat +268 fchmodat +269 faccessat +270 pselect6 +271 ppoll +272 unshare +273 set_robust_list +274 get_robust_list +275 splice +276 tee +277 sync_file_range +278 vmsplice +279 move_pages +280 utimensat +281 epoll_pwait +282 signalfd +283 timerfd_create +284 eventfd +285 fallocate +286 timerfd_settime +287 timerfd_gettime +288 accept4 +289 signalfd4 +290 eventfd2 +291 epoll_create1 +292 dup3 +293 pipe2 +294 inotify_init1 +295 preadv +296 pwritev +297 rt_tgsigqueueinfo +298 perf_event_open +299 recvmmsg +300 fanotify_init +301 fanotify_mark +302 prlimit64 +303 name_to_handle_at +304 open_by_handle_at +305 clock_adjtime +306 syncfs +307 sendmmsg +308 setns +309 getcpu +310 process_vm_readv +311 process_vm_writev +312 kcmp +313 finit_module +314 sched_setattr +315 sched_getattr +316 renameat2 +317 seccomp +318 getrandom +319 memfd_create +320 kexec_file_load +321 bpf +322 execveat +323 userfaultfd +324 membarrier +325 mlock2 +326 copy_file_range +327 preadv2 +328 pwritev2 +329 pkey_mprotect +330 pkey_alloc +331 pkey_free +332 statx +333 io_pgetevents +334 rseq +424 pidfd_send_signal +425 io_uring_setup +426 io_uring_enter +427 io_uring_register +428 open_tree +429 move_mount +430 fsopen +431 fsconfig +432 fsmount +433 fspick +434 pidfd_open +435 clone3 +436 close_range +437 openat2 +438 pidfd_getfd +439 faccessat2 +440 process_madvise +441 epoll_pwait2 +442 mount_setattr +443 quotactl_fd +444 landlock_create_ruleset +445 landlock_add_rule +446 landlock_restrict_self +447 memfd_secret +448 process_mrelease +449 futex_waitv +450 set_mempolicy_home_node +451 cachestat +452 fchmodat2 +453 map_shadow_stack +454 futex_wake +455 futex_wait +456 futex_requeue +457 statmount +458 listmount +459 lsm_get_self_attr +460 lsm_set_self_attr +461 lsm_list_modules diff --git a/userland/capsule_linux/src/linux/serve/dispatch.rs b/userland/capsule_linux/src/linux/serve/dispatch.rs index f86ab9c473..03d485ee86 100644 --- a/userland/capsule_linux/src/linux/serve/dispatch.rs +++ b/userland/capsule_linux/src/linux/serve/dispatch.rs @@ -27,6 +27,7 @@ use crate::linux::guest::Guest; pub fn answer(guest: &mut Guest, frame: &ForeignFrame) -> Answer { let a = frame.args(); + super::tally::call(); match frame.nr { nr::CLONE => clone(guest, frame), nr::FORK | nr::VFORK => crate::linux::call::fork(guest), diff --git a/userland/capsule_linux/src/linux/serve/loop_impl.rs b/userland/capsule_linux/src/linux/serve/loop_impl.rs index 8f0e71e0d2..dcb55103ff 100644 --- a/userland/capsule_linux/src/linux/serve/loop_impl.rs +++ b/userland/capsule_linux/src/linux/serve/loop_impl.rs @@ -34,6 +34,7 @@ pub fn serve(guest: Guest) -> i32 { } family.reap(); if let Some(code) = family.done() { + super::tally::report(); return code; } } diff --git a/userland/capsule_linux/src/linux/serve/mod.rs b/userland/capsule_linux/src/linux/serve/mod.rs index 48f3cbe7c4..aa11e63d40 100644 --- a/userland/capsule_linux/src/linux/serve/mod.rs +++ b/userland/capsule_linux/src/linux/serve/mod.rs @@ -27,6 +27,7 @@ mod table_file; mod table_mem; mod table_net; mod table_proc; +mod tally; mod unserved; pub use answer::Answer; diff --git a/userland/capsule_linux/src/linux/serve/tally.rs b/userland/capsule_linux/src/linux/serve/tally.rs new file mode 100644 index 0000000000..4c724ac74b --- /dev/null +++ b/userland/capsule_linux/src/linux/serve/tally.rs @@ -0,0 +1,40 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! How many calls a guest family made and how many were unserved: the weighted +//! half of syscall coverage, printed once when the family ends so a boot's log +//! says what fraction of what programs actually asked for was answered. + +use core::sync::atomic::{AtomicU64, Ordering}; + +static CALLS: AtomicU64 = AtomicU64::new(0); +static MISSED: AtomicU64 = AtomicU64::new(0); + +pub fn call() { + CALLS.fetch_add(1, Ordering::Relaxed); +} + +pub fn missed() { + MISSED.fetch_add(1, Ordering::Relaxed); +} + +/// `[LINUX] calls served= unserved=`, read by tools/nonos-linux-coverage. +pub fn report() { + let missed = MISSED.load(Ordering::Relaxed); + let served = CALLS.load(Ordering::Relaxed).saturating_sub(missed); + let line = alloc::format!("[LINUX] calls served={served} unserved={missed}\n"); + let _ = nonos_libc::mk_debug(line.as_ptr(), line.len()); +} diff --git a/userland/capsule_linux/src/linux/serve/unserved.rs b/userland/capsule_linux/src/linux/serve/unserved.rs index db5901ba83..e60c1c6574 100644 --- a/userland/capsule_linux/src/linux/serve/unserved.rs +++ b/userland/capsule_linux/src/linux/serve/unserved.rs @@ -21,6 +21,7 @@ use crate::linux::abi::{errno, name}; /// Name what was asked for. A guest that dies on a missing call should /// leave behind the name of the call it needed. pub fn unserved(number: u64) -> u64 { + super::tally::missed(); let mut line = [0u8; 64]; let head = b"[LINUX] unserved "; // The name table covers what is served; anything else is named by number. From 847079ecf58493afd4a288ca0b396b9ff553ef14 Mon Sep 17 00:00:00 2001 From: eKisNonos Date: Sun, 27 Sep 2026 10:21:48 +0000 Subject: [PATCH 091/244] linux: park sleeps instead of stalling the family, and real clocks nanosleep busy-waited in the serve loop, so one sleeping thread stopped every process the personality hosts; clock_nanosleep read its clock id as the request pointer. Sleeps now park and wake on a monotonic deadline. Realtime clocks read the wall clock, not uptime. --- .../capsule_linux/src/linux/abi/nr_high.rs | 2 +- .../capsule_linux/src/linux/call/clock.rs | 73 +++++++++++++++++++ userland/capsule_linux/src/linux/call/mod.rs | 6 +- .../capsule_linux/src/linux/call/sleep.rs | 58 ++++++++++----- .../capsule_linux/src/linux/call/thread.rs | 12 --- .../capsule_linux/src/linux/guest/handle.rs | 8 +- .../src/linux/guest/handle_new.rs | 1 + .../capsule_linux/src/linux/serve/dispatch.rs | 6 +- .../src/linux/serve/family_sleep.rs | 52 +++++++++++++ .../src/linux/serve/loop_impl.rs | 5 +- userland/capsule_linux/src/linux/serve/mod.rs | 1 + .../src/linux/serve/table_proc.rs | 2 +- 12 files changed, 187 insertions(+), 39 deletions(-) create mode 100644 userland/capsule_linux/src/linux/call/clock.rs create mode 100644 userland/capsule_linux/src/linux/serve/family_sleep.rs diff --git a/userland/capsule_linux/src/linux/abi/nr_high.rs b/userland/capsule_linux/src/linux/abi/nr_high.rs index 4652e125ca..400d05adfa 100644 --- a/userland/capsule_linux/src/linux/abi/nr_high.rs +++ b/userland/capsule_linux/src/linux/abi/nr_high.rs @@ -14,7 +14,6 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . - //! Linux x86_64 syscall numbers from one hundred up. Same contract //! as `nr`, split only because a file here stays under seventy-five lines. @@ -37,6 +36,7 @@ pub const EPOLL_CREATE1: u64 = 291; pub const EPOLL_PWAIT: u64 = 281; pub const SET_TID_ADDRESS: u64 = 218; pub const CLOCK_GETTIME: u64 = 228; +pub const CLOCK_GETRES: u64 = 229; pub const EXIT_GROUP: u64 = 231; pub const OPENAT: u64 = 257; pub const NEWFSTATAT: u64 = 262; diff --git a/userland/capsule_linux/src/linux/call/clock.rs b/userland/capsule_linux/src/linux/call/clock.rs new file mode 100644 index 0000000000..937252f6a2 --- /dev/null +++ b/userland/capsule_linux/src/linux/call/clock.rs @@ -0,0 +1,73 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! The clocks a guest reads. The realtime clocks are the wall clock, the rest +//! count from boot; answering every clock with uptime put a guest in 1970 and +//! broke anything that checks a certificate's dates or a file's age. + +use nonos_libc::{mk_time_millis, mk_uptime_ms}; + +use crate::linux::abi::errno; +use crate::linux::guest::Guest; + +const CLOCK_REALTIME: u64 = 0; +const CLOCK_REALTIME_COARSE: u64 = 5; +const CLOCK_REALTIME_ALARM: u64 = 8; +const CLOCK_TAI: u64 = 11; +/// Every clock that can be named: ids past it are refused, not answered. +const CLOCK_LAST: u64 = 11; +const MS: u64 = 1_000_000; + +/// Milliseconds on `clock`, or `None` for a clock Linux does not define. +pub fn now_ms(clock: u64) -> Option { + if clock > CLOCK_LAST { + return None; + } + let wall = + matches!(clock, CLOCK_REALTIME | CLOCK_REALTIME_COARSE | CLOCK_REALTIME_ALARM | CLOCK_TAI); + let raw = if wall { mk_time_millis() } else { mk_uptime_ms() }; + let ms = u64::try_from(raw).unwrap_or(0); + // TAI runs ahead of UTC by the leap seconds, 37 since 2017. + Some(if clock == CLOCK_TAI { ms.saturating_add(37_000) } else { ms }) +} + +pub fn clock_gettime(guest: &mut Guest, clock: u64, out: u64) -> u64 { + let Some(ms) = now_ms(clock) else { + return errno::fail(errno::EINVAL); + }; + write_spec(guest, out, ms / 1000, (ms % 1000) * MS) +} + +/// One millisecond: the finest step either underlying clock takes. +pub fn clock_getres(guest: &mut Guest, clock: u64, out: u64) -> u64 { + if now_ms(clock).is_none() { + return errno::fail(errno::EINVAL); + } + if out == 0 { + return errno::ok(0); + } + write_spec(guest, out, 0, MS) +} + +fn write_spec(guest: &mut Guest, out: u64, secs: u64, nanos: u64) -> u64 { + let mut buf = [0u8; 16]; + buf[..8].copy_from_slice(&secs.to_le_bytes()); + buf[8..].copy_from_slice(&nanos.to_le_bytes()); + if guest.write(out, &buf) < 0 { + return errno::fail(errno::EFAULT); + } + errno::ok(0) +} diff --git a/userland/capsule_linux/src/linux/call/mod.rs b/userland/capsule_linux/src/linux/call/mod.rs index 93f66cc634..9ef3bc9e25 100644 --- a/userland/capsule_linux/src/linux/call/mod.rs +++ b/userland/capsule_linux/src/linux/call/mod.rs @@ -18,6 +18,7 @@ mod console; mod ctl; +mod clock; mod cwd; mod futex; mod ident; @@ -61,9 +62,10 @@ pub use pipe_wait::{is_pipe, read_or_park as pipe_read_or_park}; pub use session::{getpgid, getsid, setpgid, setsid}; pub use signal::{rt_sigaction, rt_sigprocmask, sigaltstack}; pub use signal_send::kill; -pub use sleep::nanosleep; +pub use sleep::{clock_nanosleep, nanosleep}; pub use spawn::{clone, execve, fork, reap_one, wait4}; -pub use thread::{arch_prctl, clock_gettime, getrandom}; +pub use clock::{clock_getres, clock_gettime, now_ms}; +pub use thread::{arch_prctl, getrandom}; pub use timeops::{gettimeofday, time}; pub use umask::{umask, DEFAULT_UMASK}; pub use uname::uname; diff --git a/userland/capsule_linux/src/linux/call/sleep.rs b/userland/capsule_linux/src/linux/call/sleep.rs index 1d29ecab37..c1524526db 100644 --- a/userland/capsule_linux/src/linux/call/sleep.rs +++ b/userland/capsule_linux/src/linux/call/sleep.rs @@ -14,30 +14,54 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -//! Waiting. - -use nonos_libc::{mk_uptime_ms, mk_yield}; +//! Waiting, without holding up the family. +//! +//! A sleeping guest is parked and answered when its deadline passes, so the +//! other processes and threads the personality hosts keep being served. A +//! busy wait here stopped the whole family for as long as any one slept. use crate::linux::abi::errno; use crate::linux::guest::Guest; +use crate::linux::serve::Answer; + +use super::clock::now_ms; + +const TIMER_ABSTIME: u64 = 1; +const CLOCK_MONOTONIC: u64 = 1; +const NSEC: u64 = 1_000_000_000; -/// `timespec` is two 64-bit words: seconds then nanoseconds. -const PAIR: usize = 16; +/// `nanosleep(req, rem)`. +pub fn nanosleep(guest: &mut Guest, tid: u32, req: u64) -> Answer { + park(guest, tid, CLOCK_MONOTONIC, 0, req) +} -/// `nanosleep`: yield until the deadline passes. -pub fn nanosleep(guest: &Guest, req: u64) -> u64 { - let Some(spec) = guest.read(req, PAIR) else { - return errno::fail(errno::EFAULT); +/// `clock_nanosleep(clock, flags, req, rem)`: relative, or until an absolute +/// time on `clock`. Errors come back as a positive errno, as Linux returns them. +pub fn clock_nanosleep(guest: &mut Guest, tid: u32, clock: u64, flags: u64, req: u64) -> Answer { + match park(guest, tid, clock, flags, req) { + Answer::Reply(v) if (v as i64) < 0 => Answer::Reply((v as i64).unsigned_abs()), + other => other, + } +} + +fn park(guest: &mut Guest, tid: u32, clock: u64, flags: u64, req: u64) -> Answer { + let Some(spec) = guest.read(req, 16) else { + return Answer::Reply(errno::fail(errno::EFAULT)); }; let secs = u64::from_le_bytes(spec[..8].try_into().unwrap_or([0; 8])); let nanos = u64::from_le_bytes(spec[8..16].try_into().unwrap_or([0; 8])); - let until = uptime().saturating_add(secs * 1000 + nanos / 1_000_000); - while uptime() < until { - mk_yield(); + let (Some(on_clock), Some(mono)) = (now_ms(clock), now_ms(CLOCK_MONOTONIC)) else { + return Answer::Reply(errno::fail(errno::EINVAL)); + }; + if nanos >= NSEC || secs > i64::MAX as u64 { + return Answer::Reply(errno::fail(errno::EINVAL)); } - errno::ok(0) -} - -fn uptime() -> u64 { - u64::try_from(mk_uptime_ms()).unwrap_or(0) + let span = secs.saturating_mul(1000).saturating_add(nanos.div_ceil(1_000_000)); + // An absolute time is a distance from now on its own clock. + let wait = if flags & TIMER_ABSTIME != 0 { span.saturating_sub(on_clock) } else { span }; + if wait == 0 { + return Answer::Reply(errno::ok(0)); + } + guest.sleepers.push((mono.saturating_add(wait), tid)); + Answer::Park } diff --git a/userland/capsule_linux/src/linux/call/thread.rs b/userland/capsule_linux/src/linux/call/thread.rs index dd74708a3b..a693e110e8 100644 --- a/userland/capsule_linux/src/linux/call/thread.rs +++ b/userland/capsule_linux/src/linux/call/thread.rs @@ -42,18 +42,6 @@ pub fn arch_prctl(guest: &mut Guest, tid: u32, code: u64, addr: u64) -> u64 { } } -/// Seconds and nanoseconds, from the host's own monotonic millisecond clock. -pub fn clock_gettime(guest: &mut Guest, _clock: u64, out: u64) -> u64 { - let ms = nonos_libc::mk_uptime_ms().max(0) as u64; - let mut buf = [0u8; 16]; - buf[..8].copy_from_slice(&(ms / 1000).to_le_bytes()); - buf[8..].copy_from_slice(&((ms % 1000) * 1_000_000).to_le_bytes()); - if guest.write(out, &buf) < 0 { - return errno::fail(errno::EFAULT); - } - errno::ok(0) -} - /// Randomness from the kernel's own source, so a guest's keys are as good /// as a capsule's. pub fn getrandom(guest: &mut Guest, buf: u64, len: u64, _flags: u64) -> u64 { diff --git a/userland/capsule_linux/src/linux/guest/handle.rs b/userland/capsule_linux/src/linux/guest/handle.rs index b660daeb12..090284501c 100644 --- a/userland/capsule_linux/src/linux/guest/handle.rs +++ b/userland/capsule_linux/src/linux/guest/handle.rs @@ -27,8 +27,7 @@ pub struct Guest { /// The next address an anonymous mapping gets, growing upward. pub mmap_next: u64, pub fds: Vec, - /// Every span this capsule has backed for the guest, in the order - /// it did so. Fork copies exactly this list. + /// Every span backed for the guest, in order; fork copies exactly this. pub regions: Vec, /// Pipe buffers, named by index from the descriptors at each end. pub pipes: Vec>, @@ -44,8 +43,7 @@ pub struct Guest { pub objects: crate::linux::wayland::Objects, /// What those objects describe, and the surface it reaches. pub scene: crate::linux::wayland::Scene, - /// Which signals the guest installed a handler for. Nothing is ever - /// raised against them; see `call::signal`. + /// Handlers installed; nothing is raised against them (`call::signal`). pub handlers: [bool; 64], /// What a relative path is relative to. pub cwd: Vec, @@ -70,6 +68,8 @@ pub struct Guest { pub waiting: Option<(u64, u64, u32)>, /// A read parked on an empty pipe: its buffer slot, where, how much, who. pub pipe_wait: Option<(usize, u64, u64, u32)>, + /// Threads parked in a sleep: the monotonic deadline, and who. + pub sleepers: Vec<(u64, u32)>, /// The image's symbolic links, read once and shared by the family. pub links: alloc::rc::Rc, } diff --git a/userland/capsule_linux/src/linux/guest/handle_new.rs b/userland/capsule_linux/src/linux/guest/handle_new.rs index 69fd8fc947..9a3c5ca96c 100644 --- a/userland/capsule_linux/src/linux/guest/handle_new.rs +++ b/userland/capsule_linux/src/linux/guest/handle_new.rs @@ -54,6 +54,7 @@ impl Guest { ended: Vec::new(), waiting: None, pipe_wait: None, + sleepers: Vec::new(), links: Default::default(), } } diff --git a/userland/capsule_linux/src/linux/serve/dispatch.rs b/userland/capsule_linux/src/linux/serve/dispatch.rs index 03d485ee86..aa1ae14b5e 100644 --- a/userland/capsule_linux/src/linux/serve/dispatch.rs +++ b/userland/capsule_linux/src/linux/serve/dispatch.rs @@ -21,7 +21,7 @@ use nonos_libc::ForeignFrame; use super::answer::Answer; use super::table::plain; -use crate::linux::abi::nr; +use crate::linux::abi::{nr, nr_path as np}; use crate::linux::call::{clone, exit_thread, futex}; use crate::linux::guest::Guest; @@ -41,6 +41,10 @@ pub fn answer(guest: &mut Guest, frame: &ForeignFrame) -> Answer { Answer::Park } nr::FUTEX => futex(guest, frame.pid, a[0], a[1], a[2]), + nr::NANOSLEEP => crate::linux::call::nanosleep(guest, frame.pid, a[0]), + np::CLOCK_NANOSLEEP => { + crate::linux::call::clock_nanosleep(guest, frame.pid, a[0], a[1], a[2]) + } nr::READ if crate::linux::call::is_pipe(guest, a[0]) => { crate::linux::call::pipe_read_or_park(guest, a[0], a[1], a[2], frame.pid) } diff --git a/userland/capsule_linux/src/linux/serve/family_sleep.rs b/userland/capsule_linux/src/linux/serve/family_sleep.rs new file mode 100644 index 0000000000..0e1580a22a --- /dev/null +++ b/userland/capsule_linux/src/linux/serve/family_sleep.rs @@ -0,0 +1,52 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! Answering sleepers whose deadline has passed. + +use nonos_libc::mk_foreign_reply; + +use super::family::Family; +use crate::linux::call::now_ms; + +const CLOCK_MONOTONIC: u64 = 1; + +impl Family { + /// Wake every thread whose sleep is over. + pub fn settle_sleeps(&mut self) { + let Some(now) = now_ms(CLOCK_MONOTONIC) else { + return; + }; + for g in self.guests.iter_mut() { + g.sleepers.retain(|&(deadline, tid)| { + if deadline > now { + return true; + } + let _ = mk_foreign_reply(tid, 0); + false + }); + } + } + + /// Milliseconds until the nearest sleeper is due, if any is waiting. + pub fn next_wake_ms(&self) -> Option { + let now = now_ms(CLOCK_MONOTONIC)?; + self.guests + .iter() + .flat_map(|g| g.sleepers.iter()) + .map(|&(d, _)| d.saturating_sub(now)) + .min() + } +} diff --git a/userland/capsule_linux/src/linux/serve/loop_impl.rs b/userland/capsule_linux/src/linux/serve/loop_impl.rs index dcb55103ff..a8d3679156 100644 --- a/userland/capsule_linux/src/linux/serve/loop_impl.rs +++ b/userland/capsule_linux/src/linux/serve/loop_impl.rs @@ -29,9 +29,12 @@ pub fn serve(guest: Guest) -> i32 { let mut family = Family::new(guest); loop { let mut frame = ForeignFrame::default(); - if mk_foreign_wait(&mut frame, WAIT_MS) > 0 { + // A sleeper due sooner than the usual wait shortens it. + let wait = family.next_wake_ms().map_or(WAIT_MS, |ms| ms.clamp(1, WAIT_MS)); + if mk_foreign_wait(&mut frame, wait) > 0 { family.answer(&frame); } + family.settle_sleeps(); family.reap(); if let Some(code) = family.done() { super::tally::report(); diff --git a/userland/capsule_linux/src/linux/serve/mod.rs b/userland/capsule_linux/src/linux/serve/mod.rs index aa11e63d40..063294134b 100644 --- a/userland/capsule_linux/src/linux/serve/mod.rs +++ b/userland/capsule_linux/src/linux/serve/mod.rs @@ -21,6 +21,7 @@ mod dispatch; mod family; mod family_pipes; mod family_reap; +mod family_sleep; mod loop_impl; mod table; mod table_file; diff --git a/userland/capsule_linux/src/linux/serve/table_proc.rs b/userland/capsule_linux/src/linux/serve/table_proc.rs index 2b0adad924..b5239ab1c9 100644 --- a/userland/capsule_linux/src/linux/serve/table_proc.rs +++ b/userland/capsule_linux/src/linux/serve/table_proc.rs @@ -31,7 +31,7 @@ pub fn proc_ops(guest: &mut Guest, nr: u64, a: [u64; 6]) -> Option { np::SETUID | np::SETGID => call::setuid(a[0]), np::TIME => call::time(guest, a[0]), np::GETTIMEOFDAY => call::gettimeofday(guest, a[0]), - np::NANOSLEEP | np::CLOCK_NANOSLEEP => call::nanosleep(guest, a[0]), + nr::CLOCK_GETRES => call::clock_getres(guest, a[0], a[1]), // A guest yielding is the personality yielding: one slot. np::SCHED_YIELD => { nonos_libc::mk_yield(); From 8f6b65ff9fd6c16ebebc605190075b05154be0b0 Mon Sep 17 00:00:00 2001 From: eKisNonos Date: Sun, 27 Sep 2026 10:27:12 +0000 Subject: [PATCH 092/244] linux: keep installs in RAM; the store at rest is not encrypted 8b03d87 persisted every install through vfs whatever the person chose at setup. The persisted store is plaintext: persistable hands file.data to the block store as is, and the crypto module docs/userland/ramfs describes is not in the tree. Nothing may persist unless asked, and never in plaintext, so this reverts it and says so once per package. Setup promised a "Persistent encrypted store" and a passphrase that "protects the persistent store at rest". Nothing reads the passphrase. Both screens now say what is true. --- userland/capsule_linux/src/linux/file/mod.rs | 4 +-- .../src/linux/file/store_durable.rs | 35 ------------------- .../capsule_linux/src/linux/install/enrol.rs | 4 +-- .../capsule_linux/src/linux/install/place.rs | 9 ++++- .../src/linux/install/place_entry.rs | 4 +-- .../src/render/screens/passphrase.rs | 4 +-- .../src/render/screens/persistence.rs | 2 +- 7 files changed, 16 insertions(+), 46 deletions(-) delete mode 100644 userland/capsule_linux/src/linux/file/store_durable.rs diff --git a/userland/capsule_linux/src/linux/file/mod.rs b/userland/capsule_linux/src/linux/file/mod.rs index 4673eba1b6..fd16e766f8 100644 --- a/userland/capsule_linux/src/linux/file/mod.rs +++ b/userland/capsule_linux/src/linux/file/mod.rs @@ -42,7 +42,6 @@ mod root; mod seek; mod slot; mod store; -mod store_durable; mod store_name; mod timerfd; mod timerfd_read; @@ -68,8 +67,7 @@ pub use rename::rename; pub use resolve::{key, visible}; pub use seek::lseek; pub use slot::{install, MAX_FDS}; -pub use store::read as store_read; -pub use store_durable::write_durable as store_write_durable; +pub use store::{read as store_read, write as store_write}; pub use timerfd::{timerfd_create, timerfd_settime}; pub use timerfd_read::read as timerfd_read; pub use write::write; diff --git a/userland/capsule_linux/src/linux/file/store_durable.rs b/userland/capsule_linux/src/linux/file/store_durable.rs deleted file mode 100644 index 26791932f6..0000000000 --- a/userland/capsule_linux/src/linux/file/store_durable.rs +++ /dev/null @@ -1,35 +0,0 @@ -// NONOS Operating System -// Copyright (C) 2026 NONOS Contributors -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -//! A store write that survives the next boot. -//! -//! The store is staged into RAM at boot, so a plain write is undone by -//! restarting. An installed program and the proof beside it are persisted as -//! they are written; nothing about them is trusted after the reboot, since -//! exec proves a program again every time it runs. - -use nonos_app_skeleton::clients::vfs; -use nonos_libc::mk_getpid; - -use super::root::Key; -use super::store::write; - -pub fn write_durable(at: &Key, data: &[u8]) -> Result<(), &'static str> { - write(at, data)?; - // A file that landed but did not persist is reported, not kept silently - // in RAM as if it had been installed. - vfs::persist(mk_getpid(), at.as_bytes()) -} diff --git a/userland/capsule_linux/src/linux/install/enrol.rs b/userland/capsule_linux/src/linux/install/enrol.rs index 12677a933b..d2dbc46c73 100644 --- a/userland/capsule_linux/src/linux/install/enrol.rs +++ b/userland/capsule_linux/src/linux/install/enrol.rs @@ -20,7 +20,7 @@ use alloc::vec::Vec; use nonos_libc::{mk_local_sign, mk_local_sign_len}; -use crate::linux::file::{key, store_write_durable}; +use crate::linux::file::{key, store_write}; use crate::linux::attest_paths::beside; @@ -41,5 +41,5 @@ pub fn vouch(path: &[u8], image: &[u8]) -> bool { }; trailer.truncate(got); let at = beside(path, b".zk_trailer.bin"); - store_write_durable(&key(&at), &trailer).is_ok() + store_write(&key(&at), &trailer).is_ok() } diff --git a/userland/capsule_linux/src/linux/install/place.rs b/userland/capsule_linux/src/linux/install/place.rs index 973720ae29..01b33afa4f 100644 --- a/userland/capsule_linux/src/linux/install/place.rs +++ b/userland/capsule_linux/src/linux/install/place.rs @@ -15,6 +15,8 @@ // along with this program. If not, see . //! Putting a package's files into the store, under the Linux root. +use nonos_libc::mk_debug; + use super::auth::Verified; use super::place_entry::one; use super::program::record; @@ -27,5 +29,10 @@ pub fn unpack(files: &Verified, chosen: Option<&str>) -> usize { if let Some(name) = chosen { record(name, files); } - entries(files.files()).iter().filter(|entry| one(entry)).count() + let landed = entries(files.files()).iter().filter(|entry| one(entry)).count(); + // Nothing persists unless asked, and never in plaintext. The store at + // rest is not encrypted, so an install lives until the next reboot. + let line = b"[LINUX] unserved persist: install kept in RAM, store at rest unencrypted\n"; + let _ = mk_debug(line.as_ptr(), line.len()); + landed } diff --git a/userland/capsule_linux/src/linux/install/place_entry.rs b/userland/capsule_linux/src/linux/install/place_entry.rs index c8959c0034..8b40fb68ec 100644 --- a/userland/capsule_linux/src/linux/install/place_entry.rs +++ b/userland/capsule_linux/src/linux/install/place_entry.rs @@ -19,7 +19,7 @@ use nonos_libc::mk_debug; -use crate::linux::file::{key, store_write_durable, visible}; +use crate::linux::file::{key, store_write, visible}; use super::enrol::vouch; use super::tar::Entry; @@ -38,7 +38,7 @@ pub(super) fn one(entry: &Entry) -> bool { return false; } let at = visible(b"/", &entry.name); - if store_write_durable(&key(&at), &entry.body).is_err() { + if store_write(&key(&at), &entry.body).is_err() { return false; } if is_elf(&entry.body) { diff --git a/userland/capsule_setup_wizard/src/render/screens/passphrase.rs b/userland/capsule_setup_wizard/src/render/screens/passphrase.rs index e8440d6b6d..8e9227c731 100644 --- a/userland/capsule_setup_wizard/src/render/screens/passphrase.rs +++ b/userland/capsule_setup_wizard/src/render/screens/passphrase.rs @@ -5,8 +5,8 @@ use crate::state::Context; pub fn draw(ctx: &Context) { render::frame( ctx, - b"Disk-encryption passphrase", - b"Protects the persistent store at rest", + b"Passphrase", + b"Not used yet: the store at rest is not encrypted", b"TYPE BACKSPACE EDIT ENTER NEXT ESC BACK", ); let spx = ctx.stride as usize / 4; diff --git a/userland/capsule_setup_wizard/src/render/screens/persistence.rs b/userland/capsule_setup_wizard/src/render/screens/persistence.rs index df9949f397..916ccc92eb 100644 --- a/userland/capsule_setup_wizard/src/render/screens/persistence.rs +++ b/userland/capsule_setup_wizard/src/render/screens/persistence.rs @@ -2,7 +2,7 @@ use crate::render::{self, widgets::rows}; use crate::server::step::{default_key, list_nav, Outcome}; use crate::state::Context; -const MODES: &[&[u8]] = &[b"Amnesic (RAM only)", b"Persistent encrypted store"]; +const MODES: &[&[u8]] = &[b"Amnesic (RAM only)", b"Persistent store (not encrypted)"]; pub fn draw(ctx: &Context) { render::frame(ctx, b"Persistence", b"Keep data across reboots?", b"ENTER NEXT ESC BACK"); From dcd80f54c110a465b9166a08cb49bc6a89c27d72 Mon Sep 17 00:00:00 2001 From: eKisNonos Date: Sun, 27 Sep 2026 10:30:53 +0000 Subject: [PATCH 093/244] vfs: nothing reaches the disk on an amnesic boot Setup asked the question and kept the answer to itself: the file manager, the installer and consent all persisted whatever was chosen. Setup now publishes it as the Persistent policy field, which only setup and settings may write and which defaults to false. The vfs asks for it on every persist and every install, so a machine whose policy service cannot answer is amnesic. An install on an amnesic boot stays in RAM and works until reboot; a persist is refused and logged. Zeros still pass, since they are how a record is withdrawn. --- .../src/store/defaults/store.rs | 1 + userland/capsule_policy/src/store/get_bool.rs | 1 + userland/capsule_policy/src/store/set_bool.rs | 1 + userland/capsule_policy/src/store/types.rs | 1 + .../src/render/screens/review.rs | 1 + userland/capsule_vfs/Cargo.lock | 14 +++++++ userland/capsule_vfs/Cargo.toml | 2 + .../capsule_vfs/src/server/handlers/mod.rs | 1 + .../src/server/handlers/persist_gate.rs | 42 +++++++++++++++++++ .../src/server/handlers/store_install.rs | 4 +- .../src/server/handlers/store_persist.rs | 4 ++ userland/policy_proto/src/field.rs | 1 + userland/policy_proto/src/field_decode.rs | 1 + userland/policy_proto/src/field_label.rs | 1 + 14 files changed, 74 insertions(+), 1 deletion(-) create mode 100644 userland/capsule_vfs/src/server/handlers/persist_gate.rs diff --git a/userland/capsule_policy/src/store/defaults/store.rs b/userland/capsule_policy/src/store/defaults/store.rs index a57fbc2315..f627d4370b 100644 --- a/userland/capsule_policy/src/store/defaults/store.rs +++ b/userland/capsule_policy/src/store/defaults/store.rs @@ -53,6 +53,7 @@ pub const fn store() -> Store { audio_balance: 50, alert_sounds: false, startup_chime: false, + persistent: false, kernel_aslr: true, kernel_stack_guard: true, kernel_nx_bit: true, diff --git a/userland/capsule_policy/src/store/get_bool.rs b/userland/capsule_policy/src/store/get_bool.rs index 24fbd81f96..829bf54644 100644 --- a/userland/capsule_policy/src/store/get_bool.rs +++ b/userland/capsule_policy/src/store/get_bool.rs @@ -40,6 +40,7 @@ pub fn get(field: Field) -> Option { Field::WifiAskToJoin => s.wifi_ask_to_join, Field::AlertSounds => s.alert_sounds, Field::StartupChime => s.startup_chime, + Field::Persistent => s.persistent, Field::KernelAslr => s.kernel_aslr, Field::KernelStackGuard => s.kernel_stack_guard, Field::KernelNxBit => s.kernel_nx_bit, diff --git a/userland/capsule_policy/src/store/set_bool.rs b/userland/capsule_policy/src/store/set_bool.rs index 6a0d0a104f..5fb739dd2b 100644 --- a/userland/capsule_policy/src/store/set_bool.rs +++ b/userland/capsule_policy/src/store/set_bool.rs @@ -40,6 +40,7 @@ pub fn set(field: Field, value: bool) -> bool { Field::WifiAskToJoin => s.wifi_ask_to_join = value, Field::AlertSounds => s.alert_sounds = value, Field::StartupChime => s.startup_chime = value, + Field::Persistent => s.persistent = value, Field::KernelAslr => s.kernel_aslr = value, Field::KernelStackGuard => s.kernel_stack_guard = value, Field::KernelNxBit => s.kernel_nx_bit = value, diff --git a/userland/capsule_policy/src/store/types.rs b/userland/capsule_policy/src/store/types.rs index d95b394fc6..c338bc90ae 100644 --- a/userland/capsule_policy/src/store/types.rs +++ b/userland/capsule_policy/src/store/types.rs @@ -57,6 +57,7 @@ pub struct Store { pub audio_balance: u8, pub alert_sounds: bool, pub startup_chime: bool, + pub persistent: bool, pub kernel_aslr: bool, pub kernel_stack_guard: bool, pub kernel_nx_bit: bool, diff --git a/userland/capsule_setup_wizard/src/render/screens/review.rs b/userland/capsule_setup_wizard/src/render/screens/review.rs index 662e35e241..e2ded2589b 100644 --- a/userland/capsule_setup_wizard/src/render/screens/review.rs +++ b/userland/capsule_setup_wizard/src/render/screens/review.rs @@ -39,6 +39,7 @@ fn commit(ctx: &Context) { let _ = policy::set_bool(p, Field::WifiAutoconnect as u32, ctx.net_sel == 1); let _ = policy::set_bool(p, Field::AutoWipe as u32, ctx.privacy & 0b010 != 0); let _ = policy::set_bool(p, Field::NymEnabled as u32, ctx.privacy & 0b001 != 0); + let _ = policy::set_bool(p, Field::Persistent as u32, ctx.persist_sel == 1); crate::consent::apply(ctx.local_sel == 1, ctx.local_was, ctx.persist_sel == 1); if ctx.host_len > 0 { let _ = policy::set_str(p, Field::Hostname as u32, &ctx.host_buf[..ctx.host_len]); diff --git a/userland/capsule_vfs/Cargo.lock b/userland/capsule_vfs/Cargo.lock index 3a13adde5d..d798ddb7bd 100644 --- a/userland/capsule_vfs/Cargo.lock +++ b/userland/capsule_vfs/Cargo.lock @@ -24,9 +24,23 @@ dependencies = [ name = "nonos_capsule_vfs" version = "0.3.0" dependencies = [ + "nonos_policy_client", + "nonos_policy_proto", "nonos_userland_libc", ] +[[package]] +name = "nonos_policy_client" +version = "0.1.0" +dependencies = [ + "nonos_policy_proto", + "nonos_userland_libc", +] + +[[package]] +name = "nonos_policy_proto" +version = "0.3.0" + [[package]] name = "nonos_userland_libc" version = "0.3.0" diff --git a/userland/capsule_vfs/Cargo.toml b/userland/capsule_vfs/Cargo.toml index 7a247e2192..5eacad64cf 100644 --- a/userland/capsule_vfs/Cargo.toml +++ b/userland/capsule_vfs/Cargo.toml @@ -20,6 +20,8 @@ path = "src/main.rs" [dependencies] nonos_libc = { package = "nonos_userland_libc", path = "../libc" } +nonos_policy_client = { path = "../policy_client" } +nonos_policy_proto = { path = "../policy_proto" } [features] seed-terminal-store = [] diff --git a/userland/capsule_vfs/src/server/handlers/mod.rs b/userland/capsule_vfs/src/server/handlers/mod.rs index fa9d523b6d..71a186652a 100644 --- a/userland/capsule_vfs/src/server/handlers/mod.rs +++ b/userland/capsule_vfs/src/server/handlers/mod.rs @@ -27,6 +27,7 @@ mod list; mod mkdir; mod open; mod path; +mod persist_gate; mod read; mod rename; mod rmdir; diff --git a/userland/capsule_vfs/src/server/handlers/persist_gate.rs b/userland/capsule_vfs/src/server/handlers/persist_gate.rs new file mode 100644 index 0000000000..411b53948f --- /dev/null +++ b/userland/capsule_vfs/src/server/handlers/persist_gate.rs @@ -0,0 +1,42 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! Whether anything may reach the disk this boot. +//! +//! Nothing persists unless the person asked for it at setup. The choice lives +//! in the policy service, which only setup and settings may write, and is +//! asked afresh on every request, so a machine that cannot answer is amnesic. + +use nonos_libc::mk_debug; +use nonos_policy_client::{get_bool, lookup}; +use nonos_policy_proto::Field; + +use crate::protocol::EACCES; + +pub(super) fn may_persist() -> bool { + lookup().and_then(|port| get_bool(port, Field::Persistent)) == Some(true) +} + +/// Refuse a persist on an amnesic boot. Zeros are let through: they are +/// how a record is withdrawn, and removal must stay possible in either mode. +pub(super) fn require_persistent(data: &[u8]) -> Result<(), i32> { + if may_persist() || data.iter().all(|b| *b == 0) { + return Ok(()); + } + let line = b"[VFS] refused persist: amnesic boot\n"; + let _ = mk_debug(line.as_ptr(), line.len()); + Err(EACCES) +} diff --git a/userland/capsule_vfs/src/server/handlers/store_install.rs b/userland/capsule_vfs/src/server/handlers/store_install.rs index b5959d2ff6..8d51854c1f 100644 --- a/userland/capsule_vfs/src/server/handlers/store_install.rs +++ b/userland/capsule_vfs/src/server/handlers/store_install.rs @@ -26,6 +26,7 @@ use alloc::vec::Vec; use super::artifact_path::split_artifact; use super::installer_gate::require_installer; +use super::persist_gate::may_persist; use super::util::{map_blk_err, map_store_err, split_caller}; use crate::protocol::{ encode_response, Request, EINVAL, EMSGSIZE, MAX_DATA_BYTES, OP_STORE_INSTALL, @@ -57,7 +58,8 @@ fn place(store: &mut Store, req: Request<'_>, sender_pid: u32) -> Result<(), i32 return Err(EMSGSIZE); } store.install_bytes(&path, offset, data, pid).map_err(map_store_err)?; - if flags & STORE_INSTALL_FINAL == 0 { + // An amnesic boot keeps the install in RAM, where it works until reboot. + if flags & STORE_INSTALL_FINAL == 0 || !may_persist() { return Ok(()); } let whole = store.persistable(&path, pid).map_err(map_store_err)?; diff --git a/userland/capsule_vfs/src/server/handlers/store_persist.rs b/userland/capsule_vfs/src/server/handlers/store_persist.rs index ec50d5d4bc..583864826f 100644 --- a/userland/capsule_vfs/src/server/handlers/store_persist.rs +++ b/userland/capsule_vfs/src/server/handlers/store_persist.rs @@ -18,6 +18,7 @@ use alloc::vec::Vec; use core::str; use super::path::normalize; +use super::persist_gate::require_persistent; use super::util::{map_blk_err, map_store_err, split_caller}; use crate::protocol::{encode_response, Request, EINVAL, MAX_PATH_BYTES, OP_STORE_PERSIST}; use crate::store::Store; @@ -53,6 +54,9 @@ pub fn store_persist(store: &mut Store, req: Request<'_>, sender_pid: u32) -> Ve ) } }; + if let Err(s) = require_persistent(&data) { + return encode_response(OP_STORE_PERSIST, req.flags, req.request_id, s, &[]); + } match crate::blk::store_write::append(&path, &data) { Ok(()) => encode_response(OP_STORE_PERSIST, req.flags, req.request_id, 0, &[]), Err(e) => encode_response(OP_STORE_PERSIST, req.flags, req.request_id, map_blk_err(e), &[]), diff --git a/userland/policy_proto/src/field.rs b/userland/policy_proto/src/field.rs index 985747b08e..81ee007632 100644 --- a/userland/policy_proto/src/field.rs +++ b/userland/policy_proto/src/field.rs @@ -50,6 +50,7 @@ pub enum Field { AudioBalance = 0x011F, AlertSounds = 0x0120, StartupChime = 0x0121, + Persistent = 0x0122, KernelAslr = 0x0201, KernelStackGuard = 0x0202, KernelNxBit = 0x0203, diff --git a/userland/policy_proto/src/field_decode.rs b/userland/policy_proto/src/field_decode.rs index 2e60087946..c767b91751 100644 --- a/userland/policy_proto/src/field_decode.rs +++ b/userland/policy_proto/src/field_decode.rs @@ -51,6 +51,7 @@ pub fn decode(id: u32) -> Option { 0x011F => Field::AudioBalance, 0x0120 => Field::AlertSounds, 0x0121 => Field::StartupChime, + 0x0122 => Field::Persistent, 0x0201 => Field::KernelAslr, 0x0202 => Field::KernelStackGuard, 0x0203 => Field::KernelNxBit, diff --git a/userland/policy_proto/src/field_label.rs b/userland/policy_proto/src/field_label.rs index b184480b4d..15b7925a68 100644 --- a/userland/policy_proto/src/field_label.rs +++ b/userland/policy_proto/src/field_label.rs @@ -51,6 +51,7 @@ pub fn label_of(field: Field) -> &'static [u8] { Field::AudioBalance => b"Balance", Field::AlertSounds => b"Alert sounds", Field::StartupChime => b"Startup chime", + Field::Persistent => b"Keep data across reboots", Field::KernelAslr => b"Kernel ASLR", Field::KernelStackGuard => b"Stack guard pages", Field::KernelNxBit => b"NX bit enforcement", From 668fe30b5cf0668eda44dfed24540b862d4eadeb Mon Sep 17 00:00:00 2001 From: eKisNonos Date: Sun, 27 Sep 2026 10:39:06 +0000 Subject: [PATCH 094/244] process: keep a parked guest's frame out of the scheduler's resume slot redirect stored every trap's register frame in saved_user_context so a later fork could copy it. That is the slot the scheduler resumes a task from when it has no kernel context. A guest that yielded while parked and came back that way returned to user mode with its syscall number in rax, read as the answer: the hostile exec guest saw mmap and mprotect of a tampered file "succeed" at 0x9 and 0xa, and execve return 59. The frame now lives in a table of its own, read only by fork, and goes when the answer is taken or the process is cleared. --- src/process/foreign/fork.rs | 2 +- src/process/foreign/mod.rs | 1 + src/process/foreign/registry.rs | 1 + src/process/foreign/trap.rs | 6 ++--- src/process/foreign/trap_frame.rs | 43 +++++++++++++++++++++++++++++++ src/process/foreign/trap_wait.rs | 1 + 6 files changed, 49 insertions(+), 5 deletions(-) create mode 100644 src/process/foreign/trap_frame.rs diff --git a/src/process/foreign/fork.rs b/src/process/foreign/fork.rs index 293738b7ea..3d63f1276b 100644 --- a/src/process/foreign/fork.rs +++ b/src/process/foreign/fork.rs @@ -52,5 +52,5 @@ pub fn sys_foreign_fork(pid: u64) -> i64 { } fn saved_state(pid: u32) -> Option { - crate::process::with_process(pid, |pcb| *pcb.saved_user_context.lock()).flatten() + super::trap_frame::parked_frame(pid) } diff --git a/src/process/foreign/mod.rs b/src/process/foreign/mod.rs index 93713448e4..09ef7165e0 100644 --- a/src/process/foreign/mod.rs +++ b/src/process/foreign/mod.rs @@ -39,6 +39,7 @@ mod start_context; mod thread; mod trap; mod trap_claim; +mod trap_frame; mod trap_reply; mod trap_table; mod trap_wait; diff --git a/src/process/foreign/registry.rs b/src/process/foreign/registry.rs index 738a30f8a3..04c7d9caa4 100644 --- a/src/process/foreign/registry.rs +++ b/src/process/foreign/registry.rs @@ -65,4 +65,5 @@ pub fn clear(pid: u32) { * an answer meant for a process that no longer exists. */ super::trap_reply::forget(pid); + super::trap_frame::drop_frame(pid); } diff --git a/src/process/foreign/trap.rs b/src/process/foreign/trap.rs index 11e59836e5..7becdc4f52 100644 --- a/src/process/foreign/trap.rs +++ b/src/process/foreign/trap.rs @@ -29,12 +29,10 @@ pub fn redirect(nr: u64, args: [u64; 6], frame: &[u64; FRAME_WORDS]) -> Option. + +//! The register state of a guest parked inside a syscall, kept for a fork. +//! +//! It is not the scheduler's resume slot. A guest that yields while parked +//! and is switched back without a kernel context would be resumed from that +//! slot, returning to user mode with its syscall number in rax as if that +//! were the answer. So the frame lives here, where only fork reads it. + +use alloc::collections::BTreeMap; + +use spin::Mutex; + +use crate::arch::context::SavedUser; + +static FRAMES: Mutex> = Mutex::new(BTreeMap::new()); + +pub(super) fn keep(pid: u32, frame: SavedUser) { + FRAMES.lock().insert(pid, frame); +} + +/// The frame of a guest still parked, or `None` once it has its answer. +pub(super) fn parked_frame(pid: u32) -> Option { + FRAMES.lock().get(&pid).copied() +} + +pub(super) fn drop_frame(pid: u32) { + FRAMES.lock().remove(&pid); +} diff --git a/src/process/foreign/trap_wait.rs b/src/process/foreign/trap_wait.rs index 8beb8726a9..d44383cc52 100644 --- a/src/process/foreign/trap_wait.rs +++ b/src/process/foreign/trap_wait.rs @@ -34,6 +34,7 @@ pub(super) fn wait_for_answer(pid: u32) -> u64 { /// Every answer but one is a return value. fn settle(pid: u32, value: u64) -> u64 { + super::trap_frame::drop_frame(pid); if value == super::exec::EXECED { super::exec_enter::enter(pid) } From ec7eb7c4009bc546bf2613bd41b768f12d6f67a0 Mon Sep 17 00:00:00 2001 From: eKisNonos Date: Sun, 27 Sep 2026 10:39:06 +0000 Subject: [PATCH 095/244] linux_guests: a probe that hears nothing does not pass The separation probe read its verdicts from the child's exit status, so a wait4 that never really answered left every bit clear and all three checks passed. The exec child clamped a non-negative execve return to errno 0 and passed the same way. The child now marks its report, the parent fails without the mark, and execve returning at all is a failure. --- userland/linux_guests/src/exec_child.rs | 6 +++++- userland/linux_guests/src/sep_child.rs | 19 ++++++++++++++++++- userland/linux_guests/src/sep_probe.rs | 6 +++--- 3 files changed, 26 insertions(+), 5 deletions(-) diff --git a/userland/linux_guests/src/exec_child.rs b/userland/linux_guests/src/exec_child.rs index 2289518b62..4b8a5c87f5 100644 --- a/userland/linux_guests/src/exec_child.rs +++ b/userland/linux_guests/src/exec_child.rs @@ -24,6 +24,7 @@ const FORK: u64 = 57; const EXECVE: u64 = 59; const WAIT4: u64 = 61; const EXIT_GROUP: u64 = 231; +const NOT_REFUSED: i32 = 99; /// exec replaces the process that calls it, so a child makes the attempt and /// reports an errno through its status; a status under 100 is a program @@ -33,12 +34,15 @@ pub fn exec_in_child() -> Seen { if child == 0 { let argv = [p("/bin/tampered\0"), 0u64]; let rc = call(EXECVE, [argv[0], argv.as_ptr() as u64, 0, 0, 0, 0]); - let _ = call(EXIT_GROUP, [(100 + (-rc).clamp(0, 100)) as u64, 0, 0, 0, 0, 0]); + // Only a negative errno is a refusal; anything else returned is not. + let code = if rc < 0 { 100 + (-rc).min(100) } else { NOT_REFUSED as i64 }; + let _ = call(EXIT_GROUP, [code as u64, 0, 0, 0, 0, 0]); } let mut status = 0i32; let _ = call(WAIT4, [child as u64, &mut status as *mut i32 as u64, 0, 0, 0, 0]); match (status >> 8) & 0xff { code if code >= 100 => Seen::Refused(-(code as i64 - 100)), + NOT_REFUSED => Seen::Escaped("execve returned without refusing".into()), code => Seen::Escaped(format!("the tampered program ran and exited {code}")), } } diff --git a/userland/linux_guests/src/sep_child.rs b/userland/linux_guests/src/sep_child.rs index d2a5f915cd..4622d4fe35 100644 --- a/userland/linux_guests/src/sep_child.rs +++ b/userland/linux_guests/src/sep_child.rs @@ -16,12 +16,18 @@ //! The child's half of the separation probe. +use crate::report::{Report, Seen}; use crate::sep_probe::{peek, poke}; use crate::sys::{call, NANOSLEEP, PROCESS_VM_READV, PTRACE}; +const WAIT4: u64 = 61; + /// Attaches without stopping the target, so a success does not wedge it. const PTRACE_SEIZE: u64 = 0x4206; +/// Set on every report, so a status of zero cannot pass for one. +pub const REPORTED: u64 = 0x40; + /// Bits of the exit status: 1 saw the parent's later write, 2 reached into /// the parent through a call. pub fn run(parent: u32) -> u64 { @@ -44,5 +50,16 @@ pub fn run(parent: u32) -> u64 { bits |= 2; } poke(b'C'); - bits + bits | REPORTED +} + +/// The child's report bits. Without one every check would read as refused. +pub(crate) fn heard(r: &mut Report, child: i64, status: &mut i32) -> Option { + let waited = call(WAIT4, [child as u64, status as *mut i32 as u64, 0, 0, 0, 0]); + let bits = (*status >> 8) & 0xff; + if waited == child && bits & REPORTED as i32 != 0 { + return Some(bits); + } + r.check("hear from the child", Seen::Escaped(format!("wait4 gave {waited:#x}"))); + None } diff --git a/userland/linux_guests/src/sep_probe.rs b/userland/linux_guests/src/sep_probe.rs index 55d558cba3..c0d45e522b 100644 --- a/userland/linux_guests/src/sep_probe.rs +++ b/userland/linux_guests/src/sep_probe.rs @@ -24,7 +24,6 @@ use crate::report::{Report, Seen}; use crate::sys::{call, GETPID, MAP_FIXED, MAP_PRIVATE_ANON, MMAP, PROT_RW}; const FORK: u64 = 57; -const WAIT4: u64 = 61; const EXIT_GROUP: u64 = 231; const AT: u64 = 0x5000_0000; @@ -46,8 +45,9 @@ pub fn scan(r: &mut Report) { } poke(b'B'); let mut status = 0i32; - let _ = call(WAIT4, [child as u64, &mut status as *mut i32 as u64, 0, 0, 0, 0]); - let bits = (status >> 8) & 0xff; + let Some(bits) = super::sep_child::heard(r, child, &mut status) else { + return; + }; let seen = |bit: i32, how: &str| match bits & bit { 0 => Seen::Refused(0), _ => Seen::Escaped(how.into()), From 179d458986f61b2c694b6d4c4062c58531b96706 Mon Sep 17 00:00:00 2001 From: eKisNonos Date: Sun, 27 Sep 2026 10:40:18 +0000 Subject: [PATCH 096/244] tools: what a boot left on disk, from the image's store table An amnesic boot must leave nothing. --record takes the NONOSTR1 rows before a boot and --against names every row the boot added or changed, exiting 1 on any. --- tools/nonos-amnesic-check | 75 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100755 tools/nonos-amnesic-check diff --git a/tools/nonos-amnesic-check b/tools/nonos-amnesic-check new file mode 100755 index 0000000000..afac97cb86 --- /dev/null +++ b/tools/nonos-amnesic-check @@ -0,0 +1,75 @@ +#!/usr/bin/env python3 +# NONOS Operating System +# Copyright (C) 2026 NONOS Contributors +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +"""What a boot left on disk, read from the image's package container. + +The store at LBA 256 is a NONOSTR1 table of named extents. --record writes its +rows before a boot; --against compares after one, and exit 1 names every row +the boot added or changed. An amnesic boot must add none: nothing on disk. +""" + +import argparse +import struct +import sys + +STORE_LBA, SECTOR = 256, 512 +HEADER, ENTRY, NAME, MAX = 32, 128, 96, 64 + + +def rows(image): + with open(image, "rb") as f: + f.seek(STORE_LBA * SECTOR) + head = f.read(HEADER + ENTRY * MAX) + if head[:8] != b"NONOSTR1" or struct.unpack_from(" MAX: + sys.exit(f"{image}: {count} entries, above {MAX}") + out = [] + for i in range(count): + base = HEADER + ENTRY * i + name = head[base:base + NAME].split(b"\0", 1)[0].decode("ascii", "replace") + off, length = struct.unpack_from(" Date: Sun, 27 Sep 2026 10:51:13 +0000 Subject: [PATCH 097/244] linux: a family counts its own pids A guest saw kernel pids, which are global: how many processes the machine had started, and a sibling's forks moving the counter, a channel any two guests could signal through. Each family now numbers its own: the personality is 1, the program it started 2, and every process or thread after takes the next, never reused while the family lives. Pids are rewritten where they cross, in and out of every call that takes or returns one, so no handler sees a guest's number and no guest sees a kernel's. A pid outside the family is ECHILD or ESRCH, as on Linux. gettid and set_tid_address now answer the calling thread, not the process. --- .../capsule_linux/src/linux/serve/dispatch.rs | 2 + .../capsule_linux/src/linux/serve/family.rs | 14 +++- .../src/linux/serve/family_reap.rs | 5 ++ userland/capsule_linux/src/linux/serve/mod.rs | 3 + .../capsule_linux/src/linux/serve/pid_map.rs | 64 +++++++++++++++++++ .../capsule_linux/src/linux/serve/pid_ns.rs | 56 ++++++++++++++++ .../capsule_linux/src/linux/serve/pid_out.rs | 40 ++++++++++++ .../src/linux/serve/table_proc.rs | 2 +- 8 files changed, 182 insertions(+), 4 deletions(-) create mode 100644 userland/capsule_linux/src/linux/serve/pid_map.rs create mode 100644 userland/capsule_linux/src/linux/serve/pid_ns.rs create mode 100644 userland/capsule_linux/src/linux/serve/pid_out.rs diff --git a/userland/capsule_linux/src/linux/serve/dispatch.rs b/userland/capsule_linux/src/linux/serve/dispatch.rs index aa1ae14b5e..be3ec3bc4a 100644 --- a/userland/capsule_linux/src/linux/serve/dispatch.rs +++ b/userland/capsule_linux/src/linux/serve/dispatch.rs @@ -41,6 +41,8 @@ pub fn answer(guest: &mut Guest, frame: &ForeignFrame) -> Answer { Answer::Park } nr::FUTEX => futex(guest, frame.pid, a[0], a[1], a[2]), + // The caller's own thread, which is not always the process. + nr::GETTID | nr::SET_TID_ADDRESS => Answer::value(u64::from(frame.pid)), nr::NANOSLEEP => crate::linux::call::nanosleep(guest, frame.pid, a[0]), np::CLOCK_NANOSLEEP => { crate::linux::call::clock_nanosleep(guest, frame.pid, a[0], a[1], a[2]) diff --git a/userland/capsule_linux/src/linux/serve/family.rs b/userland/capsule_linux/src/linux/serve/family.rs index b5e7758198..92c9f897f2 100644 --- a/userland/capsule_linux/src/linux/serve/family.rs +++ b/userland/capsule_linux/src/linux/serve/family.rs @@ -29,6 +29,9 @@ use nonos_libc::{mk_foreign_reply, ForeignFrame}; use super::answer::Answer; use super::dispatch::answer; +use super::pid_map::frame_in; +use super::pid_ns::PidNs; +use super::pid_out::value_out; use crate::linux::guest::Guest; pub struct Family { @@ -36,25 +39,30 @@ pub struct Family { pub(super) pipes: Vec>, pub(super) root: u32, pub(super) root_code: i32, + pub(super) ns: PidNs, } impl Family { pub fn new(mut first: Guest) -> Self { let pipes = mem::take(&mut first.pipes); let root = first.pid; - Family { guests: alloc::vec![first], pipes, root, root_code: 0 } + let ns = PidNs::new(first.parent, root); + Family { guests: alloc::vec![first], pipes, root, root_code: 0, ns } } pub fn answer(&mut self, frame: &ForeignFrame) { let Some(g) = self.guests.iter_mut().find(|g| g.owns(frame.pid)) else { return; }; + let Some(frame) = frame_in(&self.ns, frame) else { + return; + }; mem::swap(&mut self.pipes, &mut g.pipes); - let got = answer(g, frame); + let got = answer(g, &frame); mem::swap(&mut self.pipes, &mut g.pipes); let born = mem::take(&mut g.forked); if let Answer::Reply(value) = got { - let _ = mk_foreign_reply(frame.pid, value); + let _ = mk_foreign_reply(frame.pid, value_out(&mut self.ns, frame.nr, value)); } self.guests.extend(born); self.settle_pipes(); diff --git a/userland/capsule_linux/src/linux/serve/family_reap.rs b/userland/capsule_linux/src/linux/serve/family_reap.rs index b5e2c10f21..16b102a885 100644 --- a/userland/capsule_linux/src/linux/serve/family_reap.rs +++ b/userland/capsule_linux/src/linux/serve/family_reap.rs @@ -43,6 +43,11 @@ impl Family { if let Some((want, status, tid)) = p.waiting { if let Some(value) = reap_one(p, want, status) { p.waiting = None; + let value = super::pid_out::value_out( + &mut self.ns, + crate::linux::abi::nr::WAIT4, + value, + ); let _ = mk_foreign_reply(tid, value); } } diff --git a/userland/capsule_linux/src/linux/serve/mod.rs b/userland/capsule_linux/src/linux/serve/mod.rs index 063294134b..12c1686aa2 100644 --- a/userland/capsule_linux/src/linux/serve/mod.rs +++ b/userland/capsule_linux/src/linux/serve/mod.rs @@ -23,6 +23,9 @@ mod family_pipes; mod family_reap; mod family_sleep; mod loop_impl; +mod pid_map; +mod pid_ns; +mod pid_out; mod table; mod table_file; mod table_mem; diff --git a/userland/capsule_linux/src/linux/serve/pid_map.rs b/userland/capsule_linux/src/linux/serve/pid_map.rs new file mode 100644 index 0000000000..9321d1d6c1 --- /dev/null +++ b/userland/capsule_linux/src/linux/serve/pid_map.rs @@ -0,0 +1,64 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! Where a pid crosses between a guest and the kernel. +//! +//! Every call that takes a pid is rewritten before it is answered, and every +//! call that returns one is rewritten after, so no handler sees a guest's +//! number and no guest sees a kernel's. + +use nonos_libc::ForeignFrame; + +use crate::linux::abi::{errno, nr, nr_path as np}; + +use super::pid_ns::PidNs; + +/// The frame with its pid arguments in kernel terms. A guest naming a process +/// outside its family is answered here, with the errno Linux would give. +pub fn frame_in(ns: &PidNs, frame: &ForeignFrame) -> Option { + let mut a = frame.args(); + if let Err(refused) = args_in(ns, frame.nr, &mut a) { + let _ = nonos_libc::mk_foreign_reply(frame.pid, refused); + return None; + } + let [arg0, arg1, arg2, arg3, arg4, arg5] = a; + Some(ForeignFrame { arg0, arg1, arg2, arg3, arg4, arg5, ..*frame }) +} + +fn args_in(ns: &PidNs, call: u64, a: &mut [u64; 6]) -> Result<(), u64> { + let (slots, missing): (&[usize], i64) = match call { + nr::WAIT4 => (&[0], errno::ECHILD), + np::KILL | np::TKILL | np::GETPGID | np::GETSID => (&[0], errno::ESRCH), + np::SETPGID => (&[0, 1], errno::ESRCH), + _ => return Ok(()), + }; + for &i in slots { + a[i] = one_in(ns, a[i]).ok_or(errno::fail(missing))?; + } + Ok(()) +} + +/// 0 and -1 mean the caller or everyone; a negative below that is a group, +/// named by its leader's pid. +fn one_in(ns: &PidNs, v: u64) -> Option { + match v as i64 { + -1..=0 => Some(v), + g if g < 0 => { + ns.inward(u32::try_from(g.checked_neg()?).ok()?).map(|k| -i64::from(k) as u64) + } + g => ns.inward(u32::try_from(g).ok()?).map(u64::from), + } +} diff --git a/userland/capsule_linux/src/linux/serve/pid_ns.rs b/userland/capsule_linux/src/linux/serve/pid_ns.rs new file mode 100644 index 0000000000..c3c9501f17 --- /dev/null +++ b/userland/capsule_linux/src/linux/serve/pid_ns.rs @@ -0,0 +1,56 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! A family's own pid numbers. +//! +//! Kernel pids are global. A guest reading them learns how many processes the +//! machine has started, and can watch a sibling's forks move the counter, so +//! it sees these instead: the personality is 1, the program it started is 2, +//! and each process or thread after takes the next number. None is reused +//! while the family lives, so a stale number never reaches a newer process. + +use alloc::vec::Vec; + +pub struct PidNs { + map: Vec<(u32, u32)>, + next: u32, +} + +impl PidNs { + pub fn new(personality: u32, first: u32) -> Self { + PidNs { map: alloc::vec![(personality, 1), (first, 2)], next: 3 } + } + + /// The number a guest sees for kernel pid `k`, given on first sight. + /// Zero once the space is spent, which no caller reads as a process. + pub fn outward(&mut self, k: u32) -> u32 { + if let Some(&(_, g)) = self.map.iter().find(|(kp, _)| *kp == k) { + return g; + } + let Some(after) = self.next.checked_add(1) else { + return 0; + }; + let g = self.next; + self.next = after; + self.map.push((k, g)); + g + } + + /// The kernel pid behind a guest's number, if it names one of this family. + pub fn inward(&self, g: u32) -> Option { + self.map.iter().find(|(_, gp)| *gp == g).map(|(k, _)| *k) + } +} diff --git a/userland/capsule_linux/src/linux/serve/pid_out.rs b/userland/capsule_linux/src/linux/serve/pid_out.rs new file mode 100644 index 0000000000..65c5d53650 --- /dev/null +++ b/userland/capsule_linux/src/linux/serve/pid_out.rs @@ -0,0 +1,40 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! The calls that hand a pid back, and the number the guest sees in it. + +use crate::linux::abi::{nr, nr_path as np}; + +use super::pid_ns::PidNs; + +/// A returned pid in the guest's terms; every other value passes unchanged. +pub fn value_out(ns: &mut PidNs, call: u64, v: u64) -> u64 { + let returns_pid = + matches!( + call, + nr::FORK + | nr::VFORK + | nr::CLONE + | nr::GETPID + | nr::GETTID + | nr::SET_TID_ADDRESS + | nr::WAIT4 + ) || matches!(call, np::GETPPID | np::GETPGRP | np::GETPGID | np::GETSID | np::SETSID); + match u32::try_from(v) { + Ok(k) if returns_pid && k > 0 => u64::from(ns.outward(k)), + _ => v, + } +} diff --git a/userland/capsule_linux/src/linux/serve/table_proc.rs b/userland/capsule_linux/src/linux/serve/table_proc.rs index b5239ab1c9..9b22d6c05e 100644 --- a/userland/capsule_linux/src/linux/serve/table_proc.rs +++ b/userland/capsule_linux/src/linux/serve/table_proc.rs @@ -37,7 +37,7 @@ pub fn proc_ops(guest: &mut Guest, nr: u64, a: [u64; 6]) -> Option { nonos_libc::mk_yield(); errno::ok(0) } - nr::SET_TID_ADDRESS | nr::GETTID | nr::GETPID => errno::ok(guest.pid as u64), + nr::GETPID => errno::ok(guest.pid as u64), nr::GETUID | nr::GETEUID | nr::GETGID | nr::GETEGID => errno::ok(0), nr::CLOCK_GETTIME => call::clock_gettime(guest, a[0], a[1]), _ => return None, From 9bf5aa1a584e6d2316c3a84694868a0344ac8c0e Mon Sep 17 00:00:00 2001 From: eKisNonos Date: Sun, 27 Sep 2026 10:51:13 +0000 Subject: [PATCH 098/244] linux: clocks from the family's start, and one statfs for everyone Monotonic clocks and input timestamps were machine uptime, which dates the boot: the same for every guest on a machine, different between machines. They now count from when the family started. The realtime clocks stay the wall clock at one millisecond, which every machine on the same network time shares. statfs reported the store's real usage, which moves when anything on the machine writes and sizes the install. It now reports the same 1 GiB volume, half free, everywhere; a write that does not fit still fails. --- .../capsule_linux/src/linux/call/clock.rs | 11 ++--- .../capsule_linux/src/linux/call/epoch.rs | 42 +++++++++++++++++++ userland/capsule_linux/src/linux/call/mod.rs | 2 + .../src/linux/file/meta/statfs.rs | 28 ++++++------- .../src/linux/wayland/input_key.rs | 3 +- .../src/linux/wayland/input_send.rs | 3 +- 6 files changed, 64 insertions(+), 25 deletions(-) create mode 100644 userland/capsule_linux/src/linux/call/epoch.rs diff --git a/userland/capsule_linux/src/linux/call/clock.rs b/userland/capsule_linux/src/linux/call/clock.rs index 937252f6a2..534cb4725d 100644 --- a/userland/capsule_linux/src/linux/call/clock.rs +++ b/userland/capsule_linux/src/linux/call/clock.rs @@ -15,10 +15,12 @@ // along with this program. If not, see . //! The clocks a guest reads. The realtime clocks are the wall clock, the rest -//! count from boot; answering every clock with uptime put a guest in 1970 and -//! broke anything that checks a certificate's dates or a file's age. +//! count from the family's start; answering every clock with uptime put a +//! guest in 1970 and broke anything that checks a certificate's dates. -use nonos_libc::{mk_time_millis, mk_uptime_ms}; +use nonos_libc::mk_time_millis; + +use super::epoch::family_ms; use crate::linux::abi::errno; use crate::linux::guest::Guest; @@ -38,8 +40,7 @@ pub fn now_ms(clock: u64) -> Option { } let wall = matches!(clock, CLOCK_REALTIME | CLOCK_REALTIME_COARSE | CLOCK_REALTIME_ALARM | CLOCK_TAI); - let raw = if wall { mk_time_millis() } else { mk_uptime_ms() }; - let ms = u64::try_from(raw).unwrap_or(0); + let ms = if wall { u64::try_from(mk_time_millis()).unwrap_or(0) } else { family_ms() }; // TAI runs ahead of UTC by the leap seconds, 37 since 2017. Some(if clock == CLOCK_TAI { ms.saturating_add(37_000) } else { ms }) } diff --git a/userland/capsule_linux/src/linux/call/epoch.rs b/userland/capsule_linux/src/linux/call/epoch.rs new file mode 100644 index 0000000000..dc23f9eeb2 --- /dev/null +++ b/userland/capsule_linux/src/linux/call/epoch.rs @@ -0,0 +1,42 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! Where a family's clocks start. +//! +//! Uptime is the machine's: it dates the boot, which is the same for every +//! guest on it and differs between machines, so read raw it both fingerprints +//! the machine and lets two guests agree on a moment. A guest's monotonic +//! clocks count from when its family started instead. + +use core::sync::atomic::{AtomicU64, Ordering}; + +use nonos_libc::mk_uptime_ms; + +static START: AtomicU64 = AtomicU64::new(0); + +fn uptime() -> u64 { + u64::try_from(mk_uptime_ms()).unwrap_or(0) +} + +/// Called once, before the first guest runs. +pub fn mark_start() { + START.store(uptime(), Ordering::Relaxed); +} + +/// Milliseconds since the family started. +pub fn family_ms() -> u64 { + uptime().saturating_sub(START.load(Ordering::Relaxed)) +} diff --git a/userland/capsule_linux/src/linux/call/mod.rs b/userland/capsule_linux/src/linux/call/mod.rs index 9ef3bc9e25..c67755b9a0 100644 --- a/userland/capsule_linux/src/linux/call/mod.rs +++ b/userland/capsule_linux/src/linux/call/mod.rs @@ -19,6 +19,7 @@ mod console; mod ctl; mod clock; +mod epoch; mod cwd; mod futex; mod ident; @@ -65,6 +66,7 @@ pub use signal_send::kill; pub use sleep::{clock_nanosleep, nanosleep}; pub use spawn::{clone, execve, fork, reap_one, wait4}; pub use clock::{clock_getres, clock_gettime, now_ms}; +pub use epoch::{family_ms, mark_start}; pub use thread::{arch_prctl, getrandom}; pub use timeops::{gettimeofday, time}; pub use umask::{umask, DEFAULT_UMASK}; diff --git a/userland/capsule_linux/src/linux/file/meta/statfs.rs b/userland/capsule_linux/src/linux/file/meta/statfs.rs index e638ee21a8..a4c9a04a47 100644 --- a/userland/capsule_linux/src/linux/file/meta/statfs.rs +++ b/userland/capsule_linux/src/linux/file/meta/statfs.rs @@ -14,36 +14,32 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -//! How much room the store has, in the shape `statfs` expects. - -use nonos_app_skeleton::clients::vfs; -use nonos_libc::mk_getpid; +//! How much room there is, in the shape `statfs` expects. +//! +//! The store's real usage is shared by everything on the machine: read here, +//! it would let a guest watch a sibling write, and it sizes this install. So +//! every guest sees the same plausible figures, and a write that does not fit +//! still fails where it is made, with ENOSPC. use crate::linux::abi::errno; use crate::linux::guest::Guest; /// `struct statfs` on x86_64 is 120 bytes. const STATFS: usize = 120; - /// The store addresses bytes, not blocks, so a block size is a fiction either /// way. const BSIZE: u64 = 1024; +/// A 1 GiB volume, half free, on every machine. +const BLOCKS: u64 = 1 << 20; +const FREE: u64 = BLOCKS / 2; pub fn statfs(guest: &Guest, out: u64) -> u64 { - let Ok((_, bytes, max)) = vfs::usage(mk_getpid()) else { - return errno::fail(errno::EIO); - }; - // The vfs reports its ceiling as 32 bits and its usage as 64. - let (used, max) = (bytes, u64::from(max)); - let total = max / BSIZE; - let free = max.saturating_sub(used) / BSIZE; - let mut buf = [0u8; STATFS]; put(&mut buf, 0, 0x6E6F6E6F); // f_type, "nono" put(&mut buf, 8, BSIZE); // f_bsize - put(&mut buf, 16, total); // f_blocks - put(&mut buf, 24, free); // f_bfree - put(&mut buf, 32, free); // f_bavail + put(&mut buf, 16, BLOCKS); // f_blocks + put(&mut buf, 24, FREE); // f_bfree + put(&mut buf, 32, FREE); // f_bavail put(&mut buf, 56, 255); // f_namelen, the vfs path limit put(&mut buf, 64, BSIZE); // f_frsize match guest.write(out, &buf) { diff --git a/userland/capsule_linux/src/linux/wayland/input_key.rs b/userland/capsule_linux/src/linux/wayland/input_key.rs index 8b86347d66..762996faf6 100644 --- a/userland/capsule_linux/src/linux/wayland/input_key.rs +++ b/userland/capsule_linux/src/linux/wayland/input_key.rs @@ -14,7 +14,6 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . - //! A key event, written to the client. use crate::linux::guest::Guest; @@ -39,5 +38,5 @@ pub fn key(guest: &mut Guest, code: u32, state: u32) { } fn time_ms() -> u32 { - nonos_libc::mk_uptime_ms().max(0) as u32 + crate::linux::call::family_ms() as u32 } diff --git a/userland/capsule_linux/src/linux/wayland/input_send.rs b/userland/capsule_linux/src/linux/wayland/input_send.rs index fddb4ef8d2..cf8469f7c2 100644 --- a/userland/capsule_linux/src/linux/wayland/input_send.rs +++ b/userland/capsule_linux/src/linux/wayland/input_send.rs @@ -14,7 +14,6 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . - //! One input event, written to the client. use crate::linux::guest::Guest; @@ -58,5 +57,5 @@ pub fn button(guest: &mut Guest, code: u32, state: u32) { } fn time_ms() -> u32 { - nonos_libc::mk_uptime_ms().max(0) as u32 + crate::linux::call::family_ms() as u32 } From 2db55183628c2486bb09f88e495974ba3f314df4 Mon Sep 17 00:00:00 2001 From: eKisNonos Date: Sun, 27 Sep 2026 10:51:13 +0000 Subject: [PATCH 099/244] linux: scratch directories of each family's own, and a read-only tree Every guest shared one /linux tree and could write all of it, so a file left in /tmp or /home by one guest was there for the next: a name two guests share, and state that outlived the run. /tmp, /dev/shm, /home, /root, /run and /var/tmp now live under a root named by a random id drawn at start, outside /linux so no guest path reaches another's, and removed when the family ends. The rest of the tree is written by installs alone; a running guest is refused. --- userland/capsule_linux/src/linux/file/mod.rs | 2 + .../src/linux/file/private/life.rs | 44 +++++++++++++ .../src/linux/file/private/mod.rs | 23 +++++++ .../src/linux/file/private/names.rs | 65 +++++++++++++++++++ userland/capsule_linux/src/linux/file/root.rs | 19 +++++- .../capsule_linux/src/linux/file/store.rs | 1 + .../src/linux/file/store_name.rs | 6 ++ userland/capsule_linux/src/linux/start.rs | 8 +++ 8 files changed, 166 insertions(+), 2 deletions(-) create mode 100644 userland/capsule_linux/src/linux/file/private/life.rs create mode 100644 userland/capsule_linux/src/linux/file/private/mod.rs create mode 100644 userland/capsule_linux/src/linux/file/private/names.rs diff --git a/userland/capsule_linux/src/linux/file/mod.rs b/userland/capsule_linux/src/linux/file/mod.rs index fd16e766f8..ba8192c95d 100644 --- a/userland/capsule_linux/src/linux/file/mod.rs +++ b/userland/capsule_linux/src/linux/file/mod.rs @@ -34,6 +34,7 @@ mod meta; mod open; mod path; mod pread; +mod private; mod read; mod regular; mod rename; @@ -62,6 +63,7 @@ pub use meta::{ pub use open::openat; pub use path::read_path; pub use pread::pread64; +pub use private::{allow_shared_writes, clear as clear_private, prepare as prepare_private}; pub use read::read; pub use rename::rename; pub use resolve::{key, visible}; diff --git a/userland/capsule_linux/src/linux/file/private/life.rs b/userland/capsule_linux/src/linux/file/private/life.rs new file mode 100644 index 0000000000..356fffffae --- /dev/null +++ b/userland/capsule_linux/src/linux/file/private/life.rs @@ -0,0 +1,44 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! A family's private directories, made before it runs and gone after. + +use nonos_app_skeleton::clients::vfs; +use nonos_libc::mk_getpid; + +use super::names::{choose, root, PRIVATE}; + +/// A fresh id and the scratch directories a program expects to find. False +/// when there is no id to keep them apart by, and the guest must not start. +pub fn prepare() -> bool { + if !choose() { + return false; + } + let pid = mk_getpid(); + for p in PRIVATE { + let mut at = root(); + at.extend_from_slice(p); + if vfs::mkdir(pid, &at).is_err() { + return false; + } + } + true +} + +/// Everything the family wrote to its own directories, removed. +pub fn clear() { + let _ = vfs::rmdir(mk_getpid(), &root(), true); +} diff --git a/userland/capsule_linux/src/linux/file/private/mod.rs b/userland/capsule_linux/src/linux/file/private/mod.rs new file mode 100644 index 0000000000..d0ca4608c3 --- /dev/null +++ b/userland/capsule_linux/src/linux/file/private/mod.rs @@ -0,0 +1,23 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! What each family keeps to itself. + +mod life; +mod names; + +pub use life::{clear, prepare}; +pub use names::{allow_shared_writes, is_private, root, shared_writes_allowed}; diff --git a/userland/capsule_linux/src/linux/file/private/names.rs b/userland/capsule_linux/src/linux/file/private/names.rs new file mode 100644 index 0000000000..63ce423773 --- /dev/null +++ b/userland/capsule_linux/src/linux/file/private/names.rs @@ -0,0 +1,65 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! What each family keeps to itself. +//! +//! The Linux tree is one tree for every guest on the machine. A scratch +//! directory in it would be a name two guests share, and a file left there a +//! message from one to the other. These prefixes live instead under a root of +//! the family's own, named by a random id and outside `/linux`, so no path a +//! guest can write reaches another family's, and it is cleared at the end. + +use alloc::vec::Vec; +use core::sync::atomic::{AtomicBool, AtomicU64, Ordering}; + +pub const PRIVATE: &[&[u8]] = &[b"/tmp", b"/dev/shm", b"/home", b"/root", b"/run", b"/var/tmp"]; +const BASE: &[u8] = b"/linux-private/"; + +static ID: AtomicU64 = AtomicU64::new(0); +static INSTALLING: AtomicBool = AtomicBool::new(false); + +/// Draw this family's id from the kernel's source. False if it cannot. +pub fn choose() -> bool { + let mut id = [0u8; 8]; + if nonos_libc::crypto_random(id.as_mut_ptr(), id.len()) < 0 { + return false; + } + ID.store(u64::from_le_bytes(id), Ordering::Relaxed); + true +} + +/// The store root this family's private prefixes live under. +pub fn root() -> Vec { + let mut out = Vec::from(BASE); + out.extend_from_slice(alloc::format!("{:016x}", ID.load(Ordering::Relaxed)).as_bytes()); + out +} + +/// True when `visible` is one of the private prefixes or below one. +pub fn is_private(visible: &[u8]) -> bool { + PRIVATE + .iter() + .any(|p| visible.starts_with(p) && matches!(visible.get(p.len()), None | Some(b'/'))) +} + +/// Only the install path writes the shared tree; a running guest never does. +pub fn allow_shared_writes() { + INSTALLING.store(true, Ordering::Relaxed); +} + +pub fn shared_writes_allowed() -> bool { + INSTALLING.load(Ordering::Relaxed) +} diff --git a/userland/capsule_linux/src/linux/file/root.rs b/userland/capsule_linux/src/linux/file/root.rs index 4155b82e70..b5e0fce61c 100644 --- a/userland/capsule_linux/src/linux/file/root.rs +++ b/userland/capsule_linux/src/linux/file/root.rs @@ -24,12 +24,19 @@ pub const ROOT: &[u8] = b"/linux"; /// A path in the store, already confined. Built only from a normalised /// guest-visible path, by `resolve::key`. -pub struct Key(Vec); +/// `shared` is false for a path in the family's private directories. +pub struct Key(Vec, bool); impl Key { /// `visible` must be absolute and free of `.` and `..`, which is what /// `resolve::visible` guarantees and the only thing that calls this. pub(super) fn under_root(visible: &[u8]) -> Key { + // An install has no family, and writes only the shared tree. + if !super::private::shared_writes_allowed() && super::private::is_private(visible) { + let mut out = super::private::root(); + out.extend_from_slice(visible); + return Key(out, false); + } let mut out = Vec::with_capacity(ROOT.len() + visible.len()); out.extend_from_slice(ROOT); /* @@ -40,10 +47,18 @@ impl Key { if visible != b"/" { out.extend_from_slice(visible); } - Key(out) + Key(out, true) } pub fn as_bytes(&self) -> &[u8] { &self.0 } + + /// The shared tree is written by installs alone; a guest is refused. + pub fn writable(&self) -> Result<(), &'static str> { + match self.1 && !super::private::shared_writes_allowed() { + true => Err("read-only file system"), + false => Ok(()), + } + } } diff --git a/userland/capsule_linux/src/linux/file/store.rs b/userland/capsule_linux/src/linux/file/store.rs index ef1e9f8892..ec6dcce7dc 100644 --- a/userland/capsule_linux/src/linux/file/store.rs +++ b/userland/capsule_linux/src/linux/file/store.rs @@ -31,6 +31,7 @@ pub fn read(at: &Key, max: u32) -> Result, Fail> { } pub fn write(at: &Key, data: &[u8]) -> Result<(), Fail> { + at.writable()?; vfs::write_file(mk_getpid(), at.as_bytes(), data) } diff --git a/userland/capsule_linux/src/linux/file/store_name.rs b/userland/capsule_linux/src/linux/file/store_name.rs index d2dbf874d9..bbca4c43a9 100644 --- a/userland/capsule_linux/src/linux/file/store_name.rs +++ b/userland/capsule_linux/src/linux/file/store_name.rs @@ -24,21 +24,27 @@ use super::root::Key; type Fail = &'static str; pub fn mkdir(at: &Key) -> Result<(), Fail> { + at.writable()?; vfs::mkdir(mk_getpid(), at.as_bytes()) } pub fn rmdir(at: &Key) -> Result<(), Fail> { + at.writable()?; vfs::rmdir(mk_getpid(), at.as_bytes(), false) } pub fn unlink(at: &Key) -> Result<(), Fail> { + at.writable()?; vfs::unlink(mk_getpid(), at.as_bytes()) } pub fn rename(from: &Key, to: &Key) -> Result<(), Fail> { + from.writable()?; + to.writable()?; vfs::rename(mk_getpid(), from.as_bytes(), to.as_bytes()) } pub fn chmod(at: &Key, mode: u16) -> Result<(), Fail> { + at.writable()?; vfs::chmod(mk_getpid(), at.as_bytes(), mode) } diff --git a/userland/capsule_linux/src/linux/start.rs b/userland/capsule_linux/src/linux/start.rs index b99134992d..e7e97fb583 100644 --- a/userland/capsule_linux/src/linux/start.rs +++ b/userland/capsule_linux/src/linux/start.rs @@ -28,6 +28,7 @@ pub fn run() -> ! { say(b"[LINUX] personality up\n"); if let Some((name, pin)) = super::request::install_request() { say(b"[LINUX] installing\n"); + super::file::allow_shared_writes(); let ok = super::install::install(&name, &pin); say(if ok { b"[LINUX] installed\n" } else { b"[LINUX] install failed\n" }); mk_exit(if ok { 0 } else { 1 }) @@ -44,6 +45,11 @@ pub fn run() -> ! { say(&digits); mk_exit(1) } + super::call::mark_start(); + if !super::file::prepare_private() { + say(b"[LINUX] no private directories, not starting\n"); + mk_exit(1) + } let mut guest = Guest::new(pid as u32); guest.links = alloc::rc::Rc::new(super::guest::Links::load()); let code = match start(&mut guest, &launch) { @@ -52,10 +58,12 @@ pub fn run() -> ! { serve(guest) } Err(step) => { + super::file::clear_private(); say(step); mk_exit(2) } }; + super::file::clear_private(); say(b"[LINUX] guest exited\n"); mk_exit(code) } From d701e149a2f539bbd829900a0dcc22fa56d756a3 Mon Sep 17 00:00:00 2001 From: eKisNonos Date: Sun, 27 Sep 2026 10:52:44 +0000 Subject: [PATCH 100/244] linux: one file saying what every served call discloses Each served call has a line in abi/disclosure.txt: what a guest learns from it and why that is acceptable. CI fails when a call is served without a line, or a line names a call that is not, so a new call cannot be answered before someone has decided what it tells. --- .github/workflows/verify.yml | 2 + tools/ratchets/disclosure.py | 62 ++++++++++++ userland/capsule_linux/abi/disclosure.txt | 114 ++++++++++++++++++++++ 3 files changed, 178 insertions(+) create mode 100644 tools/ratchets/disclosure.py create mode 100644 userland/capsule_linux/abi/disclosure.txt diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml index c8002a1936..e912af710a 100644 --- a/.github/workflows/verify.yml +++ b/.github/workflows/verify.yml @@ -123,6 +123,8 @@ jobs: run: python3 tools/nonos-linux-coverage --baseline scripts/baselines/linux-syscalls.txt - name: Wayland coverage does not fall run: python3 tools/nonos-wayland-coverage --baseline scripts/baselines/wayland-globals.txt + - name: Every served Linux call says what it discloses + run: python3 tools/ratchets/disclosure.py # The committed verification/evidence/EVIDENCE.json is a machine-readable inventory of # everything NONOS proves. Regenerate it from the source tree and fail if it diff --git a/tools/ratchets/disclosure.py b/tools/ratchets/disclosure.py new file mode 100644 index 0000000000..6e3f1ac3fc --- /dev/null +++ b/tools/ratchets/disclosure.py @@ -0,0 +1,62 @@ +# NONOS Operating System +# Copyright (C) 2026 NONOS Contributors +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +"""The syscalls the Linux personality serves, and the ones x86_64 defines.""" +"""Every served Linux call says what it discloses, in one file. + +abi/disclosure.txt holds one line per served call: name | discloses | why that +is acceptable. A call served without a line fails, and so does a line for a +call that is not served, so the file cannot drift from the table. +""" + +import argparse +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) +from linux_calls import LINUX, defined, served # noqa: E402 + + +def lines(root): + out = {} + for n, raw in enumerate((root / LINUX / "abi/disclosure.txt").read_text().splitlines(), 1): + if not raw or raw.startswith("#"): + continue + parts = [p.strip() for p in raw.split("|")] + if len(parts) != 3 or not all(parts): + sys.exit(f"disclosure.txt:{n}: want name | discloses | why") + out[parts[0]] = n + return out + + +def main(): + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("--root", type=Path, default=Path(".")) + a = ap.parse_args() + table = defined(a.root) + have = {table[n] for n in served(a.root)} + said = lines(a.root) + missing = sorted(have - said.keys()) + extra = sorted(said.keys() - have) + for name in missing: + print(f"[disclosure] served without a line: {name}") + for name in extra: + print(f"[disclosure] a line for a call not served: {name}") + print(f"[disclosure] {len(said)} lines, {len(have)} served") + return 1 if missing or extra else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/userland/capsule_linux/abi/disclosure.txt b/userland/capsule_linux/abi/disclosure.txt new file mode 100644 index 0000000000..dffeba66a3 --- /dev/null +++ b/userland/capsule_linux/abi/disclosure.txt @@ -0,0 +1,114 @@ +# What each served Linux call tells a guest, and why that is acceptable. +# One line per call: name | discloses | why acceptable. tools/ratchets/ +# disclosure.py fails when a served call has no line or a line names a call +# not served. Section 8 of the completion doc: a plausible constant over the +# truth wherever the truth is nobody's business; the machine must look the +# same from every guest on every install unless the person chose otherwise. +read | bytes of the guest's own files, pipes and sockets | its own data +write | nothing back but a count | a count of its own bytes +open | whether a path exists in the Linux tree or the family's private dirs | the tree holds installs every machine with them shares; private dirs are the family's own +close | nothing | none +stat | size and kind of a path; fixed inode, times and owner | size is of shared installs or the family's own files +fstat | size and kind of an open descriptor | as stat +lstat | as stat | as stat +poll | readiness of its own descriptors | its own state +lseek | its own file offset | its own state +mmap | an address in its own layout, chosen here | the layout is the personality's, not the machine's +mprotect | success or refusal of its own pages | its own state +munmap | success or refusal of its own pages | its own state +brk | its own break, in a layout chosen here | as mmap +rt_sigaction | the previous handler it set | its own state +rt_sigprocmask | the mask it set | its own state +ioctl | ENOTTY for every open descriptor, EBADF otherwise | a constant; no terminal size or device is described +pread64 | bytes of its own files | as read +readv | as read | as read +writev | as write | as write +access | whether a path exists | as open +pipe | two descriptor numbers | its own table +select | readiness of its own descriptors | as poll +sched_yield | nothing | none +madvise | 0 | a constant +dup | a descriptor number | its own table +dup2 | a descriptor number | its own table +nanosleep | elapsed time at 1 ms, on the family's clock | the family's clock starts at its own start, not the machine's boot +getpid | the family's own number for the process | family numbering hides the machine's process count +socket | a descriptor number | its own table +connect | success or refusal; names resolve to addresses invented here | no DNS leaves the machine; the remote sees the network service's egress, not this machine +sendto | a count | as write +recvfrom | bytes the remote sent | the guest asked for them +sendmsg | a count, on the display socket | the family's own display +recvmsg | display events for its own surfaces | input only while focused, through the router +shutdown | nothing | none +clone | a thread number in the family's numbering | as getpid +fork | a child number in the family's numbering | as getpid +vfork | as fork | as getpid +execve | whether a program is enrolled and proved | refusal names no measurement or key +exit | nothing | none +wait4 | its own child's number and exit code | its own children +kill | whether a number is its own or its child's | nothing outside the family is named or reachable +uname | Linux, nonos, 6.1.0, NONOS Linux personality, x86_64, nonos | constants; the person's hostname is never shown +fcntl | its own descriptor flags | its own state +fsync | 0, or EIO if its own buffered bytes did not reach the store | the store is RAM; nothing reaches disk from a guest +ftruncate | success or refusal of its own file | its own state +getcwd | its own cwd under its own root | its own state +chdir | whether a directory exists | as open +fchdir | as chdir | as open +rename | success, or refusal outside its private dirs | the shared tree is read-only to guests +mkdir | as rename | as rename +rmdir | as rename | as rename +unlink | as rename | as rename +readlink | the target of a link in the Linux tree | links come with the shared installs +chmod | as rename | as rename +fchmod | as rename | as rename +umask | the mask it set | its own state +gettimeofday | the wall clock at 1 ms | shared by every machine on network time; no finer step is given +getrlimit | fixed limits | constants +getuid | 0 | a constant +getgid | 0 | a constant +setuid | success only for 0 | a constant +setgid | success only for 0 | a constant +geteuid | 0 | a constant +getegid | 0 | a constant +setpgid | success within the family | family numbering +getppid | the parent's number; 1 for the program the personality started | family numbering +getpgrp | the group's number in the family | family numbering +setsid | the session's number in the family | family numbering +getpgid | as getpgrp | family numbering +getsid | as setsid | family numbering +sigaltstack | the stack it set | its own state +statfs | a 1 GiB volume, half free | a constant; the store's real usage is shared and sizes the install +fstatfs | as statfs | a constant +arch_prctl | its own thread pointer | its own state +gettid | the calling thread's number in the family | family numbering +tkill | as kill | as kill +time | the wall clock in seconds | as gettimeofday +futex | wakes among its own threads | its own state +getdents64 | names in the Linux tree or its private dirs | as open +set_tid_address | the calling thread's number in the family | family numbering +clock_gettime | wall clocks at 1 ms; other clocks since the family started | as nanosleep and gettimeofday +clock_getres | 1 ms for every clock | a constant +clock_nanosleep | as nanosleep | as nanosleep +exit_group | nothing | none +epoll_wait | readiness of its own descriptors | as poll +epoll_ctl | its own interest set | its own state +openat | as open | as open +mkdirat | as mkdir | as rename +newfstatat | as stat | as stat +unlinkat | as unlink | as rename +fchmodat | as chmod | as rename +faccessat | as access | as open +pselect6 | as select | as poll +ppoll | as poll | as poll +set_robust_list | 0 | a constant +epoll_pwait | as epoll_wait | as poll +timerfd_create | a descriptor number | its own table +timerfd_settime | expirations on the family's clock | as nanosleep +epoll_create1 | a descriptor number | its own table +dup3 | a descriptor number | its own table +pipe2 | as pipe | its own table +prlimit64 | fixed limits; changes refused | constants +getrandom | bytes from the kernel's generator, up to 256 a call | fresh per call and never shared between guests +memfd_create | a descriptor number | its own table +statx | as stat | as stat +rseq | 0 | a constant +faccessat2 | as access | as open From e785840e7b4f4d385677cc019eb67fdb52ec672c Mon Sep 17 00:00:00 2001 From: eKisNonos Date: Sun, 27 Sep 2026 10:56:02 +0000 Subject: [PATCH 101/244] linux_guests: a fingerprinter, a sibling through a shared name, a clock Three guests for the channels section 8 names. The fingerprint probe reads the host name, its own pid and parent, the volume size and two draws of randomness, and fails on anything that would tell this machine from another. The holder leaves a pattern in /tmp and at the root and the reader, a family of its own, looks for it. The reader first reads the monotonic and boot clocks, which must count from its own start. --- userland/linux_guests/src/bin/holder.rs | 1 + userland/linux_guests/src/bin/reader.rs | 11 +++- userland/linux_guests/src/bin/suite.rs | 6 ++- userland/linux_guests/src/clock_probe.rs | 44 ++++++++++++++++ userland/linux_guests/src/fp_probe.rs | 63 ++++++++++++++++++++++ userland/linux_guests/src/lib.rs | 3 ++ userland/linux_guests/src/shared_name.rs | 67 ++++++++++++++++++++++++ 7 files changed, 191 insertions(+), 4 deletions(-) create mode 100644 userland/linux_guests/src/clock_probe.rs create mode 100644 userland/linux_guests/src/fp_probe.rs create mode 100644 userland/linux_guests/src/shared_name.rs diff --git a/userland/linux_guests/src/bin/holder.rs b/userland/linux_guests/src/bin/holder.rs index 017720358f..d4cf11cee6 100644 --- a/userland/linux_guests/src/bin/holder.rs +++ b/userland/linux_guests/src/bin/holder.rs @@ -38,6 +38,7 @@ fn main() { for chunk in page.chunks_mut(PATTERN.len()) { chunk.copy_from_slice(&PATTERN[..chunk.len()]); } + nonos_linux_guests::shared_name::leave(); let pid = call(GETPID, [0; 6]); out(format!("[GUEST] holder pid={pid} pattern at {PATTERN_AT:#x}\n").as_bytes()); let ts = [HOLD_SECS, 0u64]; diff --git a/userland/linux_guests/src/bin/reader.rs b/userland/linux_guests/src/bin/reader.rs index efdb684993..a3385c5966 100644 --- a/userland/linux_guests/src/bin/reader.rs +++ b/userland/linux_guests/src/bin/reader.rs @@ -26,13 +26,17 @@ use nonos_linux_guests::report::{Report, Seen}; use nonos_linux_guests::sys::{ call, GETPID, MAP_FIXED, MAP_PRIVATE_ANON, MMAP, PATTERN, PATTERN_AT, PROT_RW, }; -use nonos_linux_guests::{proc_probe, vm_probe}; +use nonos_linux_guests::{clock_probe, proc_probe, shared_name, vm_probe}; /// Guest pids are small; a sibling started beside this one sits well inside. const PID_RANGE: u32 = 256; fn main() -> ExitCode { + let mut clock = Report::new("clock"); + clock_probe::scan(&mut clock); + let clock_broken = clock.finish() != ExitCode::SUCCESS; let mut r = Report::new("reader"); + shared_name::look(&mut r); let me = call(GETPID, [0; 6]) as u32; let pids: Vec = (1..=PID_RANGE).filter(|p| *p != me).collect(); vm_probe::scan(&mut r, &pids); @@ -50,5 +54,8 @@ fn main() -> ExitCode { } }; r.check("same address, own page", seen); - r.finish() + match r.finish() { + _ if clock_broken => ExitCode::FAILURE, + verdict => verdict, + } } diff --git a/userland/linux_guests/src/bin/suite.rs b/userland/linux_guests/src/bin/suite.rs index 754efc0040..142318b31c 100644 --- a/userland/linux_guests/src/bin/suite.rs +++ b/userland/linux_guests/src/bin/suite.rs @@ -24,13 +24,15 @@ use std::process::ExitCode; use nonos_linux_guests::report::Report; use nonos_linux_guests::{ - bounds_probe, dyn_probe, exec_probe, fs_probe, life_probe, native_probe, proc_probe, sep_probe, + bounds_probe, dyn_probe, exec_probe, fp_probe, fs_probe, life_probe, native_probe, proc_probe, + sep_probe, }; fn main() -> ExitCode { let mut broken = false; for (guest, scan) in [ - ("native", native_probe::scan as fn(&mut Report)), + ("fingerprint", fp_probe::scan as fn(&mut Report)), + ("native", native_probe::scan), ("bounds", bounds_probe::scan), ("fs", fs_probe::scan), ("proc", proc_self), diff --git a/userland/linux_guests/src/clock_probe.rs b/userland/linux_guests/src/clock_probe.rs new file mode 100644 index 0000000000..fdbe736815 --- /dev/null +++ b/userland/linux_guests/src/clock_probe.rs @@ -0,0 +1,44 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! A clock two guests could read to agree on a moment. +//! +//! Monotonic time since boot is the machine's, the same for every guest on it +//! and different on the next machine. A family's clocks start with the family, +//! so a guest reading one at its own start must see a small number. + +use crate::report::{Report, Seen}; +use crate::sys::call; + +const CLOCK_GETTIME: u64 = 228; +const CLOCKS: [(u64, &str); 2] = [(1, "monotonic"), (7, "boottime")]; +/// Far above a family's first moments, far below any boot under emulation. +const FRESH_MS: u64 = 30_000; + +/// Called first thing in a guest's main, before anything else takes time. +pub fn scan(r: &mut Report) { + for (clock, name) in CLOCKS { + let mut ts = [0u64; 2]; + let rc = call(CLOCK_GETTIME, [clock, ts.as_mut_ptr() as u64, 0, 0, 0, 0]); + let ms = ts[0].saturating_mul(1000).saturating_add(ts[1] / 1_000_000); + let seen = match (rc, ms) { + (rc, _) if rc < 0 => Seen::Refused(rc), + (_, ms) if ms < FRESH_MS => Seen::Refused(0), + (_, ms) => Seen::Escaped(format!("{ms} ms, the machine's uptime")), + }; + r.check(&format!("the {name} clock"), seen); + } +} diff --git a/userland/linux_guests/src/fp_probe.rs b/userland/linux_guests/src/fp_probe.rs new file mode 100644 index 0000000000..319b0553fb --- /dev/null +++ b/userland/linux_guests/src/fp_probe.rs @@ -0,0 +1,63 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! What a guest can learn that would tell this machine from another. +//! +//! Each value must be the one every install gives: a constant name, a pid in +//! the family's own numbering, the same volume size, and randomness that is +//! fresh on every read. Anything else is a fingerprint, and it is reported. + +use crate::report::{Report, Seen}; +use crate::sys::{call, GETPID}; + +const UNAME: u64 = 63; +const STATFS: u64 = 137; +const GETPPID: u64 = 110; +const GETRANDOM: u64 = 318; + +pub fn scan(r: &mut Report) { + let mut uts = [0u8; 390]; + let _ = call(UNAME, [uts.as_mut_ptr() as u64, 0, 0, 0, 0, 0]); + let node = field(&uts, 1); + r.check("the host name", same(node == b"nonos", || format!("nodename {:?}", node))); + let (pid, ppid) = (call(GETPID, [0; 6]), call(GETPPID, [0; 6])); + let numbered = pid == 2 && ppid == 1; + r.check("the machine's pid count", same(numbered, || format!("pid {pid}, parent {ppid}"))); + let mut fs = [0u64; 15]; + let _ = call(STATFS, [b"/\0".as_ptr() as u64, fs.as_mut_ptr() as u64, 0, 0, 0, 0]); + let plain = fs[2] == 1 << 20 && fs[3] == 1 << 19; + r.check("the store's size", same(plain, || format!("{} blocks, {} free", fs[2], fs[3]))); + let (a, b) = (random(), random()); + r.check("repeated randomness", same(a != b && a != [0; 16], || "two reads agreed".into())); +} + +fn same(held: bool, how: impl FnOnce() -> String) -> Seen { + match held { + true => Seen::Refused(0), + false => Seen::Escaped(how()), + } +} + +fn field(uts: &[u8; 390], i: usize) -> &[u8] { + let f = &uts[i * 65..(i + 1) * 65]; + &f[..f.iter().position(|b| *b == 0).unwrap_or(65)] +} + +fn random() -> [u8; 16] { + let mut buf = [0u8; 16]; + let _ = call(GETRANDOM, [buf.as_mut_ptr() as u64, 16, 0, 0, 0, 0]); + buf +} diff --git a/userland/linux_guests/src/lib.rs b/userland/linux_guests/src/lib.rs index 207a2b565f..326f33a255 100644 --- a/userland/linux_guests/src/lib.rs +++ b/userland/linux_guests/src/lib.rs @@ -18,10 +18,12 @@ pub mod arg; pub mod bounds_probe; +pub mod clock_probe; pub mod dyn_probe; pub mod exec_child; pub mod exec_probe; pub mod fs_paths; +pub mod fp_probe; pub mod fs_probe; pub mod life_probe; pub mod native_probe; @@ -29,5 +31,6 @@ pub mod proc_probe; pub mod report; pub mod sep_child; pub mod sep_probe; +pub mod shared_name; pub mod sys; pub mod vm_probe; diff --git a/userland/linux_guests/src/shared_name.rs b/userland/linux_guests/src/shared_name.rs new file mode 100644 index 0000000000..3c26a464cb --- /dev/null +++ b/userland/linux_guests/src/shared_name.rs @@ -0,0 +1,67 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! A name two guests could both reach, tried from each side. +//! +//! The holder leaves the pattern under each name; the reader, a family of +//! its own, looks for it. Scratch space is the family's own and the tree is +//! read-only to guests, so the reader must find neither. + +use crate::report::{Report, Seen}; +use crate::sys::{call, out, CLOSE, OPEN, PATTERN, READ, WRITE}; + +const O_WRONLY_CREAT: u64 = 0o101; +const NAMES: [&str; 2] = ["/tmp/nonos-sibling\0", "/nonos-sibling\0"]; + +/// The holder's half: every name written, and what each write returned. +pub fn leave() { + for name in NAMES { + let fd = call(OPEN, [name.as_ptr() as u64, O_WRONLY_CREAT, 0o644, 0, 0, 0]); + let wrote = match fd { + fd if fd < 0 => fd, + fd => { + let n = call( + WRITE, + [fd as u64, PATTERN.as_ptr() as u64, PATTERN.len() as u64, 0, 0, 0], + ); + let _ = call(CLOSE, [fd as u64, 0, 0, 0, 0, 0]); + n + } + }; + let shown = name.trim_end_matches('\0'); + out(format!("[GUEST] holder left {shown}: {wrote}\n").as_bytes()); + } +} + +/// The reader's half: a name that opens onto the pattern is an escape. +pub fn look(r: &mut Report) { + for name in NAMES { + let what = format!("the sibling's {}", name.trim_end_matches('\0')); + let fd = call(OPEN, [name.as_ptr() as u64, 0, 0, 0, 0, 0]); + if fd < 0 { + r.check(&what, Seen::Refused(fd)); + continue; + } + let mut buf = [0u8; 16]; + let n = call(READ, [fd as u64, buf.as_mut_ptr() as u64, 16, 0, 0, 0]); + let _ = call(CLOSE, [fd as u64, 0, 0, 0, 0, 0]); + let seen = match n == 16 && &buf == PATTERN { + true => Seen::Escaped("its pattern was there".into()), + false => Seen::Refused(0), + }; + r.check(&what, seen); + } +} From 396639a365ad77ae951dd441ddae43c58c6f6916 Mon Sep 17 00:00:00 2001 From: eKisNonos Date: Sun, 27 Sep 2026 10:56:02 +0000 Subject: [PATCH 102/244] tools: a mutant per control, and the line its guest must print verification/mutants.json removes each control by one exact substitution and names what the hostile guest prints once it is gone: the pid namespace, the family clock, the statfs constant, private scratch, the read-only tree, the trap frame and the amnesic gate. --check runs in CI so a mutant cannot quietly stop applying; --apply and --verdict drive a mutant build and read its boot. --- .github/workflows/verify.yml | 2 + tools/nonos-mutant | 74 ++++++++++++++++++++++++++++++++++++ verification/mutants.json | 51 +++++++++++++++++++++++++ 3 files changed, 127 insertions(+) create mode 100755 tools/nonos-mutant create mode 100644 verification/mutants.json diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml index e912af710a..624519cf01 100644 --- a/.github/workflows/verify.yml +++ b/.github/workflows/verify.yml @@ -125,6 +125,8 @@ jobs: run: python3 tools/nonos-wayland-coverage --baseline scripts/baselines/wayland-globals.txt - name: Every served Linux call says what it discloses run: python3 tools/ratchets/disclosure.py + - name: Every mutant still removes the control it names + run: python3 tools/nonos-mutant --check # The committed verification/evidence/EVIDENCE.json is a machine-readable inventory of # everything NONOS proves. Regenerate it from the source tree and fail if it diff --git a/tools/nonos-mutant b/tools/nonos-mutant new file mode 100755 index 0000000000..138197e568 --- /dev/null +++ b/tools/nonos-mutant @@ -0,0 +1,74 @@ +#!/usr/bin/env python3 +# NONOS Operating System +# Copyright (C) 2026 NONOS Contributors +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +"""A build with one control removed, and the line that must then appear. + +verification/mutants.json names each control by one exact substitution and +the log line its hostile guest prints once the control is gone. --check +confirms every substitution still applies exactly once, so a mutant cannot +rot into testing nothing; --apply NAME rewrites a worktree to that mutant for +a build and a boot; --verdict NAME LOG says whether the boot showed it. +""" + +import argparse +import json +import sys +from pathlib import Path + +MUTANTS = Path("verification/mutants.json") + + +def load(root): + return {m["name"]: m for m in json.loads((root / MUTANTS).read_text())} + + +def count(root, m): + return (root / m["file"]).read_text().count(m["old"]) + + +def main(): + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("--root", type=Path, default=Path(".")) + ap.add_argument("--check", action="store_true", help="every mutant still applies once") + ap.add_argument("--apply", metavar="NAME", help="rewrite --root to this mutant") + ap.add_argument("--verdict", nargs=2, metavar=("NAME", "LOG"), help="did the boot show it") + a = ap.parse_args() + mutants = load(a.root) + if a.check: + stale = [n for n, m in mutants.items() if count(a.root, m) != 1] + for n in stale: + print(f"[mutant] {n}: its substitution no longer applies once") + print(f"[mutant] {len(mutants) - len(stale)} of {len(mutants)} apply") + return 1 if stale else 0 + if a.apply: + m = mutants[a.apply] + if count(a.root, m) != 1: + sys.exit(f"[mutant] {a.apply} does not apply to {a.root}") + path = a.root / m["file"] + path.write_text(path.read_text().replace(m["old"], m["new"])) + print(f"[mutant] {a.apply} applied; expect: {m['escapes']}") + return 0 + if a.verdict: + name, log = a.verdict + seen = mutants[name]["escapes"].encode() in Path(log).read_bytes() + print(f"[mutant] {name}: {'caught' if seen else 'NOT caught, the guest did not notice'}") + return 0 if seen else 1 + ap.print_help() + return 2 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/verification/mutants.json b/verification/mutants.json new file mode 100644 index 0000000000..21591fc487 --- /dev/null +++ b/verification/mutants.json @@ -0,0 +1,51 @@ +[ + { + "name": "pid-namespace", + "file": "userland/capsule_linux/src/linux/serve/pid_out.rs", + "old": "Ok(k) if returns_pid && k > 0 => u64::from(ns.outward(k)),", + "new": "Ok(k) if returns_pid && k > 0 => u64::from(k),", + "escapes": "[GUEST] fingerprint ESCAPED the machine's pid count" + }, + { + "name": "family-clock", + "file": "userland/capsule_linux/src/linux/call/epoch.rs", + "old": "uptime().saturating_sub(START.load(Ordering::Relaxed))", + "new": "uptime()", + "escapes": "[GUEST] clock ESCAPED the monotonic clock" + }, + { + "name": "statfs-constant", + "file": "userland/capsule_linux/src/linux/file/meta/statfs.rs", + "old": "const BLOCKS: u64 = 1 << 20;", + "new": "const BLOCKS: u64 = 1 << 21;", + "escapes": "[GUEST] fingerprint ESCAPED the store's size" + }, + { + "name": "private-tmp", + "file": "userland/capsule_linux/src/linux/file/private/names.rs", + "old": ".any(|p| visible.starts_with(p) && matches!(visible.get(p.len()), None | Some(b'/')))", + "new": ".any(|p| p.is_empty() && visible.starts_with(p))", + "escapes": "[GUEST] reader ESCAPED the sibling's /tmp/nonos-sibling" + }, + { + "name": "read-only-tree", + "file": "userland/capsule_linux/src/linux/file/root.rs", + "old": "match self.1 && !super::private::shared_writes_allowed() {", + "new": "match false {", + "escapes": "[GUEST] reader ESCAPED the sibling's /nonos-sibling" + }, + { + "name": "trap-frame", + "file": "src/process/foreign/trap.rs", + "old": "super::trap_frame::keep(pid, saved);", + "new": "super::trap_frame::keep(pid, saved);\n crate::process::with_process(pid, |pcb| *pcb.saved_user_context.lock() = Some(saved));", + "escapes": "[GUEST] exec ESCAPED" + }, + { + "name": "amnesic-gate", + "file": "userland/capsule_vfs/src/server/handlers/persist_gate.rs", + "old": "lookup().and_then(|port| get_bool(port, Field::Persistent)) == Some(true)", + "new": "true", + "escapes": "[amnesic] the boot left" + } +] From 4cb68a7f4a0d6dc11113ebc7d599ee5137860f00 Mon Sep 17 00:00:00 2001 From: eKisNonos Date: Sun, 27 Sep 2026 10:56:51 +0000 Subject: [PATCH 103/244] docs: the store at rest is plaintext, where the docs said encrypted --- docs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs b/docs index 439e94af9d..a64ee4b0d7 160000 --- a/docs +++ b/docs @@ -1 +1 @@ -Subproject commit 439e94af9dcf887c881847f00abace03d59d8bc2 +Subproject commit a64ee4b0d73d7035a2fd81e02088656a8c17e3a8 From c4d95831c00c327a84ebb94294ef45cce97e119a Mon Sep 17 00:00:00 2001 From: eKisNonos Date: Sun, 27 Sep 2026 11:07:47 +0000 Subject: [PATCH 104/244] interrupts: a timer snapshot is spent once its own frame resumes Every user-mode tick stored the interrupted registers as the task's resume point and left them there when the tick did not switch away. A later switch that found no kernel context resumed the task from that stale point: in runG6 the bounds guest, parked in mmap, woke at the instruction after an earlier native call, still holding that call's rax, and ran code it had already left. The snapshot is now cleared when the handler returns to the frame it interrupted. --- src/interrupts/isr/timer_trampoline/handler.rs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/interrupts/isr/timer_trampoline/handler.rs b/src/interrupts/isr/timer_trampoline/handler.rs index 43fcc23cee..6305970052 100644 --- a/src/interrupts/isr/timer_trampoline/handler.rs +++ b/src/interrupts/isr/timer_trampoline/handler.rs @@ -89,6 +89,17 @@ pub(crate) extern "C" fn timer_trap_handler(ctx: *mut UserContext) { send_eoi(); crate::process::accounting::set_tick_origin(from_user); timer::on_timer_interrupt(); + /* + * Back here means this frame, not the snapshot, is what resumes: either + * no switch happened or the task came back on its kernel context. A + * snapshot left behind would later resume the task at this old rip, so a + * guest parked in a syscall woke inside code it had already left. + */ + if from_user { + if let Some(pcb) = crate::process::current_process() { + *pcb.saved_user_context.lock() = None; + } + } // Never reclaim while the interrupted context is a dying one: after // exit_and_yield tears the current process down, CURRENT_PID is cleared // and the CPU keeps looping on the dead pid's kernel stack under its From e88419b7bae9f9a0e5532476a90e46c1a8de538b Mon Sep 17 00:00:00 2001 From: eKisNonos Date: Sun, 27 Sep 2026 11:42:56 +0000 Subject: [PATCH 105/244] sched: a tick preempts user mode only The tick switched tasks whatever it interrupted. Kernel code holds plain spin locks with interrupts open, so a tick inside one handed the CPU to a task whose resume took the same lock. runG7 hung with CPU 0 spinning in switch_to_process_address_space on the paging manager, taken by the spawn it had preempted. The per-CPU preempt count existed and nothing read it; the tick now honours it, and switches only when it interrupted user mode. Kernel paths wait by yielding, which takes the request up. --- src/interrupts/timer/tick.rs | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/interrupts/timer/tick.rs b/src/interrupts/timer/tick.rs index 9c6ec61d39..3c7da606d9 100644 --- a/src/interrupts/timer/tick.rs +++ b/src/interrupts/timer/tick.rs @@ -59,7 +59,20 @@ pub fn on_timer_interrupt() { hooks::invoke_hook(); - if crate::sched::scheduler::preemption::need_reschedule() { + /* + * Only a tick that interrupted user mode may switch. Kernel code here + * holds plain spin locks with interrupts open, and a switch taken inside + * one hands the CPU to a task whose resume takes the same lock: on one + * processor that spin never ends, as runG7 hung in the paging manager. + * A kernel path waits by yielding, which picks up the pending request. + */ + let from_user = crate::smp::percpu::current() + .tick_from_user + .load(core::sync::atomic::Ordering::Relaxed); + if from_user + && crate::smp::preempt_enabled() + && crate::sched::scheduler::preemption::need_reschedule() + { crate::sched::scheduler::preemption::clear_reschedule(); if crate::process::scheduler::contract::switch( crate::process::scheduler::contract::SwitchIntent::Preempt, From 92818e85b6c883c1902366f4bcbed723900479f7 Mon Sep 17 00:00:00 2001 From: eKisNonos Date: Sun, 27 Sep 2026 11:54:09 +0000 Subject: [PATCH 106/244] assets: the phones' faces and section photographs, for the wallet Geist in three weights and JetBrains Mono, both OFL with their licences, as the iOS and Android wallets ship them. The ten Etna section photographs, sized to the wallet's 560 pixel column by tools/nonos-etna-banners from the 1290 by 860 originals, eight unique files and an index naming each section's. CREDITS.txt carries the CC BY 2.0 credit the About screen must show. --- tools/etna_png.py | 75 ++++++++++++++ tools/nonos-etna-banners | 59 +++++++++++ userland/assets/etna/CREDITS.txt | 6 ++ userland/assets/etna/etna-0b0046f5f585.png | Bin 0 -> 121526 bytes userland/assets/etna/etna-32f5aa765d0e.png | Bin 0 -> 115104 bytes userland/assets/etna/etna-387f21b9dbd2.png | Bin 0 -> 114919 bytes userland/assets/etna/etna-460f26b9c468.png | Bin 0 -> 121884 bytes userland/assets/etna/etna-4bb30d031341.png | Bin 0 -> 101228 bytes userland/assets/etna/etna-90380f7829df.png | Bin 0 -> 61483 bytes userland/assets/etna/etna-a0754afa8556.png | Bin 0 -> 101324 bytes userland/assets/etna/etna-fcb6fdda9325.png | Bin 0 -> 40956 bytes userland/assets/etna/index.txt | 10 ++ userland/assets/fonts/Geist-Medium.ttf | Bin 0 -> 127660 bytes userland/assets/fonts/Geist-OFL.txt | 93 ++++++++++++++++++ userland/assets/fonts/Geist-Regular.ttf | Bin 0 -> 126048 bytes userland/assets/fonts/Geist-SemiBold.ttf | Bin 0 -> 127872 bytes userland/assets/fonts/JetBrainsMono-OFL.txt | 93 ++++++++++++++++++ .../assets/fonts/JetBrainsMono-Regular.ttf | Bin 0 -> 273900 bytes 18 files changed, 336 insertions(+) create mode 100644 tools/etna_png.py create mode 100755 tools/nonos-etna-banners create mode 100644 userland/assets/etna/CREDITS.txt create mode 100644 userland/assets/etna/etna-0b0046f5f585.png create mode 100644 userland/assets/etna/etna-32f5aa765d0e.png create mode 100644 userland/assets/etna/etna-387f21b9dbd2.png create mode 100644 userland/assets/etna/etna-460f26b9c468.png create mode 100644 userland/assets/etna/etna-4bb30d031341.png create mode 100644 userland/assets/etna/etna-90380f7829df.png create mode 100644 userland/assets/etna/etna-a0754afa8556.png create mode 100644 userland/assets/etna/etna-fcb6fdda9325.png create mode 100644 userland/assets/etna/index.txt create mode 100644 userland/assets/fonts/Geist-Medium.ttf create mode 100644 userland/assets/fonts/Geist-OFL.txt create mode 100644 userland/assets/fonts/Geist-Regular.ttf create mode 100644 userland/assets/fonts/Geist-SemiBold.ttf create mode 100644 userland/assets/fonts/JetBrainsMono-OFL.txt create mode 100644 userland/assets/fonts/JetBrainsMono-Regular.ttf diff --git a/tools/etna_png.py b/tools/etna_png.py new file mode 100644 index 0000000000..e0e6dd3dc0 --- /dev/null +++ b/tools/etna_png.py @@ -0,0 +1,75 @@ +# NONOS Operating System +# Copyright (C) 2026 NONOS Contributors +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +"""Just enough PNG for the Etna photographs: 8-bit RGB in, box filter, RGB out.""" + +import struct +import sys +import zlib + + +def decode(path): + data = path.read_bytes() + w, h, depth, kind, _, _, lace = struct.unpack(">IIBBBBB", data[16:29]) + if (depth, kind, lace) != (8, 2, 0): + sys.exit(f"{path}: want 8-bit RGB without interlace") + raw, i = b"", 8 + while i < len(data): + n, tag = struct.unpack(">I4s", data[i:i + 8]) + raw += data[i + 8:i + 8 + n] if tag == b"IDAT" else b"" + i += 12 + n + return w, h, unfilter(zlib.decompress(raw), w, h) + + +def unfilter(raw, w, h): + stride, prev, rows, o = w * 3, bytearray(w * 3), [], 0 + for _ in range(h): + f, line = raw[o], bytearray(raw[o + 1:o + 1 + stride]) + o += 1 + stride + for x in range(stride): + a = line[x - 3] if x >= 3 else 0 + b, c = prev[x], prev[x - 3] if x >= 3 else 0 + p = a + b - c + pred = [0, a, b, (a + b) // 2, + a if abs(p - a) <= abs(p - b) and abs(p - a) <= abs(p - c) else b if abs(p - b) <= abs(p - c) else c][f] + line[x] = (line[x] + pred) & 255 + rows.append(bytes(line)) + prev = line + return rows + + +def shrink(w, h, rows, out_w): + out_h = round(out_w * h / w) + out = [] + for y in range(out_h): + y0, y1 = y * h // out_h, max((y + 1) * h // out_h, y * h // out_h + 1) + line = bytearray() + for x in range(out_w): + x0, x1 = x * w // out_w, max((x + 1) * w // out_w, x * w // out_w + 1) + n, acc = (y1 - y0) * (x1 - x0), [0, 0, 0] + for r in rows[y0:y1]: + for xx in range(x0, x1): + for c in range(3): + acc[c] += r[xx * 3 + c] + line += bytes(v // n for v in acc) + out.append(bytes(line)) + return out_w, out_h, out + + +def encode(w, h, rows): + chunk = lambda t, d: struct.pack(">I", len(d)) + t + d + struct.pack(">I", zlib.crc32(t + d)) + body = zlib.compress(b"".join(b"\0" + r for r in rows), 9) + return (b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", struct.pack(">IIBBBBB", w, h, 8, 2, 0, 0, 0)) + + chunk(b"IDAT", body) + chunk(b"IEND", b"")) diff --git a/tools/nonos-etna-banners b/tools/nonos-etna-banners new file mode 100755 index 0000000000..1e25895bbd --- /dev/null +++ b/tools/nonos-etna-banners @@ -0,0 +1,59 @@ +#!/usr/bin/env python3 +# NONOS Operating System +# Copyright (C) 2026 NONOS Contributors +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +"""The wallet's section photographs, sized for its column, from the phone art. + +Reads design/etna/-header.png (1290 by 860, 8-bit RGB) from an Etna-iOS +checkout, box-filters each to --width keeping the 1290:860 aspect, and writes +RGB PNGs the capsule decodes as they are. Standard library only, so the art +can be regenerated anywhere; identical photos are written once and named in +the index the capsule reads. +""" + +import argparse +import hashlib +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) +from etna_png import decode, encode, shrink # noqa: E402 + +NAMES = ["welcome", "home", "send", "receive", "deposit", "withdraw", "proving", + "history", "settings", "backup"] + + +def main(): + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("etna", type=Path, help="an Etna-iOS checkout") + ap.add_argument("out", type=Path, help="where the sized photographs go") + ap.add_argument("--width", type=int, default=560) + a = ap.parse_args() + a.out.mkdir(parents=True, exist_ok=True) + index = [] + for name in NAMES: + src = a.etna / "design/etna" / f"{name}-header.png" + digest = hashlib.sha256(src.read_bytes()).hexdigest()[:12] + dst = a.out / f"etna-{digest}.png" + if not dst.exists(): + dst.write_bytes(encode(*shrink(*decode(src), a.width))) + index.append(f"{name} {dst.name}") + print(f"[etna] {name} -> {dst.name} ({dst.stat().st_size} bytes)") + (a.out / "index.txt").write_text("\n".join(index) + "\n") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/userland/assets/etna/CREDITS.txt b/userland/assets/etna/CREDITS.txt new file mode 100644 index 0000000000..56f1ebc889 --- /dev/null +++ b/userland/assets/etna/CREDITS.txt @@ -0,0 +1,6 @@ +Photograph: "Etna Volcano Paroxysmal Eruption July 30 2011" by gnuckx, CC BY 2.0 +https://commons.wikimedia.org/wiki/File:Etna_Volcano_Paroxysmal_Eruption_July_30_2011_-_Creative_Commons_by_gnuckx_(4).jpg +Licence: https://creativecommons.org/licenses/by/2.0/ +Changes: recoloured to the NØNOS palette (Ink, Deep Teal, NØNOS Cyan), cropped, NØNOS logo added. +Wherever an image is published, carry this credit line: +"Photo: gnuckx, CC BY 2.0, recoloured by NØNOS" diff --git a/userland/assets/etna/etna-0b0046f5f585.png b/userland/assets/etna/etna-0b0046f5f585.png new file mode 100644 index 0000000000000000000000000000000000000000..bf3641e094749d207ebdfd81d91d08043bb89637 GIT binary patch literal 121526 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

AYAUX|(96N% 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

OXdu6f<~n+A!9mfq`>WZoRJ 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

}| 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