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/.github/workflows/verify.yml b/.github/workflows/verify.yml index 68f65b4f4d..624519cf01 100644 --- a/.github/workflows/verify.yml +++ b/.github/workflows/verify.yml @@ -115,6 +115,18 @@ 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 + - 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 + - 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 @@ -343,6 +355,8 @@ jobs: - audio_proto_proofs - capsule_crypto_proofs - aes_proofs + - capsule_linux_proofs + - market_proofs steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v4.2.2 with: diff --git a/.keys/app_store_publisher_ed25519.pub b/.keys/app_store_publisher_ed25519.pub new file mode 100644 index 0000000000..38557b00d3 Binary files /dev/null and b/.keys/app_store_publisher_ed25519.pub differ diff --git a/.keys/app_store_publisher_mldsa65.pub b/.keys/app_store_publisher_mldsa65.pub new file mode 100644 index 0000000000..09606e55ff Binary files /dev/null and b/.keys/app_store_publisher_mldsa65.pub differ diff --git a/.keys/marketplace_operator_ed25519.pub b/.keys/marketplace_operator_ed25519.pub new file mode 100644 index 0000000000..604f50c19e --- /dev/null +++ b/.keys/marketplace_operator_ed25519.pub @@ -0,0 +1,2 @@ +)_„É|b/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/abi/syscalls.toml b/abi/syscalls.toml index 3a8695a235..420061faea 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" @@ -72,6 +79,9 @@ MFSP = 0x5053464D MFST = 0x5453464D MFWT = 0x5457464D MFRP = 0x5052464D +MFCX = 0x5843464D +MFSG = 0x4753464D +MFIN = 0x4E49464D MPMP = 0x504D504D MPCP = 0x5043504D MPPT = 0x5450504D @@ -84,6 +94,10 @@ MLSG = 0x47534C4D MLVF = 0x46564C4D MAIN = 0x4E49414D MDRO = 0x4F52444D +MLCG = 0x47434C4D +MLCR = 0x52434C4D +MAPL = 0x4C50414D +MAIS = 0x5349414D MIRW = 0x5752494D MIRY = 0x5952494D MKAR = 0x52414B4D @@ -622,6 +636,24 @@ caps = ["ForeignExec"] args = [{name="pid",type="u32",dir="in"},{name="value",type="u64",dir="in"}] ret = {type="i64"} +[desc.MFCX] +nr = 0x5843464D +caps = ["ForeignExec"] +args = [{name="pid",type="u32",dir="in"},{name="out",type="u64*",dir="out"}] +ret = {type="i64"} + +[desc.MFSG] +nr = 0x4753464D +caps = ["ForeignExec"] +args = [{name="pid",type="u32",dir="in"},{name="regs",type="u64*",dir="in"},{name="kind",type="u64",dir="in"}] +ret = {type="i64"} + +[desc.MFIN] +nr = 0x4E49464D +caps = ["ForeignExec"] +args = [{name="pid",type="u32",dir="in"}] +ret = {type="i64"} + [desc.MPMP] nr = 0x504D504D caps = ["ForeignExec"] @@ -637,7 +669,7 @@ ret = {type="i64"} [desc.MFTH] nr = 0x4854464D caps = ["ForeignExec"] -args = [{name="pid",type="u32",dir="in"},{name="entry",type="u64",dir="in"},{name="rsp",type="u64",dir="in"},{name="tls",type="u64",dir="in"}] +args = [{name="pid",type="u32",dir="in"},{name="entry",type="u64",dir="in"},{name="rsp",type="u64",dir="in"},{name="tls",type="u64",dir="in"},{name="from",type="u32",dir="in"}] ret = {type="i64"} [desc.MPTL] @@ -679,7 +711,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] @@ -688,6 +720,30 @@ 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.MAIS] +nr = 0x5349414D +caps = ["AppInstall"] +args = [{name="listing_ptr",type="u64",dir="in"},{name="listing_len",type="u64",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/build.rs b/build.rs index 5d879e56cb..adb0ba30ba 100644 --- a/build.rs +++ b/build.rs @@ -570,7 +570,11 @@ fn rerun_on_capsule_binaries() { /// the whole space uniformly and needs neither. fn c_target(arch: &str) -> Option<(&'static str, &'static [&'static str])> { match arch { - "x86_64" => Some(("x86_64-unknown-none-elf", &["-mno-red-zone", "-mcmodel=kernel"][..])), + // No vector registers: kernel code runs before a thread's are saved. + "x86_64" => Some(( + "x86_64-unknown-none-elf", + &["-mno-red-zone", "-mcmodel=kernel", "-mno-mmx", "-mno-sse", "-mno-sse2", "-mno-avx"][..], + )), "aarch64" => Some(("aarch64-unknown-none-elf", &[][..])), "riscv64" => Some(("riscv64-unknown-none-elf", &[][..])), _ => None, diff --git a/docs b/docs index 8672acfa8b..a64ee4b0d7 160000 --- a/docs +++ b/docs @@ -1 +1 @@ -Subproject commit 8672acfa8bcc482ff37268fc52fd5e93834f4f0c +Subproject commit a64ee4b0d73d7035a2fd81e02088656a8c17e3a8 diff --git a/mk/10-qemu.mk b/mk/10-qemu.mk index e0334a47da..5ba4b396c4 100644 --- a/mk/10-qemu.mk +++ b/mk/10-qemu.mk @@ -44,17 +44,47 @@ 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 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 # Options for the intel-iommu device the IOMMU lane adds; a knob like the # others so the lane can be driven from the command line. QEMU_IOMMU_OPTS ?= intremap=on,caching-mode=on +# A virtio device uses the vIOMMU only with iommu_platform=on, and only then is +# VIRTIO_F_ACCESS_PLATFORM offered. Without it the device addresses memory +# physically and the lane tests nothing about it. disable-legacy=on because a +# legacy driver cannot take bit 33: it fails to bind instead of bypassing. +QEMU_IOMMU_VIRTIO ?= iommu_platform=on,disable-legacy=on +_iv := $(_boot_comma)$(QEMU_IOMMU_VIRTIO)$(_boot_comma) +iommu_virtio = $(foreach d,virtio-blk-pci virtio-net-pci virtio-rng-pci virtio-vga virtio-vga-gl,\ + $(eval _iommu_args := $(patsubst $(d),$(d)$(_boot_comma)$(QEMU_IOMMU_VIRTIO),\ + $(subst $(d)$(_boot_comma),$(d)$(_iv),$(_iommu_args)))))$(_iommu_args) +iommu_virtio_args = $(eval _iommu_args := $(subst virtio-vga$(_boot_comma)disable-modern=on,virtio-vga,$(1)))$(call iommu_virtio) QEMU_BLK_IMG := $(TARGET_DIR)/qemu-virtio-blk.img QEMU_OVMF_VARS_RW := $(TARGET_DIR)/qemu-OVMF_VARS.fd QEMU_BLK := -drive "file=$(QEMU_BLK_IMG),if=none,id=vd0,format=raw" -device virtio-blk-pci,drive=vd0 @@ -84,19 +114,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..ae5e484bed 100644 --- a/mk/20-build.mk +++ b/mk/20-build.mk @@ -559,6 +559,7 @@ include userland/toolkit/Capsule.mk include userland/capsule_about/Capsule.mk include userland/capsule_install/Capsule.mk include userland/tool_install/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 @@ -618,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. @@ -632,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 @@ -794,6 +800,21 @@ $(MARKETPLACE_INDEX_TOOL): nonos-mk-marketplace-index-tool: $(MARKETPLACE_INDEX_TOOL) +# The catalogue the market capsule embeds. Signed and verified here when the +# operator seed is present; empty otherwise, which the capsule reads as no +# baseline. The serial is the commit time, so a later build never publishes +# an index older than one already installed. +MARKET_OPERATOR_SEED := .keys/marketplace_operator_ed25519.seed +MARKET_OPERATOR_PUB := .keys/marketplace_operator_ed25519.pub +MARKET_LINUX_LIST := userland/capsule_market/linux-packages.txt +MARKET_INDEX_BIN := $(TARGET_DIR)/market/index.bin + +$(MARKET_INDEX_BIN): $(MARKETPLACE_INDEX_TOOL) $(MARKET_OPERATOR_PUB) $(MARKET_LINUX_LIST) \ + tools/nonos-market-index tools/nonos-market-catalogue $(wildcard $(MARKET_OPERATOR_SEED)) + @$(NONOS_PYTHON) tools/nonos-market-index --out $@ --cli $(MARKETPLACE_INDEX_TOOL) \ + --seed $(MARKET_OPERATOR_SEED) --pubkey $(MARKET_OPERATOR_PUB) \ + --linux-list $(MARKET_LINUX_LIST) --serial $$(git log -1 --format=%ct) + # Generate the four signed fixtures the kernel-side market smoke # embeds. Depends on the host marketplace-index CLI. The trusted # seed is `0x42`-repeated-32 (publicly known); the matching @@ -851,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 @@ -1002,7 +1024,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) \ @@ -1144,8 +1166,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 @@ -1165,10 +1187,18 @@ DESKTOP_GUI_CAPSULE_ARTIFACTS := $(DESKTOP_BASE_CAPSULE_ARTIFACTS) \ $(DESKTOP_STD_TOOL_ARTIFACTS) \ $(ZK_POLICY_ROOT) +# A Linux-guest test image boots unattended, and first-boot setup waits for +# keys nobody presses. Under the setup profile the apps, the Linux personality +# among them, spawn only once setup exits, so that image would never start its +# guest. It builds the desktop profile without first-boot setup instead. 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) +ifeq ($(NONOS_LINUX_GUESTS),1) + $(call nonos_kernel_build,microkernel-desktop-gui + nonos-stark-attest (unattended guest test),microkernel-desktop-gui$(_boot_comma)nonos-stark-attest) +else + $(call nonos_kernel_build,microkernel-setup-wizard + nonos-stark-attest,microkernel-setup-wizard$(_boot_comma)nonos-stark-attest) +endif # 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 @@ -1224,6 +1254,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 @@ -1234,7 +1267,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) \ 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..d493f2f684 100644 --- a/mk/40-run.mk +++ b/mk/40-run.mk @@ -33,8 +33,23 @@ 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) - @$(NONOS_PYTHON) tools/nonos-store-pack --image $(QEMU_BLK_IMG) --lba 256 \ +# 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). +# The demo capsules the desktop offers from the store. Grouped so the +# Linux-guest test image, which packs its own large signed set, can leave +# them out and stay inside the vfs load budget (Guests.mk empties this). +NONOS_STORE_DEMO_ENTRIES := \ --entry /capsules/std_proof.elf=$(std-proof_BIN) \ --entry /capsules/std_proof.nonos_id_cert.bin=$(std-proof_CERT) \ --entry /capsules/std_proof.manifest.bin=$(std-proof_MANIFEST) \ @@ -50,13 +65,13 @@ $(QEMU_BLK_STORE_STAMP): $(std-proof_ARTIFACTS) $(gui_demo_ARTIFACTS) $(game_204 --entry /capsules/egui_proof.elf=$(egui_proof_BIN) \ --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 + --entry /capsules/egui_proof.zk_trailer.bin=$(egui_proof_ATTESTATION) + +$(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 \ + $(NONOS_STORE_DEMO_ENTRIES) \ + $(NONOS_STORE_MEDIA_ENTRIES) \ + $(LINUX_GUEST_STORE_ENTRIES) @touch $@ # Declared in mk/20-build.mk; this only extends its prerequisites. @@ -96,7 +111,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 +168,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 +190,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 +208,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 +219,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) \ @@ -331,9 +346,23 @@ 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. +# 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) + @$(NONOS_PYTHON) tools/nonos-proof-coverage --baseline scripts/baselines/proof-coverage.txt + MICROKERNEL_BIN := $(TARGET_DIR)/x86_64-nonos/release/nonos-kernel # Patterns are matched against demangled `nm` output, so each entry is @@ -393,7 +422,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 +455,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 +468,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" \ @@ -467,7 +496,7 @@ nonos-mk-run-iommu-serial-log: nonos-mk-desktop-gui-prod $(QEMU_BLK_IMG) $(QEMU_ -drive "format=raw,file=fat:rw:$(ESP_DIR)" \ -drive if=pflash,format=raw,readonly=on,file="$(OVMF)" \ -drive if=pflash,format=raw,unit=1,file="$(QEMU_OVMF_VARS_RW)" \ - $(QEMU_BLK) $(QEMU_GPU) $(QEMU_NET) $(QEMU_USB) $(QEMU_RNG) \ + $(call iommu_virtio_args,$(QEMU_BLK) $(QEMU_GPU) $(QEMU_NET) $(QEMU_RNG)) $(QEMU_USB) \ -serial "file:$(QEMU_IOMMU_SERIAL_LOG)" -display none -no-reboot # The machine matrix. The shipping images (single CPU, and the same tree with 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-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-mk b/nonos-mk index 428ded0a71..c451691557 160000 --- a/nonos-mk +++ b/nonos-mk @@ -1 +1 @@ -Subproject commit 428ded0a713af173beeb92b52fd226ba71932eab +Subproject commit c451691557f9fcd9ff34aaa197518a827e4154bb 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", ] 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/nonos-verify/src/build.rs b/nonos-verify/src/build.rs index 4e7ec98c14..cddca6fff4 100644 --- a/nonos-verify/src/build.rs +++ b/nonos-verify/src/build.rs @@ -41,6 +41,31 @@ 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", + ); + + // 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/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/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/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/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/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) 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)) 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/arch/x86_64/boot/validation/sse_avx.rs b/src/arch/x86_64/boot/validation/sse_avx.rs index 9ac2e12283..12e0dc7e1c 100644 --- a/src/arch/x86_64/boot/validation/sse_avx.rs +++ b/src/arch/x86_64/boot/validation/sse_avx.rs @@ -56,5 +56,8 @@ pub unsafe fn enable_sse_avx() -> Result<(), BootError> { enable_sse()?; enable_avx()?; enable_avx512()?; + // SAFETY: eK@nonos.systems - boot CPU, after its components are enabled + // and before any thread exists, which is `record_boot`'s contract. + unsafe { crate::arch::x86_64::cpu::xstate::record_boot() }; Ok(()) } diff --git a/src/arch/x86_64/cpu/mod.rs b/src/arch/x86_64/cpu/mod.rs index 0c079d2921..989c94ce6d 100644 --- a/src/arch/x86_64/cpu/mod.rs +++ b/src/arch/x86_64/cpu/mod.rs @@ -44,8 +44,10 @@ mod msr_safe; pub mod msr_stats; pub mod per_cpu; pub mod state; +pub mod xstate; mod state_getters; pub mod state_globals; +mod state_features; mod state_init; mod state_stats; pub mod thermal; @@ -67,6 +69,7 @@ pub use cpuid::{cpuid, cpuid_count, cpuid_max_extended_leaf, cpuid_max_leaf}; pub use cpuid_stats::increment_calls; pub use error::CpuError; pub use features::CpuFeatures; +pub use state_features::detect_features; pub use frequency::{core_frequency, tsc_frequency}; pub use frequency_cpuid::{detect_frequency_cpuid_16h, detect_tsc_frequency_cpuid_15h}; pub use frequency_pit::calibrate_tsc_with_pit; diff --git a/src/arch/x86_64/cpu/state_features.rs b/src/arch/x86_64/cpu/state_features.rs new file mode 100644 index 0000000000..700cfb9de2 --- /dev/null +++ b/src/arch/x86_64/cpu/state_features.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 . + +//! The feature cache on its own, for the one boot step that needs it before +//! the rest of `init`: turning on SSE, AVX and XSAVE. CPUID only; no timer is +//! touched, so this is safe as early as it is called. + +use super::features::CpuFeatures; +use super::state_globals::CPU_FEATURES; + +/// Fill the feature cache from CPUID. Idempotent; `init` fills it again later. +pub fn detect_features() { + let found = CpuFeatures::detect(); + // SAFETY: eK@nonos.systems - called on the boot CPU before any other CPU + // or thread runs, so nothing reads the cache while it is written. + unsafe { CPU_FEATURES = found }; +} diff --git a/src/arch/x86_64/cpu/xstate.rs b/src/arch/x86_64/cpu/xstate.rs new file mode 100644 index 0000000000..a474047600 --- /dev/null +++ b/src/arch/x86_64/cpu/xstate.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 . + +//! Extended processor state: the components the boot CPU enabled and the area +//! size for them. FXSAVE covers x87 and SSE only: with AVX on it dropped the upper ymm halves +//! (zmm and opmask on AVX-512), so threads sharing a CPU corrupted each other. + +use crate::arch::x86_64::boot::constants::{CR4_OSXSAVE, XCR0_AVX, XCR0_SSE, XCR0_X87}; +use crate::arch::x86_64::boot::cpu_ops::{cpuid_count, read_cr4, read_xcr0, write_cr4, write_xcr0}; +use core::sync::atomic::{AtomicBool, AtomicU64, Ordering}; + +/// Bytes in every thread's area, 64-byte aligned by its owner. +pub const AREA: usize = 4096; +static XSAVE: AtomicBool = AtomicBool::new(false); +static XCR0: AtomicU64 = AtomicU64::new(0); + +// CPUID.(0DH,0).EBX: the save area size for the components XCR0 enables now. +pub fn needed() -> usize { + cpuid_count(0x0D, 0).1 as usize +} + +/// Boot CPU, after SSE/AVX bring-up: what does not fit AREA is turned off. +/// +/// # Safety +/// Runs once, on the boot CPU, before any thread is created. +pub unsafe fn record_boot() { + if read_cr4() & CR4_OSXSAVE == 0 { + return; + } + for fallback in [XCR0_X87 | XCR0_SSE | XCR0_AVX, XCR0_X87 | XCR0_SSE] { + if needed() <= AREA { + break; + } + // SAFETY: eK@nonos.systems - OSXSAVE is set, checked above; both keep x87 and SSE. + unsafe { write_xcr0(read_xcr0() & fallback) }; + } + XCR0.store(read_xcr0(), Ordering::Release); + XSAVE.store(needed() <= AREA, Ordering::Release); +} + +/// On each AP before it runs a thread: the boot CPU's components. +/// +/// # Safety +/// Runs once per AP during its bring-up, with interrupts off. +pub unsafe fn mirror_on_ap() { + if !XSAVE.load(Ordering::Acquire) { + return; + } + // SAFETY: eK@nonos.systems - the boot CPU has XSAVE and every CPU the same features. + unsafe { + write_cr4(read_cr4() | CR4_OSXSAVE); + write_xcr0(XCR0.load(Ordering::Acquire)); + } +} + +pub fn uses_xsave() -> bool { + XSAVE.load(Ordering::Acquire) +} + +pub fn enabled() -> u64 { + XCR0.load(Ordering::Acquire) +} diff --git a/src/arch/x86_64/diag/dump_trap.rs b/src/arch/x86_64/diag/dump_trap.rs index 310a2569ea..ad46181cbc 100644 --- a/src/arch/x86_64/diag/dump_trap.rs +++ b/src/arch/x86_64/diag/dump_trap.rs @@ -61,4 +61,7 @@ pub fn dump_trap(name: &[u8], frame: &InterruptStackFrame, err: Option, cr2 print_hex_u64(c); } crate::sys::serial::println(b""); + if let (3, Some(c)) = (cpl, cr2) { + super::walk_fault::print_walk(cr3, c); + } } diff --git a/src/arch/x86_64/diag/mod.rs b/src/arch/x86_64/diag/mod.rs index 1924ce0352..b45da55eb8 100644 --- a/src/arch/x86_64/diag/mod.rs +++ b/src/arch/x86_64/diag/mod.rs @@ -21,6 +21,7 @@ mod fatal_notice; mod print_hex; #[cfg(feature = "nonos-user-entry-proof")] mod user_proof; +mod walk_fault; pub use dump_gdt::dump_gdt; pub use dump_trap::dump_trap; diff --git a/src/arch/x86_64/diag/walk_fault.rs b/src/arch/x86_64/diag/walk_fault.rs new file mode 100644 index 0000000000..616f2bf0c5 --- /dev/null +++ b/src/arch/x86_64/diag/walk_fault.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 four page-table entries that translate a faulting user address, +//! read through the directmap. A fault report with only cr2 and the error +//! code says what the CPU saw; the entries say which table and which +//! frame it saw it in. + +use super::print_hex::print_hex_u64; +use crate::memory::layout::DIRECTMAP_BASE; + +const PRESENT: u64 = 1 << 0; +const HUGE: u64 = 1 << 7; +const PHYS_MASK: u64 = 0x000F_FFFF_FFFF_F000; + +pub(super) fn print_walk(cr3: u64, va: u64) { + let shifts = [39u32, 30, 21, 12]; + let mut table = cr3 & PHYS_MASK; + crate::sys::serial::print(b"[TRAP WALK]"); + for (level, shift) in shifts.iter().enumerate() { + let index = (va >> shift) & 0x1FF; + let slot = (DIRECTMAP_BASE + table + index * 8) as *const u64; + // SAFETY: eK@nonos.systems - `table` is a page-table frame taken + // from cr3 or a present entry above it, so the directmap maps it, + // and `index` is below 512, inside that 4 KiB frame. + let entry = unsafe { core::ptr::read_volatile(slot) }; + crate::sys::serial::print(b" l"); + crate::sys::serial::print(&[b'4' - level as u8]); + crate::sys::serial::print(b"="); + print_hex_u64(entry); + if entry & PRESENT == 0 || (level > 0 && level < 3 && entry & HUGE != 0) { + break; + } + table = entry & PHYS_MASK; + } + crate::sys::serial::println(b""); +} 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/mod.rs b/src/arch/x86_64/iommu/globals/mod.rs index 3c6840c19e..d05fcb7aac 100644 --- a/src/arch/x86_64/iommu/globals/mod.rs +++ b/src/arch/x86_64/iommu/globals/mod.rs @@ -19,6 +19,7 @@ mod is_enforcing; mod is_present; mod page_levels; mod set_present; +mod snoop_control; pub(super) mod state; pub use allocate_domain_id::allocate_domain_id; @@ -26,3 +27,4 @@ pub use is_enforcing::{is_enforcing, set_enforcing}; pub use is_present::is_present; pub use page_levels::{page_levels, set_page_levels}; pub use set_present::set_present; +pub use snoop_control::{set_snoop_control, snoop_control}; diff --git a/src/arch/x86_64/iommu/globals/snoop_control.rs b/src/arch/x86_64/iommu/globals/snoop_control.rs new file mode 100644 index 0000000000..776e7e72a0 --- /dev/null +++ b/src/arch/x86_64/iommu/globals/snoop_control.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 . + +use core::sync::atomic::Ordering; + +use super::state::SNOOP_CONTROL; + +/* + * ECAP.SC. Bit 11 of a second-level leaf asks the unit to snoop CPU caches, + * and is a reserved bit on a unit that does not report snoop control: every + * access through such an entry faults (reason 0xC) instead of reaching memory. + */ +pub fn snoop_control() -> bool { + SNOOP_CONTROL.load(Ordering::Acquire) +} + +// Record what the probed unit reported. Set once, before any table is built. +pub fn set_snoop_control(ecap: u64) { + SNOOP_CONTROL.store(ecap & (1 << 7) != 0, Ordering::Release); +} diff --git a/src/arch/x86_64/iommu/globals/state.rs b/src/arch/x86_64/iommu/globals/state.rs index ac6e79cc8e..08a6d77fd3 100644 --- a/src/arch/x86_64/iommu/globals/state.rs +++ b/src/arch/x86_64/iommu/globals/state.rs @@ -14,7 +14,7 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -use core::sync::atomic::{AtomicBool, AtomicU8, AtomicU64}; +use core::sync::atomic::{AtomicBool, AtomicU64, AtomicU8}; use spin::Mutex; use super::super::types::{DomainId, SourceId, MAX_VTD_DOMAINS}; @@ -55,10 +55,10 @@ pub(crate) static DMAR_PRESENT: AtomicBool = AtomicBool::new(false); /// being confined by it, which is a different and much stronger claim. pub(crate) static ENFORCING: AtomicBool = AtomicBool::new(false); pub(crate) static PAGE_LEVELS: AtomicU8 = AtomicU8::new(0); +pub(crate) static SNOOP_CONTROL: AtomicBool = AtomicBool::new(false); /// Physical address of the one root table every unit is pointed at, or zero /// before it exists. Shared rather than per-unit: a device appears behind /// 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..423b2a03a4 100644 --- a/src/arch/x86_64/iommu/mapping/map_identity.rs +++ b/src/arch/x86_64/iommu/mapping/map_identity.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 . +use crate::arch::x86_64::iommu::globals::snoop_control; use crate::arch::x86_64::iommu::tables::frame::entries_mut; use crate::arch::x86_64::iommu::tables::sl_pte::{leaf, level_span, SL_LARGE}; +use crate::arch::x86_64::iommu::tables::touched::Touched; use crate::arch::x86_64::iommu::tables::walk::walk_create_to; use crate::arch::x86_64::iommu::types::VtdError; @@ -41,10 +43,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; + entries_mut(slot.table_phys)?[slot.index] = leaf(addr, true, true, snoop_control()) | 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..effe74a250 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::globals::{is_enforcing, page_levels, snoop_control}; 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); + leaf(phys + offset, flags.read, flags.write, flags.snoop && snoop_control()); + 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/arch/x86_64/iommu/unit/bringup/run.rs b/src/arch/x86_64/iommu/unit/bringup/run.rs index 2d6df7e67a..8061af295b 100644 --- a/src/arch/x86_64/iommu/unit/bringup/run.rs +++ b/src/arch/x86_64/iommu/unit/bringup/run.rs @@ -16,7 +16,7 @@ use super::assign::assign_enumerated; use super::domain::identity_domain; -use crate::arch::x86_64::iommu::globals::{set_enforcing, set_page_levels}; +use crate::arch::x86_64::iommu::globals::{set_enforcing, set_page_levels, set_snoop_control}; use crate::arch::x86_64::iommu::tables::root::root_table; use crate::arch::x86_64::iommu::types::VtdError; use crate::arch::x86_64::iommu::unit::enable::bring_into_service; @@ -32,6 +32,7 @@ pub fn bring_up() -> Result { let levels = info.levels.page_table_levels(); set_page_levels(levels); + set_snoop_control(info.ecap); let root = root_table()?; let (domain, sl_root) = identity_domain(levels, info.cap)?; diff --git a/src/crypto/util/rng/entropy/collect/mod.rs b/src/crypto/util/rng/entropy/collect/mod.rs index 56c5130471..e8d660e328 100644 --- a/src/crypto/util/rng/entropy/collect/mod.rs +++ b/src/crypto/util/rng/entropy/collect/mod.rs @@ -16,10 +16,12 @@ mod get; mod init; +mod pool; mod seed; pub use get::{get_entropy64, get_entropy64_secure, get_tsc_entropy}; pub use init::{ has_adequate_entropy, init_entropy, mark_bootloader_entropy_provided, verify_entropy_sources, }; -pub use seed::{collect_seed_entropy, collect_seed_entropy_secure, mix_entropy_into_seed}; +pub use pool::collect_seed_entropy_secure; +pub use seed::{collect_seed_entropy, mix_entropy_into_seed}; diff --git a/src/crypto/util/rng/entropy/collect/pool.rs b/src/crypto/util/rng/entropy/collect/pool.rs new file mode 100644 index 0000000000..9d50333713 --- /dev/null +++ b/src/crypto/util/rng/entropy/collect/pool.rs @@ -0,0 +1,74 @@ +// 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 entropy source there is, hashed into one seed. +//! +//! A seed from one source is only as good as that source. virtio-rng, RDSEED +//! and RDRAND are each drawn when present and all go into one SHA-256 with +//! cycle-counter jitter and this CPU's stack and counter, so a weak source is +//! covered by the others. It fails closed below 32 bytes from hardware. + +use alloc::vec::Vec; + +use super::super::error::EntropyError; +use super::super::hardware::{cpu_entropy64, cpu_random64, read_cycle_counter}; +use super::super::state::ENTROPY_COUNTER; +use crate::crypto::hash::sha256; +use crate::drivers::virtio_rng; +use core::sync::atomic::Ordering; + +const DOMAIN: &[u8] = b"NONOS seed pool v1"; +const WORDS: usize = 4; +const JITTER: usize = 16; + +pub fn collect_seed_entropy_secure() -> Result<[u8; 32], EntropyError> { + let mut pool = Vec::with_capacity(256); + pool.extend_from_slice(DOMAIN); + let mut hardware = 0usize; + let mut device = [0u8; 32]; + if virtio_rng::is_available() && virtio_rng::fill_random(&mut device).is_ok() { + pool.extend_from_slice(&device); + hardware += device.len(); + } + for source in [cpu_entropy64 as fn() -> Option, cpu_random64] { + for _ in 0..WORDS { + if let Some(v) = source() { + pool.extend_from_slice(&v.to_le_bytes()); + hardware += 8; + } + } + } + for _ in 0..JITTER { + let t1 = read_cycle_counter(); + for _ in 0..((t1 & 0x1F) + 1) { + core::hint::spin_loop(); + } + pool.extend_from_slice(&read_cycle_counter().wrapping_sub(t1).to_le_bytes()); + } + let counter = ENTROPY_COUNTER.fetch_add(0xA7B3_C5D9_E1F4_2680, Ordering::SeqCst); + pool.extend_from_slice(&crate::arch::stack_pointer().to_le_bytes()); + pool.extend_from_slice(&counter.to_le_bytes()); + let seed = sha256(&pool); + for b in pool.iter_mut() { + // SAFETY: `b` is a live, exclusively borrowed byte of `pool`. + unsafe { core::ptr::write_volatile(b, 0) }; + } + // Jitter and addresses stir the pool; they are never counted as entropy. + if hardware < 32 { + return Err(EntropyError::InsufficientEntropy); + } + Ok(seed) +} diff --git a/src/crypto/util/rng/entropy/collect/seed.rs b/src/crypto/util/rng/entropy/collect/seed.rs index 97c3a80567..a650874715 100644 --- a/src/crypto/util/rng/entropy/collect/seed.rs +++ b/src/crypto/util/rng/entropy/collect/seed.rs @@ -14,81 +14,10 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -use super::super::error::EntropyError; -use super::super::hardware::{cpu_entropy64, cpu_random64, read_cycle_counter}; -use super::super::state::ENTROPY_COUNTER; +use super::super::hardware::read_cycle_counter; use super::get::get_entropy64; -use crate::drivers::virtio_rng; -use core::sync::atomic::Ordering; -pub fn collect_seed_entropy_secure() -> Result<[u8; 32], EntropyError> { - let mut seed = [0u8; 32]; - if virtio_rng::is_available() { - if virtio_rng::fill_random(&mut seed).is_ok() { - return Ok(seed); - } - } - // Count only bytes that came from a hardware entropy source. The TSC/stack - // fallback below must never be accepted as a real seed. - let mut hw_bytes = 0usize; - let mut offset = 0; - while offset < 32 { - if let Some(v) = cpu_entropy64() { - let len = core::cmp::min(8, 32 - offset); - seed[offset..offset + len].copy_from_slice(&v.to_le_bytes()[..len]); - offset += len; - hw_bytes += len; - } else { - break; - } - } - while offset < 32 { - for _ in 0..10 { - if let Some(v) = cpu_random64() { - let len = core::cmp::min(8, 32 - offset); - seed[offset..offset + len].copy_from_slice(&v.to_le_bytes()[..len]); - offset += len; - hw_bytes += len; - break; - } - for _ in 0..50 { - core::hint::spin_loop(); - } - } - if offset < 32 { - let t1 = read_cycle_counter(); - for _ in 0..((t1 & 0x1F) + 1) { - core::hint::spin_loop(); - } - let t2 = read_cycle_counter(); - let len = core::cmp::min(8, 32 - offset); - seed[offset..offset + len].copy_from_slice(&t2.wrapping_sub(t1).to_le_bytes()[..len]); - offset += len; - } - } - // Fail closed unless every seed byte came from hardware (virtio-rng returned - // early above; rdseed/rdrand must supply all 32 here). Accepting a - // TSC/stack-derived seed as "secure" would key the CSPRNG from low, - // partly predictable entropy. init_rng propagates this Err and leaves the - // RNG uninitialised rather than minting predictable keys and nonces. - if hw_bytes < 32 { - return Err(EntropyError::InsufficientEntropy); - } - let stack_addr = crate::arch::stack_pointer(); - let counter = ENTROPY_COUNTER.fetch_add(0xA7B3_C5D9_E1F4_2680, Ordering::SeqCst); - let (sb, cb) = (stack_addr.to_le_bytes(), counter.to_le_bytes()); - for i in 0..8 { - seed[i] ^= sb[i]; - seed[i + 8] ^= cb[i]; - seed[i + 16] ^= sb[7 - i]; - seed[i + 24] ^= cb[7 - i]; - } - let tb = read_cycle_counter().to_le_bytes(); - for i in 0..8 { - seed[i] ^= tb[i]; - } - Ok(seed) -} +use super::pool::collect_seed_entropy_secure; pub fn collect_seed_entropy() -> [u8; 32] { if let Ok(seed) = collect_seed_entropy_secure() { diff --git a/src/crypto/util/rng/global/generate.rs b/src/crypto/util/rng/global/generate.rs index 261b1956e9..6ef02a9b59 100644 --- a/src/crypto/util/rng/global/generate.rs +++ b/src/crypto/util/rng/global/generate.rs @@ -24,7 +24,7 @@ pub fn get_random_bytes() -> [u8; 32] { if ensure_initialized().is_ok() { if let Some(ref mut rng) = *GLOBAL_RNG.lock() { - rng.fill_bytes(&mut out); + super::reseed::draw(rng, &mut out); return out; } } @@ -38,7 +38,7 @@ pub fn get_random_bytes_secure() -> RngResult<[u8; 32]> { let mut out = [0u8; 32]; if let Some(ref mut rng) = *GLOBAL_RNG.lock() { - rng.fill_bytes(&mut out); + super::reseed::draw(rng, &mut out); return Ok(out); } @@ -48,7 +48,7 @@ pub fn get_random_bytes_secure() -> RngResult<[u8; 32]> { pub fn fill_random_bytes(buf: &mut [u8]) { if ensure_initialized().is_ok() { if let Some(ref mut rng) = *GLOBAL_RNG.lock() { - rng.fill_bytes(buf); + super::reseed::draw(rng, buf); return; } } @@ -60,7 +60,7 @@ pub fn fill_random_bytes_secure(buf: &mut [u8]) -> RngResult<()> { ensure_initialized()?; if let Some(ref mut rng) = *GLOBAL_RNG.lock() { - rng.fill_bytes(buf); + super::reseed::draw(rng, buf); return Ok(()); } diff --git a/src/crypto/util/rng/global/mod.rs b/src/crypto/util/rng/global/mod.rs index 9142f9011b..a4d347d77a 100644 --- a/src/crypto/util/rng/global/mod.rs +++ b/src/crypto/util/rng/global/mod.rs @@ -16,6 +16,7 @@ mod generate; mod init; +mod reseed; mod seed; mod state; diff --git a/src/crypto/util/rng/global/reseed.rs b/src/crypto/util/rng/global/reseed.rs new file mode 100644 index 0000000000..0b312363ff --- /dev/null +++ b/src/crypto/util/rng/global/reseed.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 . + +//! The generator's scheduled reseed. After RESEED_INTERVAL blocks it takes a +//! fresh seed from every entropy source and folds in its own next output, so +//! a state read out of memory stops predicting what comes after. When the +//! sources cannot answer, it keeps its state and asks again on the next draw. + +use super::super::csprng::ChaChaRng; +use super::super::entropy::collect_seed_entropy_secure; +use crate::crypto::hash::sha256; + +pub(super) fn draw(rng: &mut ChaChaRng, buf: &mut [u8]) { + rng.fill_bytes(buf); + if !rng.needs_reseed() { + return; + } + let Ok(fresh) = collect_seed_entropy_secure() else { + return; + }; + let mut both = [0u8; 64]; + both[..32].copy_from_slice(&fresh); + rng.fill_bytes(&mut both[32..]); + rng.reseed(sha256(&both)); + for b in both.iter_mut() { + // SAFETY: `b` is a live, exclusively borrowed byte of `both`. + unsafe { core::ptr::write_volatile(b, 0) }; + } +} 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/mod.rs b/src/hardware/broker/claim/mod.rs index 8111cd43e5..eb9ec415d2 100644 --- a/src/hardware/broker/claim/mod.rs +++ b/src/hardware/broker/claim/mod.rs @@ -16,6 +16,7 @@ mod claim; mod lookup; +mod quiesce; mod release; mod state; mod types; diff --git a/src/hardware/broker/claim/quiesce.rs b/src/hardware/broker/claim/quiesce.rs new file mode 100644 index 0000000000..1bf4b60c27 --- /dev/null +++ b/src/hardware/broker/claim/quiesce.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 . + +use crate::drivers::pci::config::ConfigSpace; + +/* + * Stop a released device from mastering the bus. A driver turns Bus Master + * Enable on through the config-write allowlist, and nothing turned it off: + * a device whose driver exited kept its DMA running. With an IOMMU the + * detach that follows denies it; without one, which is most machines, this + * write is the only thing that stops it reaching all of memory. + */ +pub(super) fn stop_bus_master(device_id: u64) { + let Some(handle) = crate::hardware::broker::pci_index::lookup(device_id) else { + return; + }; + let cfg = ConfigSpace::new(handle.address); + // Read back, so the log says what the device holds, not what was asked. + let off = cfg.disable_bus_master().is_ok() && matches!(cfg.is_bus_master_enabled(), Ok(false)); + crate::sys::serial::print(b"[BROKER] released device "); + crate::sys::serial::print_hex(device_id); + crate::sys::serial::println(if off { b" bus master off" } else { b" bus master STILL ON" }); +} diff --git a/src/hardware/broker/claim/release.rs b/src/hardware/broker/claim/release.rs index 76e0a356de..72ad3310e4 100644 --- a/src/hardware/broker/claim/release.rs +++ b/src/hardware/broker/claim/release.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 . +extern crate alloc; + use super::state::CLAIMS; use super::types::ClaimError; @@ -27,6 +29,9 @@ pub fn release(pid: u32, device_id: u64) -> Result { } let epoch = claims[idx].epoch; claims.remove(idx); + drop(claims); + super::quiesce::stop_bus_master(device_id); + crate::hardware::broker::confine::detach(pid, device_id); Ok(epoch) } @@ -35,7 +40,13 @@ pub fn release(pid: u32, device_id: u64) -> Result { // number of claims revoked. pub fn release_all_for_pid(pid: u32) -> usize { let mut claims = CLAIMS.lock(); - let before = claims.len(); + let held: alloc::vec::Vec = + claims.iter().filter(|c| c.pid == pid).map(|c| c.device_id).collect(); claims.retain(|c| c.pid != pid); - before - claims.len() + drop(claims); + for device_id in &held { + super::quiesce::stop_bus_master(*device_id); + } + crate::hardware::broker::confine::detach_all(pid); + held.len() } 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/interrupts/isr/timer_trampoline/handler.rs b/src/interrupts/isr/timer_trampoline/handler.rs index 43fcc23cee..c8697df4be 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 @@ -100,4 +111,17 @@ pub(crate) extern "C" fn timer_trap_handler(ctx: *mut UserContext) { crate::process::exit::drain_pending_teardowns(); crate::kernel_core::process_spawn::drain_pending_kernel_stacks(); } + /* + * A guest thread its supervisor asked to stop is parked here, running + * no code, until it is answered; the frame it resumes from is this one, + * which a signal answer rewrites to enter the handler. + */ + if from_user { + drop(_ctx_guard); + let words = ctx.cast::<[u64; crate::process::foreign::TICK_FRAME_WORDS]>(); + // SAFETY: eK@nonos.systems - the trampoline's 160-byte frame read + // above, still on this thread's kernel stack and restored from on the + // way out; the 20 words are exactly that frame, nothing past it. + crate::process::foreign::on_user_tick(unsafe { &mut *words }); + } } 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, diff --git a/src/kernel_core/init/entry/init_extended_state.rs b/src/kernel_core/init/entry/init_extended_state.rs new file mode 100644 index 0000000000..a8486a5fe4 --- /dev/null +++ b/src/kernel_core/init/entry/init_extended_state.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 processor state user threads may use, decided here and not left to +//! firmware. Nothing set CR4.OSXSAVE or XCR0 before this step, so a program saw +//! whatever the firmware left, and the switch saved it with FXSAVE whether or +//! not AVX was on. Before the process runtime and the APs, which mirror it. + +#[cfg(target_arch = "x86_64")] +pub(super) fn init_extended_state() { + use crate::arch::x86_64::cpu::xstate; + // Nothing on this path has run CPUID into the cache yet, and without it + // every feature reads absent and SSE bring-up refuses. + crate::arch::x86_64::cpu::detect_features(); + // SAFETY: eK@nonos.systems - the boot CPU, once, before any thread exists. + // Each enable step checks CPUID first and leaves a missing feature off. + if let Err(e) = unsafe { crate::arch::x86_64::boot::validation::enable_sse_avx() } { + super::fatal::fatal("cpu: SSE bring-up failed", e.as_str()); + } + let serial = crate::sys::serial::print; + serial(b"[CPU-FPU] xsave="); + serial(if xstate::uses_xsave() { b"1" } else { b"0" }); + serial(b" xcr0="); + crate::sys::serial::print_hex(xstate::enabled()); + serial(b" area="); + crate::sys::serial::print_dec(xstate::needed() as u64); + crate::sys::serial::println(b""); +} + +#[cfg(not(target_arch = "x86_64"))] +pub(super) fn init_extended_state() {} diff --git a/src/kernel_core/init/entry/microkernel_init.rs b/src/kernel_core/init/entry/microkernel_init.rs index 0e88e4894d..9a8d95f64e 100644 --- a/src/kernel_core/init/entry/microkernel_init.rs +++ b/src/kernel_core/init/entry/microkernel_init.rs @@ -24,6 +24,7 @@ use super::init_arch_memory_and_framebuffer::init_arch_memory_and_framebuffer; use super::init_boot_entropy::init_boot_entropy; use super::init_core_services::init_core_services; use super::init_dma_protection::init_dma_protection; +use super::init_extended_state::init_extended_state; use super::init_runtime::{init_device_routing, init_process_runtime}; use super::init_vm_and_protection::init_vm_and_protection; use crate::boot::handoff::KernelHandoff; @@ -43,6 +44,7 @@ pub fn microkernel_init(handoff: &KernelHandoff) { init_arch_firmware(handoff); init_core_services(handoff); init_vm_and_protection(); + init_extended_state(); // Immediately after paging, because reaching a remapping unit means // mapping its register window, and long before any driver capsule is in diff --git a/src/kernel_core/init/entry/mod.rs b/src/kernel_core/init/entry/mod.rs index 2de296e69f..b0a5d1beb8 100644 --- a/src/kernel_core/init/entry/mod.rs +++ b/src/kernel_core/init/entry/mod.rs @@ -23,6 +23,7 @@ mod init_boot_entropy; mod init_core_services; mod init_dma_protection; mod init_runtime; +mod init_extended_state; mod init_vm_and_protection; mod microkernel_init; mod microkernel_main; 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/memory/heap/types/alloc_impl.rs b/src/memory/heap/types/alloc_impl.rs index 8b6648f233..5de9205e1c 100644 --- a/src/memory/heap/types/alloc_impl.rs +++ b/src/memory/heap/types/alloc_impl.rs @@ -16,14 +16,14 @@ use super::super::constants::MIN_ALIGNMENT; use super::allocator::SecureHeapAllocator; -use super::header::AllocationHeader; +use super::header::{data_offset, AllocationHeader}; use core::alloc::{GlobalAlloc, Layout}; use core::mem; use core::ptr::{self, null_mut}; use core::sync::atomic::Ordering; -// Allocation produces a pointer aligned to `layout.align().max(MIN_ALIGNMENT)`, -// which is ≥ 8 and so satisfies AllocationHeader and u64 alignment. +// The data pointer is aligned to `layout.align().max(MIN_ALIGNMENT)` ≥ 8, and +// the header before it to 8. #[allow(clippy::cast_ptr_alignment)] pub(super) unsafe fn alloc_impl(allocator: &SecureHeapAllocator, layout: Layout) -> *mut u8 { unsafe { @@ -31,19 +31,18 @@ pub(super) unsafe fn alloc_impl(allocator: &SecureHeapAllocator, layout: Layout) return null_mut(); } + // The data, not the block, must meet the alignment: a header in front + // of an aligned block left a 64-aligned request only 32-aligned. let header_size = mem::size_of::(); - let total_size = match header_size - .checked_add(layout.size()) - .and_then(|s| s.checked_add(mem::size_of::())) - { - Some(size) => size, - None => return null_mut(), - }; - let align = layout.align().max(MIN_ALIGNMENT); - let adjusted_layout = match Layout::from_size_align(total_size, align) { - Ok(l) => l, - Err(_) => return null_mut(), + let offset = data_offset(align); + let Some(total_size) = + offset.checked_add(layout.size()).and_then(|s| s.checked_add(mem::size_of::())) + else { + return null_mut(); + }; + let Ok(adjusted_layout) = Layout::from_size_align(total_size, align) else { + return null_mut(); }; let raw_ptr = @@ -52,8 +51,8 @@ pub(super) unsafe fn alloc_impl(allocator: &SecureHeapAllocator, layout: Layout) return null_mut(); } - let header_ptr = raw_ptr as *mut AllocationHeader; - let data_ptr = raw_ptr.add(header_size); + let data_ptr = raw_ptr.add(offset); + let header_ptr = data_ptr.sub(header_size) as *mut AllocationHeader; let canary_ptr = data_ptr.add(layout.size()) as *mut u64; let header = AllocationHeader::new(layout.size(), super::super::manager::get_timestamp()); diff --git a/src/memory/heap/types/dealloc_impl.rs b/src/memory/heap/types/dealloc_impl.rs index 7e68a43f58..64f05b65d4 100644 --- a/src/memory/heap/types/dealloc_impl.rs +++ b/src/memory/heap/types/dealloc_impl.rs @@ -16,7 +16,7 @@ use super::super::constants::MIN_ALIGNMENT; use super::allocator::SecureHeapAllocator; -use super::header::AllocationHeader; +use super::header::{data_offset, AllocationHeader}; use core::alloc::{GlobalAlloc, Layout}; use core::mem; use core::ptr; @@ -31,9 +31,10 @@ pub(super) unsafe fn dealloc_impl(allocator: &SecureHeapAllocator, ptr: *mut u8, return; } - let header_size = mem::size_of::(); - let raw_ptr = ptr.sub(header_size); - let header_ptr = raw_ptr as *const AllocationHeader; + let align = layout.align().max(MIN_ALIGNMENT); + let offset = data_offset(align); + let raw_ptr = ptr.sub(offset); + let header_ptr = ptr.sub(mem::size_of::()) as *const AllocationHeader; let header = ptr::read_volatile(header_ptr); if !header.is_valid() || header.size != layout.size() { @@ -67,8 +68,7 @@ pub(super) unsafe fn dealloc_impl(allocator: &SecureHeapAllocator, ptr: *mut u8, ptr::write_bytes(ptr, 0, layout.size()); } - let total_size = header_size + layout.size() + mem::size_of::(); - let align = layout.align().max(MIN_ALIGNMENT); + let total_size = offset + layout.size() + mem::size_of::(); if let Ok(adjusted_layout) = Layout::from_size_align(total_size, align) { super::super::manager::HEAP_STATS.record_deallocation(layout.size()); crate::arch::run_without_interrupts(|| { diff --git a/src/memory/heap/types/header.rs b/src/memory/heap/types/header.rs index b72f6e65ff..1e6d429f05 100644 --- a/src/memory/heap/types/header.rs +++ b/src/memory/heap/types/header.rs @@ -35,3 +35,11 @@ impl AllocationHeader { self.magic == ALLOCATION_MAGIC } } + +/// Where the data starts in a block aligned to `align`: past the header, +/// rounded up so the data itself has the caller's alignment. The header sits +/// directly before the data either way, where free and verify look for it. +pub const fn data_offset(align: usize) -> usize { + let header = core::mem::size_of::(); + (header + align - 1) & !(align - 1) +} 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/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/address_space.rs b/src/memory/paging/manager/api/address_space.rs index 7d0950a489..99be15ac61 100644 --- a/src/memory/paging/manager/api/address_space.rs +++ b/src/memory/paging/manager/api/address_space.rs @@ -38,6 +38,18 @@ pub fn lookup_asid_for_process(process_id: u32) -> Option { lock_responsive(&PAGING_MANAGER).lookup_asid_for_process(process_id) } +/// Make `process_id` the owner of the tables `asid` names, so its release is +/// the one that frees them. False when there is no such address space. +pub fn hand_over_address_space(asid: u32, process_id: u32) -> bool { + match lock_responsive(&PAGING_MANAGER).address_spaces.get_mut(&asid) { + Some(space) => { + space.process_id = process_id; + true + } + None => false, + } +} + pub fn switch_to_process_address_space(process_id: u32) -> PagingResult<()> { let asid = lookup_asid_for_process(process_id) .ok_or(crate::memory::paging::error::PagingError::AddressSpaceNotFound)?; diff --git a/src/memory/paging/manager/api/mod.rs b/src/memory/paging/manager/api/mod.rs index 61b0321857..7eea355707 100644 --- a/src/memory/paging/manager/api/mod.rs +++ b/src/memory/paging/manager/api/mod.rs @@ -27,8 +27,8 @@ mod stats; mod tlb_ops; pub use address_space::{ - cleanup_address_space, create_address_space, get_process_cr3, lookup_asid_for_process, - switch_address_space, switch_to_process_address_space, + cleanup_address_space, create_address_space, get_process_cr3, hand_over_address_space, + lookup_asid_for_process, switch_address_space, switch_to_process_address_space, }; pub use faults::handle_page_fault; pub use init::{init, is_initialized}; 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/faults/handler.rs b/src/memory/paging/manager/faults/handler.rs index 3938df5e31..344bccd85c 100644 --- a/src/memory/paging/manager/faults/handler.rs +++ b/src/memory/paging/manager/faults/handler.rs @@ -40,9 +40,30 @@ impl PagingManager { return Err(PagingError::UnhandledPageFault); } stats.record_demand_load(); - return self.handle_demand_fault(virtual_addr, stats); + let filled = self.handle_demand_fault(virtual_addr, stats); + if filled.is_ok() { + // Named only for a fill that happened: the guards inside refuse + // the null page and the kernel half, and a refused fault is not + // a fill. + log_demand_fill(virtual_addr, error_code); + } + return filled; } Err(PagingError::UnhandledPageFault) } } + +// A demand fill puts a zeroed page where nothing was mapped. Named on the +// serial log so a fill that lands where code or a peer's page belonged is +// visible at the moment it happens, not only at the fault it causes later. +fn log_demand_fill(virtual_addr: VirtAddr, error_code: u64) { + let pid = crate::process::current_pid().unwrap_or(0); + crate::sys::serial::print(b"[PF] demand fill pid="); + crate::sys::serial::print_hex(pid as u64); + crate::sys::serial::print(b" va="); + crate::sys::serial::print_hex(virtual_addr.as_u64()); + crate::sys::serial::print(b" err="); + crate::sys::serial::print_hex(error_code); + crate::sys::serial::println(b""); +} 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]); diff --git a/src/process/address_space/lifecycle/release.rs b/src/process/address_space/lifecycle/release.rs index 2ae992d8ed..20ad4e12bc 100644 --- a/src/process/address_space/lifecycle/release.rs +++ b/src/process/address_space/lifecycle/release.rs @@ -29,9 +29,36 @@ pub fn release(pcb: &Arc) { // table, so it freed whatever address space happened to be current; the // ASID-scoped teardown frees the leaf frames as well, so it is the only // path that touches the right tables. - if let Some(asid) = crate::memory::paging::manager::lookup_asid_for_process(pcb.pid) { - if crate::memory::paging::manager::cleanup_address_space(asid).is_err() { - crate::sys::serial::println(b"[EXIT] address_space_cleanup_failed"); + let Some(asid) = crate::memory::paging::manager::lookup_asid_for_process(pcb.pid) else { + return; + }; + /* + * A thread runs on its group's tables without owning them, and until it + * leaves the process table it can still be on a CPU under them, taking + * its own kill or parked on its kernel stack. Freed when the owner went + * first, they were reused under a running thread, and a threaded guest's + * exit triple faulted. They pass to a thread still in the table instead, + * and the last holder's release frees them. + */ + if let Some(heir) = holder_after(pcb) { + if crate::memory::paging::manager::hand_over_address_space(asid, heir.pid) { + // Its token names the ASID it runs in, which it now owns. + let _ = crate::process::caps::rebind_address_space(&heir); + return; } } + if crate::memory::paging::manager::cleanup_address_space(asid).is_err() { + crate::sys::serial::println(b"[EXIT] address_space_cleanup_failed"); + } +} + +fn holder_after(pcb: &ProcessControlBlock) -> Option> { + let tables = pcb.cr3.load(Ordering::Acquire); + if tables == 0 { + return None; + } + crate::process::core::PROCESS_TABLE + .get_all_processes() + .into_iter() + .find(|p| p.pid != pcb.pid && p.cr3.load(Ordering::Acquire) == tables) } 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/suspend.rs b/src/process/core/suspend.rs index 112c2f961d..65a64b2900 100644 --- a/src/process/core/suspend.rs +++ b/src/process/core/suspend.rs @@ -14,6 +14,7 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . +use alloc::boxed::Box; use alloc::collections::BTreeMap; use core::sync::atomic::Ordering; use spin::RwLock; @@ -28,7 +29,7 @@ static SUSPENDED_CONTEXTS: RwLock> = RwLock::new pub static INTERRUPT_SAVED_CONTEXTS: RwLock> = RwLock::new(BTreeMap::new()); -pub static INTERRUPT_SAVED_FPU_STATES: RwLock> = +pub static INTERRUPT_SAVED_FPU_STATES: RwLock>> = RwLock::new(BTreeMap::new()); pub fn suspend_process(pid: Pid) -> Result<(), &'static str> { @@ -99,15 +100,14 @@ pub fn clear_interrupt_context(pid: Pid) { INTERRUPT_SAVED_CONTEXTS.write().remove(&pid); } +/// Into the pid's own area, made once on the heap and reused on every switch. pub fn save_fpu_state(pid: Pid) { - let mut fpu = FpuState::default(); - fpu.save(); - INTERRUPT_SAVED_FPU_STATES.write().insert(pid, fpu); + INTERRUPT_SAVED_FPU_STATES.write().entry(pid).or_insert_with(FpuState::new).save(); } +/// Straight from the saved area; nothing is copied onto the stack. pub fn restore_fpu_state(pid: Pid) { - let fpu_copy = INTERRUPT_SAVED_FPU_STATES.read().get(&pid).cloned(); - if let Some(fpu) = fpu_copy { + if let Some(fpu) = INTERRUPT_SAVED_FPU_STATES.read().get(&pid) { fpu.restore(); } } 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/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..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; @@ -24,6 +27,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/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); diff --git a/src/process/exit/mod.rs b/src/process/exit/mod.rs index a00d1170a6..57ce69ac8a 100644 --- a/src/process/exit/mod.rs +++ b/src/process/exit/mod.rs @@ -23,7 +23,7 @@ mod teardown; pub use exit_and_yield::exit_and_yield; pub(crate) use pending::drain as drain_pending_teardowns; -pub(crate) use reap_log::{reap_exit_status, reap_exit_status_for}; +pub(crate) use reap_log::{peek_exit_status, reap_exit_status, reap_exit_status_for}; pub use teardown::teardown; /// Drop everything a freshly allocated pid would inherit from a dead one. diff --git a/src/process/exit/reap_log.rs b/src/process/exit/reap_log.rs index c47a24d499..dfb3c9a2b7 100644 --- a/src/process/exit/reap_log.rs +++ b/src/process/exit/reap_log.rs @@ -35,6 +35,11 @@ pub(super) fn record(pid: Pid, parent: Pid, code: i32) { log.insert(pid, (parent, code)); } +/// A status left in place, for a reader that is not the parent reaping it. +pub(crate) fn peek_exit_status(pid: Pid) -> Option { + REAP_LOG.lock().get(&pid).map(|&(_, code)| code) +} + pub(crate) fn reap_exit_status(pid: Pid) -> Option { REAP_LOG.lock().remove(&pid).map(|(_, code)| code) } diff --git a/src/process/exit/teardown.rs b/src/process/exit/teardown.rs index 8d038a258b..e58c7993d4 100644 --- a/src/process/exit/teardown.rs +++ b/src/process/exit/teardown.rs @@ -18,7 +18,7 @@ use core::sync::atomic::Ordering; use crate::process::core::{clear_current_if, Pid, ProcessState, CURRENT_PID, PROCESS_TABLE}; -pub fn teardown(pid: Pid, exit_code: i32, _by_signal: bool) { +pub fn teardown(pid: Pid, exit_code: i32, by_signal: bool) { let pcb = match PROCESS_TABLE.find_by_pid(pid) { Some(p) => p, None => return, @@ -27,6 +27,15 @@ pub fn teardown(pid: Pid, exit_code: i32, _by_signal: bool) { return; } + // A guest thread ending on a signal (a fault, not its own exit) is + // reported to its supervisor, which owns the guest; the Linux personality + // ends the whole process, as Linux does. Only when the thread ends itself: + // a supervisor killing its guest comes through MkKill with a different + // current pid, and must not loop back into another notice. + if by_signal && crate::process::current_pid() == Some(pid) { + crate::process::foreign::note_signal_death(pid, exit_code); + } + crate::kernel_core::surface_registry::release_owned_by_pid(pid); crate::kernel_core::surface_registry::attach_map::forget_pid(pid); let current = CURRENT_PID.load(Ordering::Acquire) == pid; diff --git a/src/process/foreign/exec.rs b/src/process/foreign/exec.rs index 1b20fa0be7..ea7cd4c5d9 100644 --- a/src/process/foreign/exec.rs +++ b/src/process/foreign/exec.rs @@ -23,10 +23,6 @@ use super::peer_guard::{in_user_half, pid_arg}; type Saved = Option; -/// What a parked guest receives when its supervisor has replaced the program -/// under it. -pub(super) const EXECED: u64 = u64::MAX; - pub fn sys_foreign_exec(pid: u64, entry: u64, rsp: u64) -> i64 { let Some(caller) = crate::process::current_pid() else { return ERRNO_INVAL; @@ -46,7 +42,7 @@ pub fn sys_foreign_exec(pid: u64, entry: u64, rsp: u64) -> i64 { }; drop_tls(pid); // Answering is what releases the guest. - match super::trap_reply::answer_raw(pid, EXECED) { + match super::trap_reply::answer_raw(pid, super::trap_table::Answer::Execed) { 0 => 0, err => { swap(pid, previous); diff --git a/src/process/foreign/fork.rs b/src/process/foreign/fork.rs index 293738b7ea..feed314d9e 100644 --- a/src/process/foreign/fork.rs +++ b/src/process/foreign/fork.rs @@ -44,13 +44,20 @@ pub fn sys_foreign_fork(pid: u64) -> i64 { }; let mut frame = state; frame.rax = 0; + // The thread pointer is a register the frame does not carry, so the child + // takes its forking thread's, read from that thread's PCB. Without this a + // fork from a thread that set its own FS would give the child a zero one. + let parent_tls = crate::process::with_process(parent, |pcb| pcb.get_tls_base()).unwrap_or(0); crate::process::with_process(child, |pcb| { *pcb.saved_user_context.lock() = Some(frame); + if parent_tls != 0 { + pcb.set_tls_base(parent_tls); + } *pcb.state.lock() = ProcessState::New; }); child as 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/frame.rs b/src/process/foreign/frame.rs index 24e4dec67b..e2ba6060c3 100644 --- a/src/process/foreign/frame.rs +++ b/src/process/foreign/frame.rs @@ -23,6 +23,14 @@ //! an unserviceable call came from. /// Wire layout shared with userspace. Appended to, never reordered. +/// Delivered to a supervisor, not a guest: the thread `pid` ended on a +/// signal. Matches `nonos_libc::FOREIGN_NR_DIED`; no syscall uses it. +pub(super) const NR_DIED: u64 = u64::MAX; +/// Delivered to a supervisor, not a guest: the thread `pid` was running and +/// is stopped at a timer tick, as its supervisor asked, holding its whole +/// register file. Matches `nonos_libc::FOREIGN_NR_INTERRUPTED`. +pub(super) const NR_INTERRUPTED: u64 = u64::MAX - 1; + #[repr(C)] #[derive(Clone, Copy, Default)] pub struct ForeignFrame { diff --git a/src/process/foreign/interrupt.rs b/src/process/foreign/interrupt.rs new file mode 100644 index 0000000000..af22646a19 --- /dev/null +++ b/src/process/foreign/interrupt.rs @@ -0,0 +1,114 @@ +// 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 . + +//! `MkForeignInterrupt`: stopping a guest thread that is running its own code. +//! +//! A signal is delivered by answering a parked call with a handler to enter, +//! so a thread that makes no call never receives one. A supervisor marks such +//! a thread here. At the next timer tick that interrupts it in user mode the +//! kernel parks it with its whole register file, as if it had made a call +//! numbered `NR_INTERRUPTED`, and hands that to the supervisor. The answer is +//! a handler to enter, or anything else to run on exactly where it was. The +//! kernel stops and holds the thread; what it is stopped for is the +//! supervisor's. + +use alloc::vec::Vec; +use core::sync::atomic::{AtomicBool, Ordering}; + +use spin::Mutex; + +use super::frame::{ForeignFrame, NR_INTERRUPTED}; +use super::interrupt_frame::{to_user, to_words, WORDS}; +use super::trap_table::{is_parked, park, Answer}; + +static MARKED: Mutex> = Mutex::new(Vec::new()); +/// Set while any thread is marked, so a tick with nothing marked costs one load. +static ANY: AtomicBool = AtomicBool::new(false); + +/// Mark `pid`, one of the caller's guests. 1 says it is parked in a call +/// already, whose answer can carry the handler; 0 says it is marked. +pub fn sys_foreign_interrupt(pid: u64) -> i64 { + let pid = match super::signal_call::supervised(pid) { + Ok(p) => p, + Err(e) => return e, + }; + if is_parked(pid) { + return 1; + } + let mut marked = MARKED.lock(); + if !marked.contains(&pid) { + marked.push(pid); + } + ANY.store(true, Ordering::Release); + 0 +} + +/// A thread that is gone keeps no mark for a later one with its pid. +pub(super) fn forget(pid: u32) { + let mut marked = MARKED.lock(); + marked.retain(|&p| p != pid); + ANY.store(!marked.is_empty(), Ordering::Release); +} + +/// A marked thread that makes a call needs no tick to stop it: the answer to +/// that call carries what it was marked for. Left in place, the mark would +/// stop the thread once more at a later tick for nothing. +pub(super) fn on_call(pid: u32) { + if ANY.load(Ordering::Acquire) { + forget(pid); + } +} + +fn take(pid: u32) -> bool { + let mut marked = MARKED.lock(); + let Some(at) = marked.iter().position(|&p| p == pid) else { + return false; + }; + marked.swap_remove(at); + ANY.store(!marked.is_empty(), Ordering::Release); + true +} + +/// Called by the timer trampoline, after the tick, for a tick that +/// interrupted user mode. `frame` is the interrupted register file the +/// trampoline restores (`interrupt_frame`). +pub fn on_user_tick(frame: &mut [u64; WORDS]) { + if !ANY.load(Ordering::Acquire) || !crate::smp::preempt_enabled() { + return; + } + let Some(pid) = crate::process::current_pid() else { + return; + }; + if !take(pid) { + return; + } + let Some(supervisor) = super::registry::supervisor_of(pid) else { + return; + }; + let fs_base = crate::process::with_process(pid, |p| p.get_tls_base()).unwrap_or(0); + let held = to_user(frame, fs_base); + super::trap_frame::keep(pid, held); + if !park(ForeignFrame::new(pid, NR_INTERRUPTED, [0; 6], held.rip)) { + super::trap_frame::drop_frame(pid); + return; + } + crate::sched::wake_process(supervisor); + if let Answer::Deliver(to) = super::trap_wait::wait_raw(pid) { + super::signal_enter::enter_fpu(pid); + crate::arch::context::set_user_tls(to.fs_base); + *frame = to_words(&to); + } +} diff --git a/src/process/foreign/interrupt_frame.rs b/src/process/foreign/interrupt_frame.rs new file mode 100644 index 0000000000..c8a950fce2 --- /dev/null +++ b/src/process/foreign/interrupt_frame.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 . + +//! The words the timer trampoline saves for a tick that interrupted user +//! mode, and the register file they hold. +//! +//! The trampoline pushes the fifteen general registers under the CPU's iretq +//! frame, which is the leading 160 bytes of `SavedUser` and nothing more. For +//! a tick from user mode that frame sits at the top of the kernel stack, so +//! the TLS words that follow it in `SavedUser` are not the thread's: they are +//! never read or written through the trampoline's pointer. + +use core::mem::offset_of; + +use crate::arch::context::SavedUser; + +/// r15 down to rax, then rip, cs, rflags, rsp, ss. +pub const WORDS: usize = 20; + +const _: () = assert!(offset_of!(SavedUser, r15) == 0); +const _: () = assert!(offset_of!(SavedUser, rax) == 14 * 8); +const _: () = assert!(offset_of!(SavedUser, rip) == 15 * 8); +const _: () = assert!(offset_of!(SavedUser, ss) == 19 * 8); + +pub(super) fn to_user(w: &[u64; WORDS], fs_base: u64) -> SavedUser { + SavedUser { + r15: w[0], + r14: w[1], + r13: w[2], + r12: w[3], + r11: w[4], + r10: w[5], + r9: w[6], + r8: w[7], + rdi: w[8], + rsi: w[9], + rbp: w[10], + rbx: w[11], + rdx: w[12], + rcx: w[13], + rax: w[14], + rip: w[15], + cs: w[16], + rflags: w[17], + rsp: w[18], + ss: w[19], + fs_base, + gs_base: 0, + } +} + +pub(super) fn to_words(c: &SavedUser) -> [u64; WORDS] { + [ + c.r15, c.r14, c.r13, c.r12, c.r11, c.r10, c.r9, c.r8, c.rdi, c.rsi, c.rbp, c.rbx, c.rdx, + c.rcx, c.rax, c.rip, c.cs, c.rflags, c.rsp, c.ss, + ] +} diff --git a/src/process/foreign/mod.rs b/src/process/foreign/mod.rs index 93713448e4..366faafe32 100644 --- a/src/process/foreign/mod.rs +++ b/src/process/foreign/mod.rs @@ -21,8 +21,11 @@ mod exec_context; mod exec_enter; mod fork; mod frame; +mod notice; mod frame_cpu; mod frame_snapshot; +mod interrupt; +mod interrupt_frame; mod peer_chunk; mod peer_copy; mod peer_guard; @@ -33,12 +36,16 @@ mod peer_tls; mod peer_unmap; mod registry; mod resume; +mod signal_call; +mod signal_enter; +mod signal_regs; mod spawn; mod spawn_start; mod start_context; mod thread; mod trap; mod trap_claim; +mod trap_frame; mod trap_reply; mod trap_table; mod trap_wait; @@ -48,12 +55,23 @@ pub use exec::sys_foreign_exec; pub use fork::sys_foreign_fork; pub use frame::ForeignFrame; pub use frame_snapshot::FRAME_WORDS; +pub use interrupt::{on_user_tick, sys_foreign_interrupt}; +pub use interrupt_frame::WORDS as TICK_FRAME_WORDS; pub use peer_copy::sys_peer_copy; pub use peer_map::sys_peer_map; pub use peer_protect::sys_peer_protect; pub use peer_tls::sys_peer_tls; pub use peer_unmap::sys_peer_unmap; pub use registry::{clear, is_foreign, supervisor_of}; + +/// Report to its supervisor that a guest thread ended on a signal, if it is +/// a guest at all. The supervisor's personality decides what follows. +pub fn note_signal_death(pid: u32, code: i32) { + if let Some(supervisor) = registry::supervisor_of(pid) { + notice::post(supervisor, pid, code); + } +} +pub use signal_call::{sys_foreign_context, sys_foreign_signal}; pub use spawn::sys_foreign_spawn; pub use spawn_start::sys_foreign_start; pub use thread::sys_foreign_thread; diff --git a/src/process/foreign/notice.rs b/src/process/foreign/notice.rs new file mode 100644 index 0000000000..3bc6c31a65 --- /dev/null +++ b/src/process/foreign/notice.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 . + + +//! One-way death notices from the kernel to a guest's supervisor. +//! +//! A guest thread that ends on a signal cannot park and wait for a reply, and +//! the kernel does not decide what a dead thread means: that is the +//! supervisor's, and its personality's, policy. So the kernel leaves a notice +//! its supervisor collects on the next wait, keyed by the supervisor so it +//! survives the dead thread's teardown, and one-shot because no reply follows. + +use alloc::vec::Vec; + +use spin::Mutex; + +struct Notice { + supervisor: u32, + pid: u32, + code: i32, +} + +static NOTICES: Mutex> = Mutex::new(Vec::new()); + +pub(super) fn post(supervisor: u32, pid: u32, code: i32) { + NOTICES.lock().push(Notice { supervisor, pid, code }); + crate::sched::wake_process(supervisor); +} + +/// The next death notice for this supervisor, removed as it is taken. +pub(super) fn take(supervisor: u32) -> Option<(u32, i32)> { + let mut notices = NOTICES.lock(); + let at = notices.iter().position(|n| n.supervisor == supervisor)?; + let n = notices.remove(at); + Some((n.pid, n.code)) +} + +/// Drop notices bound for a supervisor that is itself gone, so its pid, if +/// reused, does not collect a death meant for the process that had it before. +pub(super) fn forget_supervisor(supervisor: u32) { + NOTICES.lock().retain(|n| n.supervisor != supervisor); +} diff --git a/src/process/foreign/registry.rs b/src/process/foreign/registry.rs index 738a30f8a3..1383af43ef 100644 --- a/src/process/foreign/registry.rs +++ b/src/process/foreign/registry.rs @@ -65,4 +65,7 @@ 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); + super::notice::forget_supervisor(pid); + super::interrupt::forget(pid); } diff --git a/src/process/foreign/signal_call.rs b/src/process/foreign/signal_call.rs new file mode 100644 index 0000000000..4aa329d0ed --- /dev/null +++ b/src/process/foreign/signal_call.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 . + +//! `MkForeignContext` and `MkForeignSignal`: what a supervisor needs to deliver +//! a signal. It reads a parked guest's registers, and answers it with a whole +//! context instead of a value: a handler to enter, or a frame to return to. + +use super::peer_guard::pid_arg; +use super::registry; +use super::signal_regs::{from_words, to_words, WORDS}; +use super::trap_reply::answer_raw; +use super::trap_table::Answer; +use crate::syscall::microkernel::errnos::{ERRNO_FAULT, ERRNO_INVAL, ERRNO_NOENT, ERRNO_PERM}; +use crate::usercopy::{read_user_value, write_user_value}; + +const DELIVER: u64 = 0; +const SIGRETURN: u64 = 1; + +pub fn sys_foreign_context(pid: u64, out: u64) -> i64 { + let pid = match supervised(pid) { + Ok(p) => p, + Err(e) => return e, + }; + let Some(frame) = super::trap_frame::parked_frame(pid) else { + return ERRNO_NOENT; + }; + match write_user_value(out, &to_words(&frame)) { + Ok(()) => 0, + Err(_) => ERRNO_FAULT, + } +} + +pub fn sys_foreign_signal(pid: u64, regs: u64, kind: u64) -> i64 { + let pid = match supervised(pid) { + Ok(p) => p, + Err(e) => return e, + }; + let Ok(words) = read_user_value::<[u64; WORDS]>(regs) else { + return ERRNO_FAULT; + }; + let fs_base = crate::process::with_process(pid, |p| p.get_tls_base()).unwrap_or(0); + let Some(ctx) = from_words(&words, fs_base) else { + return ERRNO_INVAL; + }; + match kind { + DELIVER => answer_raw(pid, Answer::Deliver(ctx)), + SIGRETURN => answer_raw(pid, Answer::Sigreturn(ctx)), + _ => ERRNO_INVAL, + } +} + +// Only the guest's recorded supervisor, as for a reply. +pub(super) fn supervised(pid: u64) -> Result { + let caller = crate::process::current_pid().ok_or(ERRNO_INVAL)?; + let pid = pid_arg(pid)?; + match registry::supervisor_of(pid) == Some(caller) { + true => Ok(pid), + false => Err(ERRNO_PERM), + } +} diff --git a/src/process/foreign/signal_enter.rs b/src/process/foreign/signal_enter.rs new file mode 100644 index 0000000000..81829f8a55 --- /dev/null +++ b/src/process/foreign/signal_enter.rs @@ -0,0 +1,82 @@ +// 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 parked guest leaving into a signal handler, or back out of one. The FPU +//! state stays in the kernel: entering saves the thread's own and gives the +//! handler a clean unit, returning puts it back, and no guest can forge it. + +use alloc::boxed::Box; +use alloc::collections::BTreeMap; +use alloc::vec::Vec; + +use spin::Mutex; + +use crate::arch::context::SavedUser; +use crate::process::signal::SIGSEGV; +use crate::process::userspace::{restore_user_context_iretq, types::FpuState}; + +/// Handlers nested deeper than this end the thread. +const DEPTH: usize = 8; + +static SAVED: Mutex>>> = Mutex::new(BTreeMap::new()); + +pub(super) fn deliver(pid: u32, ctx: SavedUser) -> ! { + enter_fpu(pid); + resume(ctx) +} + +/// Keep the thread's FPU state for its handler's return and give the handler +/// a clean unit. Handlers nested past `DEPTH` end the thread. +pub(super) fn enter_fpu(pid: u32) { + let mut fpu = FpuState::new(); + fpu.save(); + let pushed = { + let mut saved = SAVED.lock(); + let stack = saved.entry(pid).or_default(); + let room = stack.len() < DEPTH; + if room { + stack.push(fpu); + } + room + }; + if !pushed { + crate::process::terminate_current_with_signal(SIGSEGV); + } + FpuState::init(); +} + +pub(super) fn sigreturn(pid: u32, ctx: SavedUser) -> ! { + let top = SAVED.lock().get_mut(&pid).and_then(|s| s.pop()); + match top { + Some(fpu) => fpu.restore(), + // A return with nothing delivered: not a frame this kernel made. + None => crate::process::terminate_current_with_signal(SIGSEGV), + } + resume(ctx) +} + +/// Exec and exit leave no handler to return from. +pub(super) fn forget(pid: u32) { + SAVED.lock().remove(&pid); +} + +fn resume(ctx: SavedUser) -> ! { + crate::arch::context::set_user_tls(ctx.fs_base); + // SAFETY: eK@nonos.systems - `ctx` came through `from_words`: user + // selectors, rip and rsp in the low half, flags masked to the program's + // own. The address space is this pid's, already on cr3, as in exec_enter. + unsafe { restore_user_context_iretq(&ctx) } +} diff --git a/src/process/foreign/signal_regs.rs b/src/process/foreign/signal_regs.rs new file mode 100644 index 0000000000..c61698744d --- /dev/null +++ b/src/process/foreign/signal_regs.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 . + +//! The register file a supervisor reads and writes for a parked guest, in the +//! order of Linux's `struct sigcontext`, and the checks on the way back in. + +use crate::arch::context::SavedUser; +use crate::process::userspace::{USER_CS, USER_DS}; + +/// r8..r15, rdi, rsi, rbp, rbx, rdx, rax, rcx, rsp, rip, rflags. +pub const WORDS: usize = 18; + +const USER_VA_MAX: u64 = 0x0000_7FFF_FFFF_FFFF; +// CF PF AF ZF SF DF OF are the program's. IF and the reserved bit 1 are forced +// on; TF, IOPL, NT, RF, AC and the rest stay the kernel's. +const USER_FLAGS: u64 = 0x0CD5; +const FORCED_FLAGS: u64 = 0x202; + +pub fn to_words(c: &SavedUser) -> [u64; WORDS] { + [ + c.r8, c.r9, c.r10, c.r11, c.r12, c.r13, c.r14, c.r15, c.rdi, c.rsi, c.rbp, c.rbx, c.rdx, + c.rax, c.rcx, c.rsp, c.rip, c.rflags, + ] +} + +/// A context the guest may resume into, or None. Selectors and the TLS base +/// come from the kernel, never from the supervisor. +pub fn from_words(w: &[u64; WORDS], fs_base: u64) -> Option { + let (rsp, rip) = (w[15], w[16]); + if rip > USER_VA_MAX || rsp > USER_VA_MAX || rsp == 0 { + return None; + } + Some(SavedUser { + r8: w[0], + r9: w[1], + r10: w[2], + r11: w[3], + r12: w[4], + r13: w[5], + r14: w[6], + r15: w[7], + rdi: w[8], + rsi: w[9], + rbp: w[10], + rbx: w[11], + rdx: w[12], + rax: w[13], + rcx: w[14], + rsp, + rip, + rflags: (w[17] & USER_FLAGS) | FORCED_FLAGS, + cs: USER_CS as u64, + ss: USER_DS as u64, + fs_base, + gs_base: 0, + }) +} 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); } diff --git a/src/process/foreign/thread.rs b/src/process/foreign/thread.rs index fd3d230a74..f46ea39a5d 100644 --- a/src/process/foreign/thread.rs +++ b/src/process/foreign/thread.rs @@ -17,12 +17,20 @@ //! A second thread inside a guest. use super::peer_guard::{in_user_half, pid_arg}; +use crate::arch::context::SavedUser; use crate::process::core::{admit_thread, spawn_thread_parked}; -use crate::syscall::microkernel::errnos::{ERRNO_INVAL, ERRNO_NOMEM, ERRNO_PERM}; +use crate::syscall::microkernel::errnos::{ERRNO_INVAL, ERRNO_NOENT, ERRNO_NOMEM, ERRNO_PERM}; /// `MkForeignThread`: a thread in `pid`, sharing its address space and /// supervised by the same caller. -pub fn sys_foreign_thread(pid: u64, entry: u64, rsp: u64, tls: u64) -> i64 { +/// +/// `from` is zero, or the thread of that guest parked in the call that asked +/// for this one. Given, the new thread starts on a copy of its registers, as a +/// Linux clone child does: zero in the return register, the stack and entry +/// given here, and the parent's thread pointer unless `tls` names another. Go +/// hands the child its function and its thread state in registers and calls +/// through them, so a child started on fresh registers called address zero. +pub fn sys_foreign_thread(pid: u64, entry: u64, rsp: u64, tls: u64, from: u64) -> i64 { let Some(caller) = crate::process::current_pid() else { return ERRNO_INVAL; }; @@ -44,12 +52,37 @@ pub fn sys_foreign_thread(pid: u64, entry: u64, rsp: u64, tls: u64) -> i64 { if tls != 0 && !in_user_half(tls, 1) { return ERRNO_INVAL; } + let parent = match from { + 0 => None, + raw => match parked_parent(caller, pid, raw) { + Ok(p) => Some(p), + Err(e) => return e, + }, + }; let Ok(tid) = spawn_thread_parked(pid, entry, rsp) else { return ERRNO_NOMEM; }; + let tls = match (tls, &parent) { + (0, Some((_, parent_tls))) => *parent_tls, + _ => tls, + }; if tls != 0 { crate::process::with_process(tid, |pcb| pcb.set_tls_base(tls)); } + if let Some((mut regs, _)) = parent { + regs.rax = 0; + regs.rip = entry; + regs.rsp = rsp; + /* + * A saved context is what the switch resumes when no first entry is + * pending, which is how a forked child starts; the fresh entry the + * spawn prepared would otherwise win and drop every register. + */ + crate::process::with_process(tid, |pcb| { + pcb.pending_user_entry.lock().take(); + *pcb.saved_user_context.lock() = Some(regs); + }); + } if !super::registry::insert(tid, caller) { crate::process::exit::teardown(tid, ERRNO_NOMEM as i32, false); return ERRNO_NOMEM; @@ -57,3 +90,26 @@ pub fn sys_foreign_thread(pid: u64, entry: u64, rsp: u64, tls: u64) -> i64 { admit_thread(tid); tid as i64 } + +/* + * The registers and thread pointer of the thread that asked. It must be one + * this caller supervises, in the same thread group as `pid`: a copy across + * groups would hand one guest another's register contents. It must also be + * parked in a call, since only then are its registers held aside. + */ +fn parked_parent(caller: u32, pid: u32, raw: u64) -> Result<(SavedUser, u64), i64> { + let from = pid_arg(raw)?; + if super::registry::supervisor_of(from) != Some(caller) { + return Err(ERRNO_PERM); + } + let group = |p: u32| crate::process::with_process(p, |pcb| pcb.thread_group_id()); + let (Some(want), Some(have)) = (group(pid), group(from)) else { + return Err(ERRNO_INVAL); + }; + if want != have { + return Err(ERRNO_PERM); + } + let regs = super::trap_frame::parked_frame(from).ok_or(ERRNO_NOENT)?; + let parent_tls = crate::process::with_process(from, |pcb| pcb.get_tls_base()).unwrap_or(0); + Ok((regs, parent_tls)) +} diff --git a/src/process/foreign/trap.rs b/src/process/foreign/trap.rs index 11e59836e5..700912f6c7 100644 --- a/src/process/foreign/trap.rs +++ b/src/process/foreign/trap.rs @@ -27,14 +27,13 @@ use super::trap_wait::wait_for_answer; pub fn redirect(nr: u64, args: [u64; 6], frame: &[u64; FRAME_WORDS]) -> Option { let pid = crate::process::current_pid()?; let supervisor = registry::supervisor_of(pid)?; + super::interrupt::on_call(pid); /* * The frame is reachable only while this call is on the stack, and a fork - * asks for it long afterwards, so it is copied into the control block now. + * asks for it long afterwards, so it is copied aside now. */ let saved = capture(frame, super::frame_cpu::user_rsp()); - crate::process::with_process(pid, |pcb| { - *pcb.saved_user_context.lock() = Some(saved); - }); + super::trap_frame::keep(pid, saved); if !park(ForeignFrame::new(pid, nr, args, saved.rip)) { return Some(super::trap_reply::ABANDONED); } diff --git a/src/process/foreign/trap_frame.rs b/src/process/foreign/trap_frame.rs new file mode 100644 index 0000000000..ffe89c3d1a --- /dev/null +++ b/src/process/foreign/trap_frame.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 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_reply.rs b/src/process/foreign/trap_reply.rs index afb1b5ba47..255d4d03a1 100644 --- a/src/process/foreign/trap_reply.rs +++ b/src/process/foreign/trap_reply.rs @@ -18,7 +18,7 @@ use super::peer_guard::pid_arg; use super::registry; -use super::trap_table::PARKED; +use super::trap_table::{Answer, PARKED}; use crate::syscall::microkernel::errnos::{ERRNO_INVAL, ERRNO_NOENT, ERRNO_PERM}; // A guest whose supervisor died is not left asleep forever and is not told its @@ -38,17 +38,17 @@ pub fn sys_foreign_reply(pid: u64, value: u64) -> i64 { if registry::supervisor_of(pid) != Some(caller) { return ERRNO_PERM; } - answer_raw(pid, value) + answer_raw(pid, Answer::Value(value)) } /// Hand a parked guest its value and wake it. The permission check is /// the caller's: `exec` has made it already, on the same terms. -pub(super) fn answer_raw(pid: u32, value: u64) -> i64 { +pub(super) fn answer_raw(pid: u32, answer: Answer) -> i64 { let mut parked = PARKED.lock(); let Some(entry) = parked.iter_mut().find(|p| p.frame.pid == pid && p.answer.is_none()) else { return ERRNO_NOENT; }; - entry.answer = Some(value); + entry.answer = Some(answer); drop(parked); crate::sched::wake_process(pid); 0 @@ -58,13 +58,14 @@ pub(super) fn answer_raw(pid: u32, value: u64) -> i64 { /// pid cannot collect an answer left behind by its predecessor. pub(super) fn forget(pid: u32) { PARKED.lock().retain(|p| p.frame.pid != pid); + super::signal_enter::forget(pid); } /// Release every frame belonging to a guest whose supervisor has gone. pub(super) fn abandon(pid: u32) { let mut parked = PARKED.lock(); for entry in parked.iter_mut().filter(|p| p.frame.pid == pid) { - entry.answer = Some(ABANDONED); + entry.answer = Some(Answer::Value(ABANDONED)); } drop(parked); crate::sched::wake_process(pid); diff --git a/src/process/foreign/trap_table.rs b/src/process/foreign/trap_table.rs index 1027858bc4..024963bd89 100644 --- a/src/process/foreign/trap_table.rs +++ b/src/process/foreign/trap_table.rs @@ -24,10 +24,21 @@ use spin::Mutex; use super::frame::ForeignFrame; use super::registry; +/// What wakes a parked guest. Exec is its own case, not a reserved value: +/// every u64 is some syscall's honest answer, and u64::MAX is -EPERM. A +/// signal is a whole context: a handler to enter, or the frame it returns to. +#[derive(Clone, Copy)] +pub(super) enum Answer { + Value(u64), + Execed, + Deliver(crate::arch::context::SavedUser), + Sigreturn(crate::arch::context::SavedUser), +} + pub(super) struct Parked { pub frame: ForeignFrame, /// Set by the supervisor's reply, read by the guest on wake. - pub answer: Option, + pub answer: Option, /// Taken by the first supervisor wait that claims it. pub claimed: bool, } @@ -48,8 +59,13 @@ pub(super) fn park(frame: ForeignFrame) -> bool { false } +/// Whether `pid` is parked in a call no answer has reached yet. +pub(super) fn is_parked(pid: u32) -> bool { + PARKED.lock().iter().any(|p| p.frame.pid == pid && p.answer.is_none()) +} + /// The answer for `pid`, removing the entry once it is taken. -pub(super) fn take_answer(pid: u32) -> Option { +pub(super) fn take_answer(pid: u32) -> Option { let mut parked = PARKED.lock(); let at = parked.iter().position(|p| p.frame.pid == pid)?; let value = parked[at].answer?; diff --git a/src/process/foreign/trap_wait.rs b/src/process/foreign/trap_wait.rs index 8beb8726a9..c5ad2b8d46 100644 --- a/src/process/foreign/trap_wait.rs +++ b/src/process/foreign/trap_wait.rs @@ -16,26 +16,38 @@ //! A guest asleep inside the syscall it made. -use super::trap_table::take_answer; +use super::trap_table::{take_answer, Answer}; pub(super) fn wait_for_answer(pid: u32) -> u64 { + settle(pid, wait_raw(pid)) +} + +/// Sleep until the supervisor answers, and take the answer as it is. +pub(super) fn wait_raw(pid: u32) -> Answer { loop { - if let Some(value) = take_answer(pid) { - return settle(pid, value); + if let Some(answer) = take_answer(pid) { + super::trap_frame::drop_frame(pid); + return answer; } let token = crate::sched::wake_token(pid); - if let Some(value) = take_answer(pid) { - return settle(pid, value); + if let Some(answer) = take_answer(pid) { + super::trap_frame::drop_frame(pid); + return answer; } crate::sched::sleep_until_unless_woken(pid, u64::MAX, token); crate::sched::yield_now(); } } -/// Every answer but one is a return value. -fn settle(pid: u32, value: u64) -> u64 { - if value == super::exec::EXECED { - super::exec_enter::enter(pid) +/// A value returns; exec and a signal leave by a context of their own. +fn settle(pid: u32, answer: Answer) -> u64 { + match answer { + Answer::Value(value) => value, + Answer::Execed => { + super::signal_enter::forget(pid); + super::exec_enter::enter(pid) + } + Answer::Deliver(ctx) => super::signal_enter::deliver(pid, ctx), + Answer::Sigreturn(ctx) => super::signal_enter::sigreturn(pid, ctx), } - value } diff --git a/src/process/foreign/wait.rs b/src/process/foreign/wait.rs index 8b68618230..4277fc7e19 100644 --- a/src/process/foreign/wait.rs +++ b/src/process/foreign/wait.rs @@ -39,8 +39,8 @@ pub fn sys_foreign_wait(out_ptr: u64, out_len: u64, timeout_ms: u64) -> i64 { } let start = crate::time::timestamp_millis(); loop { - if let Some(frame) = trap_claim::claim_next(caller) { - return deliver(out_ptr, frame, size); + if let Some(frame) = next_delivery(caller) { + return deliver_or_repost(caller, out_ptr, frame, size); } let waited = crate::time::timestamp_millis().saturating_sub(start); if timeout_ms > 0 && waited >= timeout_ms { @@ -48,8 +48,8 @@ pub fn sys_foreign_wait(out_ptr: u64, out_len: u64, timeout_ms: u64) -> i64 { } let deadline = if timeout_ms == 0 { u64::MAX } else { start.saturating_add(timeout_ms) }; let token = crate::sched::wake_token(caller); - if let Some(frame) = trap_claim::claim_next(caller) { - return deliver(out_ptr, frame, size); + if let Some(frame) = next_delivery(caller) { + return deliver_or_repost(caller, out_ptr, frame, size); } crate::sched::sleep_until_unless_woken(caller, deadline, token); crate::sched::yield_now(); @@ -58,6 +58,26 @@ pub fn sys_foreign_wait(out_ptr: u64, out_len: u64, timeout_ms: u64) -> i64 { /// Hand one claimed frame over, or give it back when the supervisor's buffer /// will not take it. +// A claimed guest call if one is waiting, else a one-shot death notice built +// into a frame the supervisor recognises by its nr. +fn next_delivery(caller: u32) -> Option { + if let Some(frame) = trap_claim::claim_next(caller) { + return Some(frame); + } + let (pid, code) = super::notice::take(caller)?; + Some(ForeignFrame::new(pid, super::frame::NR_DIED, [code as u64, 0, 0, 0, 0, 0], 0)) +} + +// A death notice is not in the parked table, so a failed write cannot be +// recovered by unclaiming it; re-post it so the death is not lost to a hang. +fn deliver_or_repost(caller: u32, out_ptr: u64, frame: ForeignFrame, size: usize) -> i64 { + let rc = deliver(out_ptr, frame, size); + if rc < 0 && frame.nr == super::frame::NR_DIED { + super::notice::post(caller, frame.pid, frame.arg0 as i32); + } + rc +} + fn deliver(out_ptr: u64, frame: ForeignFrame, size: usize) -> i64 { if write_user_value(out_ptr, &frame).is_err() { trap_claim::unclaim(frame.pid); diff --git a/src/process/userspace/types.rs b/src/process/userspace/types.rs index fae4b900b3..07b1796523 100644 --- a/src/process/userspace/types.rs +++ b/src/process/userspace/types.rs @@ -66,81 +66,85 @@ impl Default for KernelStack { } } -#[derive(Clone)] +// The x86_64 area holds every XSAVE component the boot CPU enabled. +#[cfg(target_arch = "x86_64")] +const FPU_AREA: usize = crate::arch::x86_64::cpu::xstate::AREA; +#[cfg(not(target_arch = "x86_64"))] +const FPU_AREA: usize = 1024; + +// Never on a stack: the area is 4 KiB and 64-byte aligned, and a kernel stack +// that held one per switch overflowed. `new` builds it on the heap, zeroed. #[repr(C, align(64))] pub struct FpuState { - pub data: [u8; 1024], + pub data: [u8; FPU_AREA], } impl FpuState { pub fn new() -> Box { - Box::new(Self { data: [0; 1024] }) + // SAFETY: eK@nonos.systems - FpuState is plain bytes, so all zeros is a + // valid value, and a zeroed area is also a clear XSAVE header. + unsafe { Box::::new_zeroed().assume_init() } } #[inline(always)] pub fn save(&mut self) { - // SAFETY: FXSAVE saves the FPU/SSE state to a 512-byte memory region. - // self.data is 1024 bytes and 64-byte aligned (repr(C, align(64))), which - // exceeds the 16-byte alignment requirement for FXSAVE. The nostack option - // is correct as FXSAVE only writes to the provided memory location. - // FXSAVE writes the x86_64 legacy area. The aarch64 register file has a - // different shape and is saved by `arch::aarch64::fpu`, so this body is - // the one architecture that uses this layout. + // XSAVE over every component XCR0 enables when the boot CPU turned it + // on, FXSAVE otherwise. The aarch64 register file is saved by + // `arch::aarch64::fpu`, so this body is x86_64 only. #[cfg(target_arch = "x86_64")] - // SAFETY: the destination is this struct is own 512-byte align(64) - // buffer, which meets FXSAVE is alignment and size requirement. + // SAFETY: eK@nonos.systems - `data` is AREA bytes, 64-byte aligned by + // repr(align(64)), and `record_boot` keeps the enabled components within + // AREA. The area is zeroed when made, so the XSAVE header starts clear. unsafe { - core::arch::asm!( - "fxsave [{}]", - in(reg) self.data.as_mut_ptr(), - options(nostack, preserves_flags) - ); + if crate::arch::x86_64::cpu::xstate::uses_xsave() { + core::arch::asm!("xsave64 [{}]", in(reg) self.data.as_mut_ptr(), + in("eax") u32::MAX, in("edx") u32::MAX, options(nostack, preserves_flags)); + } else { + core::arch::asm!("fxsave64 [{}]", in(reg) self.data.as_mut_ptr(), + options(nostack, preserves_flags)); + } } } #[inline(always)] pub fn restore(&self) { - // SAFETY: FXRSTOR restores FPU/SSE state from a 512-byte memory region. - // self.data must have been previously populated by save() or be zeroed. - // The alignment requirement (16 bytes) is satisfied by our align(64) repr. - // The nostack option is correct as FXRSTOR only reads from memory. #[cfg(target_arch = "x86_64")] - // SAFETY: reads back the same buffer `save` wrote, at the same alignment. + // SAFETY: eK@nonos.systems - reads back an area `save` wrote on a CPU + // with the same XCR0, which `mirror_on_ap` guarantees for every CPU. unsafe { - core::arch::asm!( - "fxrstor [{}]", - in(reg) self.data.as_ptr(), - options(nostack, preserves_flags) - ); + if crate::arch::x86_64::cpu::xstate::uses_xsave() { + core::arch::asm!("xrstor64 [{}]", in(reg) self.data.as_ptr(), + in("eax") u32::MAX, in("edx") u32::MAX, options(nostack, preserves_flags)); + } else { + core::arch::asm!("fxrstor64 [{}]", in(reg) self.data.as_ptr(), + options(nostack, preserves_flags)); + } } } - // Architectural default FPU/SSE state for a fresh thread. FNINIT sets - // FCW=0x037F; MXCSR must be 0x1F80 (all SIMD exceptions masked, round to - // nearest). Restoring a zeroed FXSAVE image instead leaves MXCSR=0, which - // unmasks every SIMD exception and makes the first inexact result trap. + /// A new thread's unit: every register zero, FCW 0x037F, MXCSR 0x1F80. + /// FNINIT and LDMXCSR alone left xmm and the ymm upper halves holding the + /// last thread's values, which the state suite read from a sibling. #[inline(always)] pub fn init() { - let mxcsr: u32 = 0x1F80; - // x87 and SSE control words, so this is the x86_64 unit. The aarch64 - // FPCR is set where that FPU is brought up. - #[cfg(target_arch = "x86_64")] - // SAFETY: FNINIT resets the x87 unit; LDMXCSR loads the SSE control word - // from the 4-byte `mxcsr` local. Neither touches the stack. - unsafe { - core::arch::asm!( - "fninit", - "ldmxcsr [{}]", - in(reg) &mxcsr as *const u32, - options(nostack), - ); - } + CLEAN.restore(); } } -impl Default for FpuState { - fn default() -> Self { - Self { data: [0; 1024] } +/// The initial state, as an area to restore: control words set, registers +/// zero, and an XSAVE header whose empty XSTATE_BV puts every component the +/// area covers in its initial configuration. MXCSR is 0x1F80, every SIMD +/// exception masked: an all-zero area would unmask them all. +static CLEAN: FpuState = FpuState::clean(); + +impl FpuState { + const fn clean() -> Self { + let mut data = [0u8; FPU_AREA]; + data[0] = 0x7F; + data[1] = 0x03; + data[24] = 0x80; + data[25] = 0x1F; + Self { data } } } 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..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}; -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/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. 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/hardening/memory_sanitization/guard.rs b/src/security/hardening/memory_sanitization/guard.rs deleted file mode 100644 index 16336ec0df..0000000000 --- a/src/security/hardening/memory_sanitization/guard.rs +++ /dev/null @@ -1,61 +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 super::erase::sanitize; - -pub struct GuardPage { - pub address: u64, - pub size: usize, -} - -pub fn allocate_with_guards(size: usize) -> Option<(*mut u8, GuardPage, GuardPage)> { - const PAGE_SIZE: usize = 4096; - let aligned_size = (size + PAGE_SIZE - 1) & !(PAGE_SIZE - 1); - let total_size = aligned_size + PAGE_SIZE * 2; - - let base = crate::memory::phys::alloc_contiguous( - total_size / PAGE_SIZE, - crate::memory::phys::AllocFlags::ZERO, - )?; - let base_ptr = base as *mut u8; - - let guard_low = GuardPage { address: base, size: PAGE_SIZE }; - - let guard_high = - GuardPage { address: base + aligned_size as u64 + PAGE_SIZE as u64, size: PAGE_SIZE }; - - let _ = crate::memory::paging::manager::unmap_page(crate::memory::addr::VirtAddr::new( - guard_low.address, - )); - let _ = crate::memory::paging::manager::unmap_page(crate::memory::addr::VirtAddr::new( - guard_high.address, - )); - - // SAFETY: base_ptr is valid, adding PAGE_SIZE keeps us within allocation - let data_ptr = unsafe { base_ptr.add(PAGE_SIZE) }; - - Some((data_ptr, guard_low, guard_high)) -} - -pub fn free_with_guards(ptr: *mut u8, size: usize, guard_low: GuardPage, _guard_high: GuardPage) { - sanitize(ptr, size); - - const PAGE_SIZE: usize = 4096; - let aligned_size = (size + PAGE_SIZE - 1) & !(PAGE_SIZE - 1); - let total_pages = (aligned_size + PAGE_SIZE * 2) / PAGE_SIZE; - - let _ = crate::memory::phys::free_contiguous(guard_low.address, total_pages); -} diff --git a/src/security/hardening/memory_sanitization/mod.rs b/src/security/hardening/memory_sanitization/mod.rs index 9b74240b16..bd0ed8d294 100644 --- a/src/security/hardening/memory_sanitization/mod.rs +++ b/src/security/hardening/memory_sanitization/mod.rs @@ -18,7 +18,6 @@ pub mod api; pub mod canary; pub mod containers; pub mod erase; -pub mod guard; mod kernel_stacks; pub mod primitives; #[cfg(target_arch = "x86_64")] @@ -37,5 +36,4 @@ pub use erase::{ dod_5220_erase, gutmann_erase, paranoid_erase, sanitize, sanitize_slice, secure_zero, secure_zero_slice, }; -pub use guard::{allocate_with_guards, free_with_guards, GuardPage}; pub use types::{SanitizationLevel, SanitizationStats, StackCanaryConfig}; diff --git a/src/security/hardening/mod.rs b/src/security/hardening/mod.rs index e97c1f930c..8183910a12 100644 --- a/src/security/hardening/mod.rs +++ b/src/security/hardening/mod.rs @@ -36,12 +36,11 @@ pub use spectre_mitigations::{ }; pub use memory_sanitization::{ - allocate_with_guards, dod_5220_erase, free_with_guards, get_level, get_stack_canary, - gutmann_erase, init as memory_sanitization_init, init_stack_canary, on_free, on_realloc, - paranoid_erase, sanitization_stats, sanitize, sanitize_process_memory, sanitize_slice, - secure_zero, secure_zero_slice, set_level, stack_canary_failed, verify_stack_canary, - zerostate_shutdown_wipe, GuardPage, SanitizationLevel, SanitizationStats, SecureString, - SensitiveData, StackCanaryConfig, + dod_5220_erase, get_level, get_stack_canary, gutmann_erase, init as memory_sanitization_init, + init_stack_canary, on_free, on_realloc, paranoid_erase, sanitization_stats, sanitize, + sanitize_process_memory, sanitize_slice, secure_zero, secure_zero_slice, set_level, + stack_canary_failed, verify_stack_canary, zerostate_shutdown_wipe, SanitizationLevel, + SanitizationStats, SecureString, SensitiveData, StackCanaryConfig, }; pub use memory_encryption::{ 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/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/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/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..8b18804926 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(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 /// 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/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 208bbfbf66..de7fadb652 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,10 +35,19 @@ 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 { - 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()); @@ -66,5 +70,6 @@ pub fn install_ready(listing_id: &str, release_id: &str) -> Result. + +//! 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/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) } diff --git a/src/services/registry.rs b/src/services/registry.rs index 9a01b81039..eab95be856 100644 --- a/src/services/registry.rs +++ b/src/services/registry.rs @@ -22,12 +22,15 @@ mod adopt; mod auth; mod endpoint; mod error; +mod peers; +mod peers_check; mod policy; mod reserved; pub(crate) use adopt::adopt_endpoint; pub use endpoint::ServiceEndpoint; pub use error::RegError; +pub use peers_check::{caller_may_reach, caller_may_reach_pid}; pub use policy::required_caps; pub(crate) use reserved::{is_reserved_service, is_runtime_registrable}; diff --git a/src/services/registry/peers.rs b/src/services/registry/peers.rs new file mode 100644 index 0000000000..fd4777a354 --- /dev/null +++ b/src/services/registry/peers.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 . + +//! Which peers a capsule may reach over IPC, for the capsules that are held +//! to a list. +//! +//! A capability says what kind of thing a capsule may do; it cannot say who +//! it may do it with. A capsule on this list reaches the endpoints named for +//! it and nothing else, whatever its capabilities admit, and a capsule not +//! on it is unaffected. The table lives in the kernel image, so it is +//! measured with it, and the capsule format stays as it is. + +/// Capsule name, then the endpoint names it may send to. +const PEERS: &[(&str, &[&str])] = &[ + // The Shield prover holds a witness. Its one peer is the core that sent + // it, so it reaches no network, no storage and no other capsule. + ("shield_prover", &["shield.core"]), +]; + +/// The list a capsule is held to, or None when it is not held to one. +pub fn peers_of(caller: &str) -> Option<&'static [&'static str]> { + PEERS.iter().find(|(name, _)| *name == caller).map(|(_, peers)| *peers) +} + +/// Whether `caller` may send to the endpoint called `target`. +pub fn may_reach(caller: &str, target: &str) -> bool { + peers_of(caller).is_none_or(|peers| peers.contains(&target)) +} diff --git a/src/services/registry/peers_check.rs b/src/services/registry/peers_check.rs new file mode 100644 index 0000000000..76572af726 --- /dev/null +++ b/src/services/registry/peers_check.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 peer list, applied to the calling process. + +use super::lookup_service; +use super::peers::{may_reach, peers_of}; + +fn caller_name() -> Option { + let pid = crate::process::current_pid()?; + crate::process::with_process(pid, |pcb| pcb.name()) +} + +/// Whether the caller may send to the endpoint called `target`. A caller +/// with no name is held to nothing it could be named in. +pub fn caller_may_reach(target: &str) -> bool { + caller_name().is_none_or(|name| may_reach(&name, target)) +} + +/// Whether the caller may send straight to `dest`'s own inbox: only when +/// `dest` serves one of the endpoints on the caller's list. +pub fn caller_may_reach_pid(dest: u32) -> bool { + let Some(name) = caller_name() else { + return true; + }; + let Some(peers) = peers_of(&name) else { + return true; + }; + peers.iter().any(|p| lookup_service(p).is_some_and(|ep| ep.pid == dest)) +} diff --git a/src/smp/ap/entry.rs b/src/smp/ap/entry.rs index ad1d93d991..e6dd8cd631 100644 --- a/src/smp/ap/entry.rs +++ b/src/smp/ap/entry.rs @@ -51,6 +51,11 @@ pub unsafe extern "C" fn ap_entry(cpu_id: u32) { } crate::arch::set_percpu_base(crate::smp::percpu::current().self_ptr); + // The trampoline leaves CR4.OSXSAVE clear and XCR0 unset, so AVX code + // faulted here and an area saved on the boot CPU could not be restored. + // SAFETY: eK@nonos.systems - once, during this AP's bring-up, interrupts off. + unsafe { crate::arch::x86_64::cpu::xstate::mirror_on_ap() }; + // Its own block: the slot was handed to this CPU and is never reused. let _ = crate::arch::x86_64::gdt::arm_ap_guards(cpu_id); diff --git a/src/syscall/abi/registry/mk.rs b/src/syscall/abi/registry/mk.rs index 171c9ec52e..5158a74af3 100644 --- a/src/syscall/abi/registry/mk.rs +++ b/src/syscall/abi/registry/mk.rs @@ -95,6 +95,9 @@ pub(super) const ENTRIES: &[AbiEntry] = &[ e(b"MFST", SyscallNumber::MkForeignStart, "MkForeignStart"), e(b"MFWT", SyscallNumber::MkForeignWait, "MkForeignWait"), e(b"MFRP", SyscallNumber::MkForeignReply, "MkForeignReply"), + e(b"MFCX", SyscallNumber::MkForeignContext, "MkForeignContext"), + e(b"MFSG", SyscallNumber::MkForeignSignal, "MkForeignSignal"), + e(b"MFIN", SyscallNumber::MkForeignInterrupt, "MkForeignInterrupt"), e(b"MPMP", SyscallNumber::MkPeerMap, "MkPeerMap"), e(b"MPCP", SyscallNumber::MkPeerCopy, "MkPeerCopy"), e(b"MPPT", SyscallNumber::MkPeerProtect, "MkPeerProtect"), @@ -107,6 +110,10 @@ 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"MAPL", SyscallNumber::MkAppLaunch, "MkAppLaunch"), + e(b"MAIS", SyscallNumber::MkAppInstallStatus, "MkAppInstallStatus"), 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..140396f8d7 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(), @@ -138,6 +140,9 @@ pub(super) fn check(caps: &CapabilityToken, number: SyscallNumber) -> Option Option caps.can_app_install(), + SyscallNumber::MkAppInstall + | SyscallNumber::MkAppLaunch + | SyscallNumber::MkAppInstallStatus => caps.can_app_install(), SyscallNumber::MkSurfaceRegister | SyscallNumber::MkSurfaceShare 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, diff --git a/src/syscall/dispatch/router/microkernel_ops.rs b/src/syscall/dispatch/router/microkernel_ops.rs index a01bb907f7..5693d5f52b 100644 --- a/src/syscall/dispatch/router/microkernel_ops.rs +++ b/src/syscall/dispatch/router/microkernel_ops.rs @@ -87,6 +87,9 @@ pub(super) fn matches(nr: SyscallNumber) -> bool { | MkForeignStart | MkForeignWait | MkForeignReply + | MkForeignContext + | MkForeignSignal + | MkForeignInterrupt | MkPeerMap | MkPeerCopy | MkPeerProtect @@ -99,6 +102,10 @@ pub(super) fn matches(nr: SyscallNumber) -> bool { | MkLocalVerify | MkAppInstall | MkDevRootLocal + | MkLocalConsent + | MkLocalRestore + | MkAppLaunch + | MkAppInstallStatus | MkToolRun ) } diff --git a/src/syscall/microkernel/app_install.rs b/src/syscall/microkernel/app_install.rs index 9e0d12ec33..782b45232b 100644 --- a/src/syscall/microkernel/app_install.rs +++ b/src/syscall/microkernel/app_install.rs @@ -14,39 +14,62 @@ // 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 id, short enough not to 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; +/// 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 { + 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. +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); + } + 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/app_install_status.rs b/src/syscall/microkernel/app_install_status.rs new file mode 100644 index 0000000000..ba5885f620 --- /dev/null +++ b/src/syscall/microkernel/app_install_status.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 . + +//! `MkAppInstallStatus`: where an install this caller may ask for stands. +//! A store shows it; asking changes nothing. + +use crate::syscall::microkernel::errnos::ERRNO_INVAL; +use crate::userspace::init::{install_stage, Stage}; + +use super::app_install::id; + +/// 0 nothing asked, 1 queued, 2 installing, 3 installed, 4 refused before it +/// started, and 16 plus the installer's reason code when it failed. +pub fn sys_app_install_status(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, + }; + match install_stage(&listing) { + None => 0, + Some(Stage::Queued) => 1, + Some(Stage::Running(_)) => 2, + Some(Stage::Installed) => 3, + Some(Stage::Refused) => 4, + Some(Stage::Failed(code)) => 16 + i64::from(code.clamp(0, 255)), + } +} diff --git a/src/syscall/microkernel/app_launch.rs b/src/syscall/microkernel/app_launch.rs new file mode 100644 index 0000000000..148841334e --- /dev/null +++ b/src/syscall/microkernel/app_launch.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 . + +//! `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.").and_then(crate::userspace::capsule_linux::package_arg) + else { + return ERRNO_INVAL; + }; + match crate::userspace::init::request_run(name) { + true => 0, + false => ERRNO_BUSY, + } +} 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, } } diff --git a/src/syscall/microkernel/dispatch/process.rs b/src/syscall/microkernel/dispatch/process.rs index 2ef1a136e4..1bbf3230dc 100644 --- a/src/syscall/microkernel/dispatch/process.rs +++ b/src/syscall/microkernel/dispatch/process.rs @@ -16,10 +16,14 @@ use super::args::Args; use crate::process::foreign::{ - sys_foreign_exec, sys_foreign_fork, sys_foreign_reply, sys_foreign_spawn, sys_foreign_start, + sys_foreign_context, sys_foreign_exec, sys_foreign_fork, sys_foreign_interrupt, + sys_foreign_reply, sys_foreign_signal, sys_foreign_spawn, sys_foreign_start, 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::app_install_status::sys_app_install_status; +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; @@ -27,13 +31,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; @@ -87,18 +91,25 @@ pub(super) fn handle(nr: u64, a: Args) -> Option { SYS_FOREIGN_START => sys_foreign_start(a.a0, a.a1, a.a2), SYS_FOREIGN_WAIT => sys_foreign_wait(a.a0, a.a1, a.a2), SYS_FOREIGN_REPLY => sys_foreign_reply(a.a0, a.a1), + SYS_FOREIGN_CONTEXT => sys_foreign_context(a.a0, a.a1), + SYS_FOREIGN_SIGNAL => sys_foreign_signal(a.a0, a.a1, a.a2), + SYS_FOREIGN_INTERRUPT => sys_foreign_interrupt(a.a0), SYS_PEER_MAP => sys_peer_map(a.a0, a.a1, a.a2, a.a3), SYS_PEER_COPY => sys_peer_copy(a.a0, a.a1, a.a2, a.a3, a.a4), SYS_PEER_PROTECT => sys_peer_protect(a.a0, a.a1, a.a2, a.a3), - SYS_FOREIGN_THREAD => sys_foreign_thread(a.a0, a.a1, a.a2, a.a3), + SYS_FOREIGN_THREAD => sys_foreign_thread(a.a0, a.a1, a.a2, a.a3, a.a4), SYS_PEER_TLS => sys_peer_tls(a.a0, a.a1), SYS_FOREIGN_FORK => sys_foreign_fork(a.a0), SYS_PEER_UNMAP => sys_peer_unmap(a.a0, a.a1, a.a2), 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_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_APP_INSTALL_STATUS => sys_app_install_status(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/ipc/send.rs b/src/syscall/microkernel/ipc/send.rs index 67f8a007e8..a643313eab 100644 --- a/src/syscall/microkernel/ipc/send.rs +++ b/src/syscall/microkernel/ipc/send.rs @@ -82,7 +82,9 @@ pub(super) fn send_with_correlation(endpoint: u64, buf: u64, len: usize, correla * would wake on its own. */ Redirect::ToCaller { caller_inbox, caller_pid, token } => { - if !super::send_caps::caller_satisfies_endpoint(endpoint, &caller_inbox) { + if !super::send_caps::caller_satisfies_endpoint(endpoint, &caller_inbox) + || !crate::services::registry::caller_may_reach_pid(caller_pid) + { return ERRNO_PERM; } trace(pid, endpoint, &caller_inbox, len); @@ -130,7 +132,9 @@ pub(super) fn send_with_correlation(endpoint: u64, buf: u64, len: usize, correla * (0 for sys_ipc_send, all a forged reply injection can carry). */ Redirect::AsAddressed => { - if !super::send_caps::caller_satisfies_endpoint(endpoint, &target) { + if !super::send_caps::caller_satisfies_endpoint(endpoint, &target) + || !crate::services::registry::caller_may_reach(&target) + { return ERRNO_PERM; } trace(pid, endpoint, &target, len); diff --git a/src/syscall/microkernel/ipc/send_to_pid.rs b/src/syscall/microkernel/ipc/send_to_pid.rs index 1658cef6b9..060b2eca68 100644 --- a/src/syscall/microkernel/ipc/send_to_pid.rs +++ b/src/syscall/microkernel/ipc/send_to_pid.rs @@ -56,6 +56,10 @@ pub fn sys_ipc_send_to_pid(dest_pid: u64, buf: u64, len: usize) -> i64 { return ERRNO_FAULT; } let caller_pid = current_pid().unwrap_or(0); + // A capsule held to a peer list reaches only the inboxes of its peers. + if !crate::services::registry::caller_may_reach_pid(dest_pid as u32) { + return crate::syscall::microkernel::errnos::ERRNO_PERM; + } trace(caller_pid, dest_pid, len); let dest = alloc::format!("proc.{}", dest_pid as u32); let from = alloc::format!("proc.{}", caller_pid); diff --git a/src/syscall/microkernel/kill.rs b/src/syscall/microkernel/kill.rs index 5274cf1f23..8ab3ac8bc6 100644 --- a/src/syscall/microkernel/kill.rs +++ b/src/syscall/microkernel/kill.rs @@ -32,6 +32,13 @@ pub fn sys_kill(pid: u64, sig: u64) -> i64 { // unrelated pid needs the ProcessControl capability, held only by the // process manager, so a compromised app cannot terminate other capsules. let is_parent = caller != 0 && get_parent_pid(target) == Some(caller); + /* + * A foreign supervisor ends the guests it hosts. A guest thread's parent + * is its group leader, not the supervisor, so without this a guest's + * exit left its threads running, and once the supervisor was gone they + * ran on with no one to answer their calls. + */ + let supervises = caller != 0 && crate::process::foreign::supervisor_of(target) == Some(caller); let controls = caller != 0 && with_process(caller, |pcb| { pcb.caps_bits.load(core::sync::atomic::Ordering::Relaxed) @@ -39,7 +46,7 @@ pub fn sys_kill(pid: u64, sig: u64) -> i64 { != 0 }) .unwrap_or(false); - if !is_parent && !controls { + if !is_parent && !supervises && !controls { return ERRNO_PERM; } if !pid_alive(target) { 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..fe29cae341 100644 --- a/src/syscall/microkernel/mod.rs +++ b/src/syscall/microkernel/mod.rs @@ -39,8 +39,11 @@ pub mod ipc; pub mod irq; pub mod kill; pub mod app_install; +pub mod app_install_status; +pub mod app_launch; pub mod enrol_local_root; mod local_image; +pub mod local_consent; pub mod local_sign; pub mod local_verify; pub mod memory; @@ -57,6 +60,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/numbers.rs b/src/syscall/microkernel/numbers.rs index a724b33a31..30d9134cce 100644 --- a/src/syscall/microkernel/numbers.rs +++ b/src/syscall/microkernel/numbers.rs @@ -74,6 +74,13 @@ pub const SYS_FOREIGN_START: u64 = tag4(b"MFST"); pub const SYS_FOREIGN_WAIT: u64 = tag4(b"MFWT"); /// Answer one parked guest with the value its `rax` receives. pub const SYS_FOREIGN_REPLY: u64 = tag4(b"MFRP"); +/// Copy a parked guest's registers out, in `struct sigcontext` order. +pub const SYS_FOREIGN_CONTEXT: u64 = tag4(b"MFCX"); +/// Answer a parked guest with a context: a signal handler, or its return. +pub const SYS_FOREIGN_SIGNAL: u64 = tag4(b"MFSG"); +/// Stop a guest thread that is running its own code at its next timer tick, +/// and hand it over parked, so a signal can be delivered to it. +pub const SYS_FOREIGN_INTERRUPT: u64 = tag4(b"MFIN"); /// Back a span of a guest's address space with fresh frames. pub const SYS_PEER_MAP: u64 = tag4(b"MPMP"); /// Copy bytes between the caller and a guest it supervises. @@ -98,6 +105,14 @@ 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"); +/// Start the program a distribution package installed. +pub const SYS_APP_LAUNCH: u64 = tag4(b"MAPL"); +/// Where an asked-for install stands. +pub const SYS_APP_INSTALL_STATUS: u64 = tag4(b"MAIS"); /// 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/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/syscall/microkernel/spawn_instance.rs b/src/syscall/microkernel/spawn_instance.rs index 8e1214344e..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,22 +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) { - let _irq = crate::interrupts::disable_interrupts_guard(); - *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/syscall/numbers/defs.rs b/src/syscall/numbers/defs.rs index 54bdf98363..084e1b7e99 100644 --- a/src/syscall/numbers/defs.rs +++ b/src/syscall/numbers/defs.rs @@ -116,6 +116,9 @@ pub enum SyscallNumber { MkForeignStart = tag4(b"MFST"), MkForeignWait = tag4(b"MFWT"), MkForeignReply = tag4(b"MFRP"), + MkForeignContext = tag4(b"MFCX"), + MkForeignSignal = tag4(b"MFSG"), + MkForeignInterrupt = tag4(b"MFIN"), MkPeerMap = tag4(b"MPMP"), MkPeerCopy = tag4(b"MPCP"), MkPeerProtect = tag4(b"MPPT"), @@ -128,4 +131,8 @@ pub enum SyscallNumber { MkLocalVerify = tag4(b"MLVF"), MkAppInstall = tag4(b"MAIN"), MkDevRootLocal = tag4(b"MDRO"), + MkLocalConsent = tag4(b"MLCG"), + MkLocalRestore = tag4(b"MLCR"), + MkAppLaunch = tag4(b"MAPL"), + MkAppInstallStatus = tag4(b"MAIS"), } 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/userland/capsule_linux/src/linux/install/provenance.rs b/src/userspace/capsule_app_store/state.rs similarity index 69% rename from userland/capsule_linux/src/linux/install/provenance.rs rename to src/userspace/capsule_app_store/state.rs index 3750b5ccd9..f18bb639bc 100644 --- a/userland/capsule_linux/src/linux/install/provenance.rs +++ b/src/userspace/capsule_app_store/state.rs @@ -14,13 +14,14 @@ // 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. +use crate::services::lifecycle::CapsuleState; -#[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, +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/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/src/userspace/capsule_linux/family.rs b/src/userspace/capsule_linux/family.rs new file mode 100644 index 0000000000..072fb562a2 --- /dev/null +++ b/src/userspace/capsule_linux/family.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 . + +//! A market listing names a Linux package as `linux.`. Alpine's are +//! bare, `linux.jq`; another distribution's sit under a namespace that is +//! its own, `linux.kali.jq` or `linux.blackarch.nmap`. The personality is +//! told the family in the prefix it reads, `deb:` or `pacman:`, and each +//! family installs into and runs from a tree of its own. + +use alloc::string::String; + +/// Listing namespace, and the prefix the personality knows the family by. +/// The catalogue refuses an Alpine name that begins with a namespace, so a +/// tail is never read as the wrong family. +const NAMESPACES: [(&str, &str); 2] = [("kali.", "deb:"), ("blackarch.", "pacman:")]; + +/// The name the personality is given for listing tail `tail`, or `None` +/// when a namespace names no package. +pub fn package_arg(tail: &str) -> Option { + for (space, prefix) in NAMESPACES { + if let Some(pkg) = tail.strip_prefix(space) { + return usable(pkg).then(|| alloc::format!("{prefix}{pkg}")); + } + } + usable(tail).then(|| String::from(tail)) +} + +// A colon is the personality's family separator, so a tail carrying one +// could name a family its namespace does not. Listing ids have no colon; +// this does not rely on that. +fn usable(pkg: &str) -> bool { + !pkg.is_empty() && !pkg.starts_with('.') && !pkg.contains(':') +} diff --git a/src/userspace/capsule_linux/install.rs b/src/userspace/capsule_linux/install.rs index 829f803dc8..455b354aa1 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,31 +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)]) +} -pub fn spawn_install(package: &str) -> Result { +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:", + requested_caps: LINUX_CAPS | role.extra_caps, + debug_tag: role.tag, }; let pid = capsule_spawn::spawn_verified(&spec, &trust_anchor, None)?; - let argv = vec![String::from("install"), String::from(package)]; 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..a18f8a6ada 100644 --- a/src/userspace/capsule_linux/mod.rs +++ b/src/userspace/capsule_linux/mod.rs @@ -18,10 +18,13 @@ //! kernel, and the spawn that admits them. mod embed; +mod family; mod install; +mod roles; mod spawn; mod state; -pub use install::spawn_install; +pub use family::package_arg; +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..85c3c961b8 --- /dev/null +++ b/src/userspace/capsule_linux/roles.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 endpoints the personality answers on in each role it is spawned for. + +use crate::capabilities::Capability; + +/// 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], + /// Optional capabilities this role asks for beyond LINUX_CAPS. The + /// manifest declares them optional, so a role that does not ask runs + /// without them. + pub extra_caps: u64, +} + +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:", + // A package mirror is reached through net.sockets, which serves only + // holders of Network. + extra_caps: Capability::Network.bit(), +}; + +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:", + // A guest's own sockets are the socket model's to grant, not this. + extra_caps: 0, +}; 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/src/userspace/capsule_setup_wizard/spawn.rs b/src/userspace/capsule_setup_wizard/spawn.rs index b8643fcbc3..cc870a31de 100644 --- a/src/userspace/capsule_setup_wizard/spawn.rs +++ b/src/userspace/capsule_setup_wizard/spawn.rs @@ -51,7 +51,9 @@ 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() + | crate::capabilities::serial_debug_cap(), debug_tag: b"", }; let pid = capsule_spawn::spawn_verified(&spec, &trust_anchor, None)?; diff --git a/src/userspace/init/install_queue.rs b/src/userspace/init/install_queue.rs deleted file mode 100644 index 96eb59e036..0000000000 --- a/src/userspace/init/install_queue.rs +++ /dev/null @@ -1,60 +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. - -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); - 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/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/linux_jobs/mod.rs b/src/userspace/init/linux_jobs/mod.rs new file mode 100644 index 0000000000..6fb60b7e9f --- /dev/null +++ b/src/userspace/init/linux_jobs/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 . + + +//! 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. + +mod queue; +mod service; +mod status; +mod why; + +pub(crate) use queue::{has_pending, request_install, request_run}; +pub(crate) use service::service; +pub(crate) use status::{get as install_stage, Stage}; diff --git a/src/userspace/init/linux_jobs/queue.rs b/src/userspace/init/linux_jobs/queue.rs new file mode 100644 index 0000000000..4ad86469e2 --- /dev/null +++ b/src/userspace/init/linux_jobs/queue.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 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 { + let name = listing.clone(); + let queued = push(Job::Install(listing, release)); + if queued { + super::status::set(&name, super::status::Stage::Queued); + } + queued +} + +/// 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..84a4ed0e7c --- /dev/null +++ b/src/userspace/init/linux_jobs/service.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 . + +use crate::security::market_capsule::client::{queued_get_release, queued_install_ready}; +use crate::sys::serial::{print, println}; +use crate::userspace::capsule_linux::{package_arg, spawn_install, spawn_run}; + +use super::queue::{take, Job}; +use super::status::Stage; + +/// 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.").and_then(package_arg) 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 asked = queued_install_ready(listing, release); + let ready = asked.as_ref().is_ok_and(|r| r.install_ready); + let pinned = queued_get_release(listing, release).map(|r| r.package_hash); + super::why::say(&asked, &pinned); + let said: &[u8] = match (ready, pinned.ok()) { + (true, Some(hash)) => match spawn_install(&name, &hash) { + Ok(pid) => { + super::status::set(listing, Stage::Running(pid)); + b"[LINUX-INSTALL] started " + } + Err(e) => { + super::status::set(listing, Stage::Refused); + // Which preflight check refused the installer, not only that one did. + println(alloc::format!("[LINUX-INSTALL] installer refused: {e:?}").as_bytes()); + b"[LINUX-INSTALL] refused " + } + }, + _ => { + super::status::set(listing, Stage::Refused); + b"[LINUX-INSTALL] not ready, refused " + } + }; + print(said); + println(listing.as_bytes()); +} diff --git a/src/userspace/init/linux_jobs/status.rs b/src/userspace/init/linux_jobs/status.rs new file mode 100644 index 0000000000..0f6d358a7d --- /dev/null +++ b/src/userspace/init/linux_jobs/status.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 . + +//! Where each asked-for install stands, for the store to show. Keyed by +//! listing; the running stage resolves to the installer's exit code once +//! that process has ended. + +use alloc::collections::BTreeMap; +use alloc::string::String; + +use spin::Mutex; + +use crate::process::core::{ProcessState, PROCESS_TABLE}; + +/// Enough for every listing a person could ask for in one session. +const CAP: usize = 32; + +#[derive(Clone, Copy)] +pub(crate) enum Stage { + Queued, + Running(u32), + Installed, + /// The installer's exit code: `install::Why` in the personality. + Failed(i32), + /// Init would not start it: the market withdrew it, or the spawn gate refused. + Refused, +} + +static STAGES: Mutex> = Mutex::new(BTreeMap::new()); + +pub(crate) fn set(listing: &str, stage: Stage) { + let mut s = STAGES.lock(); + if s.len() >= CAP && !s.contains_key(listing) { + return; + } + s.insert(String::from(listing), stage); +} + +/// The stage, with a finished installer's result read in and kept. +pub(crate) fn get(listing: &str) -> Option { + let mut s = STAGES.lock(); + let stage = *s.get(listing)?; + let Stage::Running(pid) = stage else { return Some(stage) }; + let ended = match PROCESS_TABLE.find_by_pid(pid) { + Some(pcb) => match *pcb.state.lock() { + ProcessState::Zombie(code) | ProcessState::Terminated(code) => Some(code), + _ => None, + }, + None => Some(crate::process::exit::peek_exit_status(pid).unwrap_or(-1)), + }; + let now = match ended { + None => stage, + Some(0) => Stage::Installed, + Some(code) => Stage::Failed(code), + }; + s.insert(String::from(listing), now); + Some(now) +} diff --git a/src/userspace/init/linux_jobs/why.rs b/src/userspace/init/linux_jobs/why.rs new file mode 100644 index 0000000000..c50dc5b1f5 --- /dev/null +++ b/src/userspace/init/linux_jobs/why.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 . + +//! What the market answered for an install, said before init acts on it: a +//! refusal that only says "not ready" leaves nothing to fix. + +use alloc::format; + +use crate::security::market_capsule::client::InstallReadiness; +use crate::security::market_capsule::MarketError; +use crate::sys::serial::println; + +pub(super) fn say(ready: &Result, pinned: &Result<[u8; 32], MarketError>) { + let r = match ready { + Ok(v) => format!( + "ready={} index={} url={} publisher={} validated={} arch={} attest={}", + v.install_ready as u8, + v.index_signature_valid as u8, + v.package_url_present as u8, + v.publisher_signature_present as u8, + v.validation_passed as u8, + v.arch_match as u8, + v.attestation_present as u8 + ), + Err(e) => format!("ready: {e:?}"), + }; + let p = match pinned { + Ok(_) => "release: pinned", + Err(_) => "release: none", + }; + println(format!("[LINUX-INSTALL] market says {r}, {p}").as_bytes()); +} diff --git a/src/userspace/init/mod.rs b/src/userspace/init/mod.rs index 41f122d1b4..0c14a21f71 100644 --- a/src/userspace/init/mod.rs +++ b/src/userspace/init/mod.rs @@ -16,14 +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::service as service_installs; +pub(crate) use linux_jobs::{install_stage, request_install, request_run, Stage}; +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/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/apps.rs b/src/userspace/init/spawn_plan/apps.rs index d5c5e36bfa..58738b9ec8 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_nonos_install(); spawn_hello(); spawn_calculator(); @@ -52,6 +53,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() {} + // The install ritual is console-only and spawns at boot; an image built // with this feature is a live installer image by definition. #[cfg(feature = "nonos-capsule-nonos-install")] 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..def05ffa85 100644 --- a/src/userspace/init/spawn_plan/orchestrator.rs +++ b/src/userspace/init/spawn_plan/orchestrator.rs @@ -67,8 +67,9 @@ 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(); + super::apps::spawn(); } #[cfg(all(not(feature = "microkernel-input-probe"), not(feature = "microkernel-setup-wizard")))] diff --git a/src/userspace/init/supervisor/loop_impl.rs b/src/userspace/init/supervisor/loop_impl.rs index af11856fac..f9bf4fc5c0 100644 --- a/src/userspace/init/supervisor/loop_impl.rs +++ b/src/userspace/init/supervisor/loop_impl.rs @@ -48,7 +48,8 @@ pub(crate) fn init_loop() -> ! { // 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(); + 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; @@ -80,15 +81,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 priority through the one setter that holds the lock with irqs 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) { - let _irq = crate::interrupts::disable_interrupts_guard(); - *pcb.priority.lock() = p; - } + super::super::set_init_priority(p); } diff --git a/src/userspace/mod.rs b/src/userspace/mod.rs index ddf3d0f79c..b28b1c770b 100644 --- a/src/userspace/mod.rs +++ b/src/userspace/mod.rs @@ -28,6 +28,7 @@ pub mod capsule_about; pub mod capsule_install; +pub mod capsule_app_store; pub mod capsule_linux; pub mod capsule_attest; pub mod capsule_audio_player; diff --git a/stark-attest b/stark-attest index 92e05312ff..d6b60b4170 160000 --- a/stark-attest +++ b/stark-attest @@ -1 +1 @@ -Subproject commit 92e05312ffc2392f129ff27685ae62d3a9d7b731 +Subproject commit d6b60b4170501a627e2c4fb4a6d84cc2d19ed5b4 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-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(". +"""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/nonos-deb-vectors b/tools/nonos-deb-vectors new file mode 100755 index 0000000000..a6a66688b4 --- /dev/null +++ b/tools/nonos-deb-vectors @@ -0,0 +1,97 @@ +#!/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 . +"""Write a small Debian archive for the installer's tests, with Debian's +own tools: .debs from dpkg-deb, the Packages index from dpkg-scanpackages, +a Release over it, signed with a throwaway GnuPG key. + +The layout is an archive's, so the test walks the same chain an install +does: Release.gpg, Release, Packages, .deb, data member. +""" + +import argparse +import hashlib +import os +import pathlib +import shutil +import subprocess +import sys +import tempfile + +PKGS = [ + # name, compression, depends, provides, files {path: bytes}, links {path: target} + ("nonos-hello", "xz", "libnonos1 (>= 1.0) | libnonos-alt, missing-alt | libnonos-virtual", "", + {"usr/bin/nonos-hello": b"\x7fELF nonos hello\n"}, {}), + ("libnonos1", "zstd", "", "libnonos-virtual", + {"usr/lib/libnonos.so.1": b"\x7fELF libnonos\n"}, {"usr/lib/libnonos.so": "libnonos.so.1"}), + ("nonos-gz", "gzip", "", "", {"usr/share/nonos/gz": b"gzip member\n"}, {}), +] + + +def run(*cmd, cwd=None, data=None): + return subprocess.run(cmd, cwd=cwd, input=data, capture_output=True, check=True).stdout + + +def build(tmp, out, name, comp, depends, provides, files, links): + root = tmp / name + (root / "DEBIAN").mkdir(parents=True) + control = f"Package: {name}\nVersion: 1.0\nArchitecture: amd64\nMaintainer: NONOS \nDescription: test\n" + control += f"Depends: {depends}\n" if depends else "" + control += f"Provides: {provides}\n" if provides else "" + (root / "DEBIAN/control").write_text(control) + for path, body in files.items(): + (root / path).parent.mkdir(parents=True, exist_ok=True) + (root / path).write_bytes(body) + for path, target in links.items(): + os.symlink(target, root / path) + pool = out / "pool/main" / name[0] / name + pool.mkdir(parents=True, exist_ok=True) + run("dpkg-deb", "--root-owner-group", f"-Z{comp}", "--build", str(root), str(pool / f"{name}_1.0_amd64.deb")) + + +def main(): + ap = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + ap.add_argument("out", type=pathlib.Path, help="directory for the archive") + a = ap.parse_args() + shutil.rmtree(a.out, ignore_errors=True) + a.out.mkdir(parents=True) + with tempfile.TemporaryDirectory() as t: + tmp = pathlib.Path(t) + for p in PKGS: + build(tmp, a.out, *p) + lists = a.out / "dists/nonos/main/binary-amd64" + lists.mkdir(parents=True) + (lists / "Packages").write_bytes(run("dpkg-scanpackages", "--multiversion", "pool", cwd=a.out)) + (lists / "Packages.xz").write_bytes(run("xz", "-c", "-6", str(lists / "Packages"))) + sums = "".join( + f" {hashlib.sha256(f.read_bytes()).hexdigest()} {f.stat().st_size} main/binary-amd64/{f.name}\n" + for f in sorted(lists.iterdir())) + release = f"Origin: NONOS test\nSuite: nonos\nCodename: nonos\nArchitectures: amd64\nComponents: main\nSHA256:\n{sums}" + (a.out / "dists/nonos/Release").write_text(release) + home = tmp / "gnupg" + home.mkdir(mode=0o700) + g = ["gpg", "--homedir", str(home), "--batch", "--quiet", "--pinentry-mode", "loopback", "--passphrase", ""] + run(*g, "--quick-gen-key", "NONOS test archive ", "rsa3072", "sign", "never") + (a.out / "archive-key.asc").write_bytes(run(*g, "--armor", "--export", "archive@nonos.invalid")) + sig = run(*g, "--armor", "--detach-sign", "-o", "-", data=release.encode()) + (a.out / "dists/nonos/Release.gpg").write_bytes(sig) + for f in sorted(p for p in a.out.rglob("*") if p.is_file()): + print(f"{f.stat().st_size:8} {f.relative_to(a.out)}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) 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/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/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/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-market-catalogue b/tools/nonos-market-catalogue new file mode 100755 index 0000000000..8043fd6ac3 --- /dev/null +++ b/tools/nonos-market-catalogue @@ -0,0 +1,469 @@ +#!/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 . +"""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 gzip +import hashlib +import json +import lzma +import subprocess +import sys +import tarfile +import tempfile +import time +import urllib.request +from pathlib import Path + +MIRROR = "https://dl-cdn.alpinelinux.org/alpine" +BRANCHES = ("main", "community") +# Listing namespaces a distribution other than Alpine publishes under. The +# kernel reads `linux.kali.` as Debian and `linux.blackarch.` as +# pacman (src/userspace/capsule_linux/family.rs); an Alpine name inside one +# would be read as the wrong family, so none is listed. +NAMESPACES = ("kali.", "blackarch.") +# The archive the capsule's deb installer reads (capsule_linux build.rs +# NONOS_DEB_ROOT, NONOS_DEB_SUITE), and the key it pins. +KALI = "https://kali.download/kali" +KALI_SUITE = "kali-rolling" +KALI_KEY = Path("userland/capsule_linux/keys/kali/archive-key-2025.asc") +# 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 name.startswith(NAMESPACES): + print(f" skip {name}: inside a namespace another family owns", file=sys.stderr) + continue + 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"], + "Linux", + 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 fetch(url: str, to: Path) -> bytes: + if not to.exists(): + to.parent.mkdir(parents=True, exist_ok=True) + with urllib.request.urlopen(url, timeout=180) as r: + to.write_bytes(r.read()) + return to.read_bytes() + + +def gpgv(key: Path, signed: Path, sig: Path) -> bool: + """The Release verifies under the pinned key and no other.""" + with tempfile.TemporaryDirectory() as home: + ring = Path(home) / "ring.gpg" + dearmor = subprocess.run( + ["gpg", "--homedir", home, "--dearmor", "--output", str(ring), str(key)], + capture_output=True, + ) + if dearmor.returncode != 0: + return False + done = subprocess.run( + ["gpgv", "--homedir", home, "--keyring", str(ring), str(sig), str(signed)], + capture_output=True, + ) + return done.returncode == 0 + + +def stanzas(text: str): + rec, last = {}, None + for line in text.split("\n"): + if not line.strip(): + if rec: + yield rec + rec, last = {}, None + elif line[0] in " \t" and last: + rec[last] += "\n" + line.strip() + elif ":" in line: + last, _, value = line.partition(":") + rec[last] = value.strip() + if rec: + yield rec + + +def kali_index(cache: Path, arch: str) -> dict: + """name -> Packages record, through the chain the device checks.""" + at = cache / "kali" + rel = at / "Release" + fetch(f"{KALI}/dists/{KALI_SUITE}/Release", rel) + fetch(f"{KALI}/dists/{KALI_SUITE}/Release.gpg", at / "Release.gpg") + if not gpgv(KALI_KEY, rel, at / "Release.gpg"): + sys.exit("kali: Release does not verify under the pinned key; nothing listed") + sums = {} + for line in next(stanzas(rel.read_text())).get("SHA256", "").split("\n"): + parts = line.split() + if len(parts) == 3: + sums[parts[2]] = parts[0] + # The order the installer tries them in (deb/index.rs LISTS). + lists = [f"main/binary-{arch}/Packages.{x}" for x in ("xz", "gz")] + path = next((p for p in lists if p in sums), None) + if path is None: + sys.exit("kali: the Release lists no Packages file the installer reads") + raw = fetch(f"{KALI}/dists/{KALI_SUITE}/{path}", at / Path(path).name) + if hashlib.sha256(raw).hexdigest() != sums[path]: + sys.exit(f"kali: {path} does not match its Release") + inflate = lzma.decompress if path.endswith(".xz") else gzip.decompress + text = inflate(raw).decode(errors="replace") + return {r["Package"]: r for r in stanzas(text) if "Package" in r} + + +def kali_entries(cache, names, arch, pubkey, when_ms) -> list: + table = kali_index(cache, arch) if names else {} + out = [] + for name in names: + rec = table.get(name) + if rec is None: + print(f" skip kali.{name}: not in the index", file=sys.stderr) + continue + url = f"{KALI}/{rec['Filename']}" + raw = fetch(url, cache / "kali" / Path(rec["Filename"]).name) + if hashlib.sha256(raw).hexdigest() != rec.get("SHA256"): + print(f" skip kali.{name}: bytes do not match the index", file=sys.stderr) + continue + digest = blake3(raw) + text = rec.get("Description", "").split("\n")[0] or f"Kali package {name}" + rel = release(f"{name}@{rec['Version']}", digest, digest, url, ["x86_64-linux"], + ["ForeignExec"], f"fetched and hashed at {len(raw)} bytes", + "nonos.operator.linux", when_ms, + linux_trailer(cache, Path(rec["Filename"]).name)) + out.append(entry(f"linux.kali.{name}", digest, name, "Linux", pubkey, + text, [rel])) + 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") + # 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") + ap.add_argument("--linux-list", type=Path, + help="file of package names, one per line; kali. for Kali") + 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() + ] + kali = [n.removeprefix("kali.") for n in names if n.startswith("kali.")] + alpine = [n for n in names if not n.startswith("kali.")] + if names: + args.cache.mkdir(parents=True, exist_ok=True) + if alpine: + found = linux_entries(args.cache, alpine, args.alpine_release, + args.alpine_arch, key, when_ms) + print(f"linux: {len(found)} of {len(alpine)} Alpine packages", file=sys.stderr) + entries += found + if kali: + found = kali_entries(args.cache, kali, "amd64", key, when_ms) + print(f"linux: {len(found)} of {len(kali)} Kali 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-index b/tools/nonos-market-index new file mode 100755 index 0000000000..0f38bd09a4 --- /dev/null +++ b/tools/nonos-market-index @@ -0,0 +1,70 @@ +#!/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 . +"""Write the catalogue the market capsule embeds as its baseline. + +With the operator seed present this runs the whole chain: the catalogue +generator reads what the tree built and the Linux packages it is asked to +list, every operator release is signed, the index is signed and then +verified under the operator key the capsule trusts, so an image never +ships an index its own market would refuse. Without the seed the output is +empty, which the capsule reads as "no baseline", and a build says so +rather than quietly embedding a stale catalogue. +""" +import argparse +import subprocess +import sys +from pathlib import Path + + +def run(*argv): + subprocess.run([str(a) for a in argv], check=True) + + +def main(): + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("--out", type=Path, required=True) + ap.add_argument("--cli", type=Path, required=True) + ap.add_argument("--seed", type=Path, required=True, help="32-byte operator seed") + ap.add_argument("--pubkey", type=Path, required=True, help="32-byte operator key") + ap.add_argument("--linux-list", type=Path, required=True) + ap.add_argument("--serial", type=int, required=True) + a = ap.parse_args() + a.out.parent.mkdir(parents=True, exist_ok=True) + if not a.seed.exists(): + print(f"market index: no operator seed at {a.seed}; the image has no baseline catalogue", + file=sys.stderr) + a.out.write_bytes(b"") + return 0 + key = a.pubkey.read_bytes().hex() + if len(key) != 64: + sys.exit(f"{a.pubkey}: an operator key is 32 bytes") + tools = Path(__file__).parent + catalogue = a.out.with_suffix(".json") + # --no-nonos: every capsule this tree builds is already in the image, so + # the baseline lists only what a user can install, and does not depend on + # which capsules happened to be signed before this ran. + run(sys.executable, tools / "nonos-market-catalogue", "--out", catalogue, "--no-nonos", + "--operator-pubkey", key, "--linux-list", a.linux_list, "--serial", a.serial) + run(sys.executable, tools / "nonos-market-sign-releases", "--cli", a.cli, + "--index", catalogue, "--key-file", a.seed, "--operator-pubkey", key) + run(a.cli, "sign", "--in", catalogue, "--key-file", a.seed, "--pubkey", key, "--out", a.out) + run(a.cli, "verify", "--in", a.out, "--pubkey", key) + 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/tools/nonos-mirror-relay b/tools/nonos-mirror-relay new file mode 100755 index 0000000000..965833767f --- /dev/null +++ b/tools/nonos-mirror-relay @@ -0,0 +1,100 @@ +#!/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 . +"""Serve package mirrors to a booted guest, through this machine's proxy. + +The guest's installer speaks plain HTTP to an address, with the mirror's +name as the Host line. This relay listens on loopback (10.0.2.2 to a QEMU +guest on user networking), refuses any Host line not on its list, fetches +the path over HTTPS through HTTPS_PROXY, caches it, and returns it. Nothing +it relays needs to be trusted: every file is held to its distribution's +signature inside the guest. It never dials a mirror directly. +""" + +import argparse +import hashlib +import http.server +import os +import pathlib +import ssl +import sys +import urllib.error +import urllib.request + +HOSTS = { + "dl-cdn.alpinelinux.org", + "geo.mirror.pkgbuild.com", + "www.blackarch.org", + "deb.debian.org", + "kali.download", +} + + +def fetcher(ca): + ctx = ssl.create_default_context(cafile=ca) if ca else ssl.create_default_context() + proxy = urllib.request.ProxyHandler() # HTTPS_PROXY from the environment + return urllib.request.build_opener(proxy, urllib.request.HTTPSHandler(context=ctx)) + + +def handler(cache, opener, log): + class Relay(http.server.BaseHTTPRequestHandler): + def do_GET(self): + host = self.headers.get("Host", "").split(":")[0] + if host not in HOSTS or ".." in self.path or not self.path.startswith("/"): + return self.reply(403, b"refused\n", f"refused {host}{self.path}") + key = cache / hashlib.sha256(f"{host}{self.path}".encode()).hexdigest() + if not key.exists(): + try: + with opener.open(f"https://{host}{self.path}", timeout=120) as r: + body = r.read() + except urllib.error.HTTPError as e: + return self.reply(e.code, b"", f"{e.code} {host}{self.path}") + except OSError as e: + return self.reply(502, b"", f"502 {host}{self.path}: {e}") + key.write_bytes(body) + body = key.read_bytes() + self.reply(200, body, f"200 {len(body):9} {host}{self.path}") + + def reply(self, code, body, line): + print(line, file=log, flush=True) + self.send_response(code) + self.send_header("Content-Length", str(len(body))) + self.send_header("Connection", "close") + self.end_headers() + self.wfile.write(body) + + def log_message(self, *_): + pass + + return Relay + + +def main(): + ap = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + ap.add_argument("--port", type=int, default=8080) + ap.add_argument("--cache", type=pathlib.Path, required=True) + ap.add_argument("--ca", default=os.environ.get("SSL_CERT_FILE") or "/root/.ccr/ca-bundle.crt") + a = ap.parse_args() + a.cache.mkdir(parents=True, exist_ok=True) + ca = a.ca if os.path.exists(a.ca) else None + server = http.server.ThreadingHTTPServer(("127.0.0.1", a.port), handler(a.cache, fetcher(ca), sys.stdout)) + print(f"relay on 127.0.0.1:{a.port} for {', '.join(sorted(HOSTS))}", flush=True) + server.serve_forever() + return 0 + + +if __name__ == "__main__": + sys.exit(main()) 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/tools/nonos-openpgp-vectors b/tools/nonos-openpgp-vectors new file mode 100755 index 0000000000..22702c8973 --- /dev/null +++ b/tools/nonos-openpgp-vectors @@ -0,0 +1,88 @@ +#!/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 . +"""Write the OpenPGP verifier's test vectors with GnuPG. + +Throwaway keys are made in a temporary home and deleted with it; only the +public keys, the detached signatures and the fingerprints +GnuPG reports are kept. The verifier is checked against GnuPG's own output, +never against a signature it made itself. +""" + +import argparse +import os +import pathlib +import subprocess +import sys +import tempfile + +KEYS = [ + # name, algorithm, usage of the primary, a signing subkey or not + ("ed", "ed25519", "sign", False), + ("rsa", "rsa3072", "sign", False), + ("sub", "ed25519", "cert", True), +] +SIGS = [("ed", "SHA512"), ("ed", "SHA256"), ("rsa", "SHA256"), ("rsa", "SHA512"), ("sub", "SHA512")] + + +def gpg(home, *args, data=None): + cmd = ["gpg", "--homedir", home, "--batch", "--quiet", "--pinentry-mode", "loopback", + "--passphrase", "", *args] + return subprocess.run(cmd, input=data, capture_output=True, check=True).stdout + + +def fingerprints(home, uid): + out = gpg(home, "--with-colons", "--fingerprint", "--fingerprint", uid).decode() + return [line.split(":")[9] for line in out.splitlines() if line.startswith("fpr:")] + + +def main(): + ap = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + ap.add_argument("out", type=pathlib.Path, help="directory for the vectors") + a = ap.parse_args() + a.out.mkdir(parents=True, exist_ok=True) + # The signed file; the test regenerates it from the same formula. + data = bytes((i * 131 + 17) % 251 for i in range(70000)) + lines = [] + with tempfile.TemporaryDirectory() as home: + os.chmod(home, 0o700) + for name, algo, usage, sub in KEYS: + uid = f"NONOS test {name} <{name}@nonos.invalid>" + gpg(home, "--quick-gen-key", uid, algo, usage, "never") + fprs = fingerprints(home, uid) + if sub: + gpg(home, "--quick-add-key", fprs[0], algo, "sign", "never") + fprs = fingerprints(home, uid) + (a.out / f"{name}.pub").write_bytes(gpg(home, "--export", fprs[0])) + lines.append(f"{name} {' '.join(fprs)}") + for name, digest in SIGS: + uid = f"{name}@nonos.invalid" + sig = gpg(home, "--local-user", uid, "--digest-algo", digest, "--detach-sign", + "-o", "-", data=data) + (a.out / f"{name}-{digest.lower()}.sig").write_bytes(sig) + print(f"{name}-{digest.lower()}.sig {len(sig)} bytes") + # Armored forms, as a Debian repository publishes its key and Release.gpg. + (a.out / "rsa.asc").write_bytes(gpg(home, "--armor", "--export", "rsa@nonos.invalid")) + asc = gpg(home, "--local-user", "rsa@nonos.invalid", "--digest-algo", "SHA256", + "--armor", "--detach-sign", "-o", "-", data=data) + (a.out / "rsa-sha256.asc").write_bytes(asc) + (a.out / "fingerprints.txt").write_text("\n".join(lines) + "\n") + print("\n".join(lines)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) 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()) 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-store-pack b/tools/nonos-store-pack index 4b6a25b233..4fe38cedcc 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 = 128 +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) diff --git a/tools/nonos-tcb b/tools/nonos-tcb new file mode 100755 index 0000000000..5ccf8047cd --- /dev/null +++ b/tools/nonos-tcb @@ -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 . +"""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, `include_bytes!` payloads and path crates such as +nonos-stark are left out: the count is non-comment Rust under src/. +""" + +import argparse +import sys +from collections import Counter +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 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=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, files = kernel_files(root, a.depinfo, a.target_deps) + if not files: + return 2 + + per = Counter() + for f in files: + rel = f.relative_to(root / "src") + 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: + for name, n in per.most_common(): + print(f"[tcb] {name:24} {n:7}") + + if a.baseline: + return budget.held("tcb", total, a.baseline, grows_ok=False) + return 0 + + +if __name__ == "__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/nonos-xz-vectors b/tools/nonos-xz-vectors new file mode 100755 index 0000000000..75c2f743bd --- /dev/null +++ b/tools/nonos-xz-vectors @@ -0,0 +1,78 @@ +#!/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 . +"""Write the xz decoder's test vectors: the zstd vectors' deterministic +inputs, compressed by the reference `xz` binary. + +Only the compressed files are committed. The inputs come from the generator +in tools/nonos-zstd-vectors, and the Rust test regenerates them with the +zstd test's copy of it, so there is one generator and the decoder is never +its own oracle. +""" + +import argparse +import pathlib +import runpy +import subprocess +import sys + +GEN = runpy.run_path(str(pathlib.Path(__file__).with_name("nonos-zstd-vectors"))) + +# name, kind, seed, size, xz arguments +VECTORS = [ + ("empty", "text", 1, 0, ["-6"]), + ("tiny", "text", 2, 50, ["-0"]), + ("text64k", "text", 3, 65536, ["-6"]), + ("text300k", "text", 4, 300000, ["-9e"]), + ("noise140k", "noise", 5, 140000, ["-6"]), + ("zeros300k", "zeros", 6, 300000, ["-6"]), + ("runs100k", "runs", 7, 100000, ["-6"]), + ("mixed256k", "mixed", 8, 262144, ["-9"]), + ("none", "text", 9, 20000, ["-6", "--check=none"]), + ("crc32", "text", 10, 20000, ["-6", "--check=crc32"]), + ("sha256", "text", 11, 20000, ["-6", "--check=sha256"]), + ("blocks", "mixed", 12, 200000, ["-6", "--block-size=65536"]), + ("lclppb", "text", 13, 100000, ["--lzma2=preset=6,lc=0,lp=2,pb=0"]), + ("lc4pb4", "runs", 14, 100000, ["--lzma2=preset=6,lc=4,lp=0,pb=4"]), + ("dict4k", "text", 15, 100000, ["--lzma2=preset=6,dict=4KiB"]), + ("bcj", "noise", 16, 4096, ["--x86", "--lzma2=preset=6"]), +] + + +def xz(data, args): + cmd = ["xz", "-q", "-c", "--format=xz", *args] + return subprocess.run(cmd, input=data, capture_output=True, check=True).stdout + + +def main(): + ap = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + ap.add_argument("out", type=pathlib.Path, help="directory for the .xz files") + a = ap.parse_args() + a.out.mkdir(parents=True, exist_ok=True) + for name, kind, seed, size, args in VECTORS: + z = xz(GEN["KINDS"][kind](GEN["Rng"](seed), size), args) + (a.out / f"{name}.xz").write_bytes(z) + print(f"{name:10} {kind:6} {size:7} -> {len(z):7} {' '.join(args)}") + # Two streams, with stream padding between and after them. + one = xz(GEN["text"](GEN["Rng"](17), 10000), ["-1"]) + two = xz(GEN["noise"](GEN["Rng"](18), 5000), ["-1", "--check=crc32"]) + (a.out / "concat.xz").write_bytes(one + bytes(8) + two + bytes(4)) + print("concat text+noise 15000") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/nonos-zstd-vectors b/tools/nonos-zstd-vectors new file mode 100755 index 0000000000..ddc14231f0 --- /dev/null +++ b/tools/nonos-zstd-vectors @@ -0,0 +1,134 @@ +#!/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 . +"""Write the zstd decoder's test vectors: deterministic inputs, compressed by +the reference `zstd` binary. + +Only the compressed files are committed. The inputs are regenerated by the +Rust test from the same generator, so a decoder bug cannot hide behind a +vector made by the decoder under test. The generator here and the one in +userland/zstd/tests/gen.rs must stay the same; the test fails if they drift. +""" + +import argparse +import pathlib +import subprocess +import sys + +MASK = (1 << 64) - 1 +WORDS = [ + b"the", b"store", b"frame", b"kernel", b"package", b"of", b"a", b"link", + b"guest", b"proof", b"and", b"block", b"window", b"signal", b"to", b"is", + b"offset", b"literal", b"table", b"stream", b"in", b"trust", b"capsule", b"by", + b"byte", b"zero", b"match", b"code", b"state", b"with", b"root", b"tar", +] + + +class Rng: + def __init__(self, seed): + self.s = seed or 1 + + def next(self): + s = self.s + s ^= s >> 12 + s ^= (s << 25) & MASK + s ^= s >> 27 + self.s = s + return (s * 0x2545F4914F6CDD1D) & MASK + + +def text(r, n): + out = bytearray() + count = 0 + while len(out) < n: + out += WORDS[r.next() >> 59] + count += 1 + out += b"\n" if count % 12 == 0 else b" " + return bytes(out[:n]) + + +def noise(r, n): + return bytes(r.next() >> 56 for _ in range(n)) + + +def runs(r, n): + out = bytearray() + while len(out) < n: + out += bytes([r.next() >> 60]) * ((r.next() >> 58) + 1) + return bytes(out[:n]) + + +def mixed(r, n): + out = bytearray() + while len(out) < n: + out += text(r, 4096) if (len(out) // 4096) % 2 == 0 else noise(r, 4096) + return bytes(out[:n]) + + +KINDS = {"text": text, "noise": noise, "runs": runs, "mixed": mixed, + "zeros": lambda r, n: bytes(n)} + +# name, kind, seed, size, zstd arguments; "-" reads stdin, so no content size. +VECTORS = [ + ("empty", "text", 1, 0, ["-3"]), + ("tiny", "text", 2, 50, ["-1"]), + ("text64k", "text", 3, 65536, ["-3"]), + ("text300k", "text", 4, 300000, ["-19"]), + ("noise140k", "noise", 5, 140000, ["-3"]), + ("zeros300k", "zeros", 6, 300000, ["-3"]), + ("runs100k", "runs", 7, 100000, ["-9"]), + ("mixed256k", "mixed", 8, 262144, ["--ultra", "-22"]), + ("nocheck", "text", 9, 20000, ["-3", "--no-check"]), + ("stream", "text", 10, 50000, ["-3", "-"]), + ("fast", "text", 11, 100000, ["--fast=5"]), + ("long", "text", 12, 400000, ["-19", "--long=27"]), +] + + +def compress(data, args): + stdin_mode = "-" in args + flags = [a for a in args if a != "-"] + cmd = ["zstd", "-q", "-c", *flags] + if stdin_mode: + return subprocess.run(cmd, input=data, capture_output=True, check=True).stdout + tmp = pathlib.Path("/dev/shm/nonos-zstd-vector.bin") + tmp.write_bytes(data) + try: + return subprocess.run([*cmd, str(tmp)], capture_output=True, check=True).stdout + finally: + tmp.unlink() + + +def main(): + ap = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + ap.add_argument("out", type=pathlib.Path, help="directory for the .zst files") + a = ap.parse_args() + a.out.mkdir(parents=True, exist_ok=True) + for name, kind, seed, size, args in VECTORS: + z = compress(KINDS[kind](Rng(seed), size), args) + (a.out / f"{name}.zst").write_bytes(z) + print(f"{name:10} {kind:6} {size:7} -> {len(z):7} {' '.join(args)}") + # Two frames with a skippable frame between them. + one = compress(text(Rng(13), 10000), ["-1"]) + two = compress(noise(Rng(14), 5000), ["-1"]) + skip = (0x184D2A53).to_bytes(4, "little") + (7).to_bytes(4, "little") + b"skipped" + (a.out / "concat.zst").write_bytes(one + skip + two) + print(f"concat text+noise 15000 -> {len(one + skip + two):7}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) 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/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/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/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/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/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/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) 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) +} 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/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 0000000000..bf3641e094 Binary files /dev/null and b/userland/assets/etna/etna-0b0046f5f585.png differ diff --git a/userland/assets/etna/etna-32f5aa765d0e.png b/userland/assets/etna/etna-32f5aa765d0e.png new file mode 100644 index 0000000000..d344aad53b Binary files /dev/null and b/userland/assets/etna/etna-32f5aa765d0e.png differ diff --git a/userland/assets/etna/etna-387f21b9dbd2.png b/userland/assets/etna/etna-387f21b9dbd2.png new file mode 100644 index 0000000000..d171edf525 Binary files /dev/null and b/userland/assets/etna/etna-387f21b9dbd2.png differ diff --git a/userland/assets/etna/etna-460f26b9c468.png b/userland/assets/etna/etna-460f26b9c468.png new file mode 100644 index 0000000000..4779069b58 Binary files /dev/null and b/userland/assets/etna/etna-460f26b9c468.png differ diff --git a/userland/assets/etna/etna-4bb30d031341.png b/userland/assets/etna/etna-4bb30d031341.png new file mode 100644 index 0000000000..3abab969e4 Binary files /dev/null and b/userland/assets/etna/etna-4bb30d031341.png differ diff --git a/userland/assets/etna/etna-90380f7829df.png b/userland/assets/etna/etna-90380f7829df.png new file mode 100644 index 0000000000..761fe5f24a Binary files /dev/null and b/userland/assets/etna/etna-90380f7829df.png differ diff --git a/userland/assets/etna/etna-a0754afa8556.png b/userland/assets/etna/etna-a0754afa8556.png new file mode 100644 index 0000000000..05ed398fc4 Binary files /dev/null and b/userland/assets/etna/etna-a0754afa8556.png differ diff --git a/userland/assets/etna/etna-fcb6fdda9325.png b/userland/assets/etna/etna-fcb6fdda9325.png new file mode 100644 index 0000000000..548a654ead Binary files /dev/null and b/userland/assets/etna/etna-fcb6fdda9325.png differ diff --git a/userland/assets/etna/index.txt b/userland/assets/etna/index.txt new file mode 100644 index 0000000000..debef48641 --- /dev/null +++ b/userland/assets/etna/index.txt @@ -0,0 +1,10 @@ +welcome etna-387f21b9dbd2.png +home etna-90380f7829df.png +send etna-4bb30d031341.png +receive etna-fcb6fdda9325.png +deposit etna-460f26b9c468.png +withdraw etna-a0754afa8556.png +proving etna-0b0046f5f585.png +history etna-32f5aa765d0e.png +settings etna-fcb6fdda9325.png +backup etna-4bb30d031341.png diff --git a/userland/assets/fonts/Geist-Medium.ttf b/userland/assets/fonts/Geist-Medium.ttf new file mode 100644 index 0000000000..96cb22f8bb Binary files /dev/null and b/userland/assets/fonts/Geist-Medium.ttf differ diff --git a/userland/assets/fonts/Geist-OFL.txt b/userland/assets/fonts/Geist-OFL.txt new file mode 100644 index 0000000000..04e95fc550 --- /dev/null +++ b/userland/assets/fonts/Geist-OFL.txt @@ -0,0 +1,93 @@ +Copyright 2024 The Geist Project Authors (https://github.com/vercel/geist-font) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. \ No newline at end of file diff --git a/userland/assets/fonts/Geist-Regular.ttf b/userland/assets/fonts/Geist-Regular.ttf new file mode 100644 index 0000000000..4da1b3a6db Binary files /dev/null and b/userland/assets/fonts/Geist-Regular.ttf differ diff --git a/userland/assets/fonts/Geist-SemiBold.ttf b/userland/assets/fonts/Geist-SemiBold.ttf new file mode 100644 index 0000000000..b2b0618fa4 Binary files /dev/null and b/userland/assets/fonts/Geist-SemiBold.ttf differ diff --git a/userland/assets/fonts/JetBrainsMono-OFL.txt b/userland/assets/fonts/JetBrainsMono-OFL.txt new file mode 100644 index 0000000000..5ceee0025d --- /dev/null +++ b/userland/assets/fonts/JetBrainsMono-OFL.txt @@ -0,0 +1,93 @@ +Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/userland/assets/fonts/JetBrainsMono-Regular.ttf b/userland/assets/fonts/JetBrainsMono-Regular.ttf new file mode 100644 index 0000000000..dff66cc507 Binary files /dev/null and b/userland/assets/fonts/JetBrainsMono-Regular.ttf differ diff --git a/userland/assets/icons/store.a8 b/userland/assets/icons/store.a8 new file mode 100644 index 0000000000..e086216b2c Binary files /dev/null and b/userland/assets/icons/store.a8 differ 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..7332e0c357 --- /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 := 0x40001819 +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..22e7c057f7 --- /dev/null +++ b/userland/capsule_app_store/src/store/app.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 . + +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); + } + /// Only while an install is moving: an idle store costs nothing. + fn on_tick(&mut self) -> bool { + let moved = self.state.any_pending() && self.state.poll_pending(); + if moved { + // "install requested" is stale once the system has said more. + self.state.asked = None; + } + moved + } + fn tick_interval_ms(&self) -> i64 { + 500 + } + fn busy(&self) -> bool { + self.state.any_pending() + } +} 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..ec9044eda8 --- /dev/null +++ b/userland/capsule_app_store/src/store/event_actions.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 that do something rather than move somewhere. + +use nonos_app_skeleton::{EventOutcome, KEY_ENTER}; + +use super::install; +use super::state::State; + +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 { + // One key does the next sensible thing: install, wait, or open. + KEY_ENTER => { + state.asked = super::install_primary::primary(state); + true + } + /* + * 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 => { + 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..06b6a1ce3e --- /dev/null +++ b/userland/capsule_app_store/src/store/event_keys.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 . + +//! 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. + 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..81bf8e7bab --- /dev/null +++ b/userland/capsule_app_store/src/store/install.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 . + +//! Asking for the selected listing to be installed. + +use nonos_libc::{mk_app_install, mk_app_launch}; + +use super::state::State; + +/// What the user is told after asking. +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum Asked { + Queued, + Opening, + Busy, + Refused, + NotInstallable, +} + +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", + } + } +} + +pub fn ask(state: &State) -> Asked { + let Some(listing) = state.current() else { + return Asked::NotInstallable; + }; + // `ready` only saves a request: the kernel asks the market again. + if !listing.id.starts_with(b"linux.") || !listing.ready { + return Asked::NotInstallable; + } + match mk_app_install(&listing.id, b"") { + 0 => Asked::Queued, + -16 => Asked::Busy, + _ => 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/install_primary.rs b/userland/capsule_app_store/src/store/install_primary.rs new file mode 100644 index 0000000000..590bca17ee --- /dev/null +++ b/userland/capsule_app_store/src/store/install_primary.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 . + +//! Enter does the next sensible thing for the selected listing: install it, +//! wait while it installs, or open it once it has. + +use super::install::{ask, open, Asked}; +use super::progress::Progress; +use super::state::State; + +/// What Enter means for the selected listing, as far as it has got. +pub fn primary(state: &mut State) -> Option { + let progress = state.current().map(|l| l.progress)?; + match progress { + Progress::Installed => Some(open(state)), + p if p.pending() => None, + _ => { + let asked = ask(state); + if asked == Asked::Queued { + if let Some(l) = state.current_mut() { + l.progress = Progress::Queued; + } + } + Some(asked) + } + } +} 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..498e06a53a --- /dev/null +++ b/userland/capsule_app_store/src/store/listing.rs @@ -0,0 +1,74 @@ +// 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", + } + } + + /// The line under a card's name. The store sells apps: where a Linux + /// package is fetched from is provenance, kept in the signed listing and + /// checked at install, not what the card is about. + pub fn origin(self) -> &'static [u8] { + match self { + Source::Linux => b"Linux app", + _ => self.label(), + } + } +} + +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, + /// Where an install of it stands, as the system last said. + pub progress: super::progress::Progress, +} + +impl Listing { + pub fn new(id: Vec, measurement: [u8; 32], name: Vec, ready: bool) -> Listing { + let source = Source::of(&id); + let progress = super::progress::Progress::Idle; + Listing { id, measurement, name, ready, source, progress } + } +} 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..6519385d8b --- /dev/null +++ b/userland/capsule_app_store/src/store/market/mod.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 . + +//! Talking to the market capsule. + +mod detail; +mod list; +mod ready; +mod release; +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 release::{fetch as get_release, Release}; +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/release.rs b/userland/capsule_app_store/src/store/market/release.rs new file mode 100644 index 0000000000..64cf068ae8 --- /dev/null +++ b/userland/capsule_app_store/src/store/market/release.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 release a listing would install: which version, from where, and what +//! the operator recorded when it checked the bytes. + +use alloc::vec::Vec; + +use super::wire::call; + +const OP_GET_RELEASE: u16 = 4; + +pub struct Release { + pub version: Vec, + /// The operator's validation note, such as the size it hashed. + pub note: Vec, +} + +/// The default release: an empty id asks for it. +pub fn fetch(port: u32, request_id: u32, listing: &[u8]) -> Option { + let mut body = Vec::with_capacity(8 + listing.len()); + body.extend_from_slice(&(listing.len() as u32).to_le_bytes()); + body.extend_from_slice(listing); + body.extend_from_slice(&0u32.to_le_bytes()); + let out = call(port, OP_GET_RELEASE, request_id, &body)?; + // release_id, manifest, package, url, signature, arches, abi, caps, status, note + let (version, at) = lp(&out, 0)?; + // The url is provenance the installer checks; the store does not show it. + let (_, mut at) = lp(&out, at + 64)?; + at = skip_blob(&out, at)?; + at = skip_list(&out, at)? + 4; + at = skip_list(&out, at)? + 1; + let (note, _) = lp(&out, at)?; + Some(Release { version, note }) +} + +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 end = (at + 4).checked_add(len)?; + Some((body.get(at + 4..end)?.to_vec(), end)) +} + +fn skip_blob(body: &[u8], at: usize) -> Option { + lp(body, at).map(|(_, end)| end) +} + +/// A count, then that many length-prefixed strings. +fn skip_list(body: &[u8], at: usize) -> Option { + let n = u32::from_le_bytes(body.get(at..at + 4)?.try_into().ok()?); + (0..n).try_fold(at + 4, |at, _| skip_blob(body, at)) +} 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..cabbb0e649 --- /dev/null +++ b/userland/capsule_app_store/src/store/mod.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 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 event_actions; +mod event_click; +mod event_keys; +mod event_rows; +mod event_search; +mod event_tab; +mod install; +mod install_primary; +mod listing; +mod poll; +mod progress; +mod progress_text; +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/poll.rs b/userland/capsule_app_store/src/store/poll.rs new file mode 100644 index 0000000000..0e51323a67 --- /dev/null +++ b/userland/capsule_app_store/src/store/poll.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 . + +//! Asking the system where each install stands, while any is moving. + +use nonos_libc::mk_app_install_status; + +use super::progress::Progress; +use super::state::State; + +impl State { + /// Ask about every Linux listing once, so one installed earlier in the + /// session reads as installed. True when anything changed. + pub fn poll_all(&mut self) -> bool { + self.poll(|l| l.id.starts_with(b"linux.")) + } + + /// Ask about the installs still moving. True when anything changed. + pub fn poll_pending(&mut self) -> bool { + self.poll(|l| l.progress.pending()) + } + + pub fn any_pending(&self) -> bool { + self.listings.iter().any(|l| l.progress.pending()) + } + + fn poll(&mut self, which: impl Fn(&super::listing::Listing) -> bool) -> bool { + let mut changed = false; + for l in self.listings.iter_mut().filter(|l| which(l)) { + let now = Progress::of(mk_app_install_status(&l.id)); + // A request this window made is not forgotten by an early answer. + let now = if now == Progress::Idle && l.progress.pending() { l.progress } else { now }; + changed |= now != l.progress; + l.progress = now; + } + changed + } +} diff --git a/userland/capsule_app_store/src/store/progress.rs b/userland/capsule_app_store/src/store/progress.rs new file mode 100644 index 0000000000..5bbffc2388 --- /dev/null +++ b/userland/capsule_app_store/src/store/progress.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 . + +//! Where an install this window asked for stands, as the system reports it, +//! and how that reads to a person. + +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum Progress { + /// Nothing asked since the system started. + Idle, + Queued, + Installing, + Installed, + /// The system would not start the installer. + Refused, + /// The installer stopped, with its reason code. + Failed(u8), +} + +impl Progress { + /// From `mk_app_install_status`. + pub fn of(code: i64) -> Progress { + match code { + 1 => Progress::Queued, + 2 => Progress::Installing, + 3 => Progress::Installed, + 4 => Progress::Refused, + c if c >= 16 => Progress::Failed((c - 16).clamp(0, 255) as u8), + _ => Progress::Idle, + } + } + + /// Still moving, so worth asking again. + pub fn pending(self) -> bool { + matches!(self, Progress::Queued | Progress::Installing) + } + + pub fn button(self, ready: bool) -> &'static [u8] { + match self { + Progress::Idle if ready => b"Install", + Progress::Idle => b"Details", + Progress::Queued => b"Queued", + Progress::Installing => b"Installing", + Progress::Installed => b"Open", + Progress::Refused | Progress::Failed(_) => b"Retry", + } + } +} diff --git a/userland/capsule_app_store/src/store/progress_text.rs b/userland/capsule_app_store/src/store/progress_text.rs new file mode 100644 index 0000000000..c3a8e9a2eb --- /dev/null +++ b/userland/capsule_app_store/src/store/progress_text.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 . + +//! How an install's progress reads to a person, and in what colour. + +use super::progress::Progress; +use crate::store::theme::{ACCENT, DANGER, MUTED, OK}; + +impl Progress { + /// A sentence for the detail pane, with its colour, when there is news. + pub fn sentence(self) -> Option<(&'static [u8], u32)> { + let line: (&'static [u8], u32) = match self { + Progress::Idle => return None, + Progress::Queued => (b"Waiting for the installer to start", MUTED), + Progress::Installing => (b"Downloading and checking every file", ACCENT), + Progress::Installed => (b"Installed. Press Enter to open it", OK), + Progress::Refused => (b"The system would not start the installer", DANGER), + Progress::Failed(2) => (b"The package index did not download or verify", DANGER), + Progress::Failed(3) => (b"Something it needs is in no index", DANGER), + Progress::Failed(4) => (b"It needs more packages than this machine allows", DANGER), + Progress::Failed(5) => (b"A package did not download, or did not verify", DANGER), + Progress::Failed(8) => (b"This system has no mirror for it", DANGER), + Progress::Failed(9) => (b"This system holds no key to check it with", DANGER), + Progress::Failed(_) => (b"The install stopped", DANGER), + }; + Some(line) + } +} 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..d3a97886e1 --- /dev/null +++ b/userland/capsule_app_store/src/store/state.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 . + +//! 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, + /// Description and publisher for the selected listing, fetched once per + /// selection. + pub search: super::search::Search, + pub detail: Option, + /// The release the selected listing would install. + pub release: 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..a9fa1ef5b1 --- /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, + search: super::search::Search::default(), + detail: None, + release: 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..90378b71cd --- /dev/null +++ b/userland/capsule_app_store/src/store/state_refresh.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 . + +//! 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; + // One installed earlier in this session reads as installed. + self.poll_all(); + } + /* + * 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..d7af91fcb2 --- /dev/null +++ b/userland/capsule_app_store/src/store/state_select.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 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; + self.release = 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); + self.release = market::get_release(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)?) + } + + pub fn current_mut(&mut self) -> Option<&mut super::listing::Listing> { + let at = *self.visible().get(self.cursor)?; + self.listings.get_mut(at) + } +} 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..c93b441121 --- /dev/null +++ b/userland/capsule_app_store/src/store/ui/card.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 . + +//! One listing, drawn as a card. + +use nonos_app_skeleton::PaintBuffer; + +use crate::store::listing::{Listing, Source}; +use crate::store::progress::Progress; +use crate::store::theme::{ + ACCENT, BUTTON_BG, BUTTON_FG, BUTTON_OFF_BG, BUTTON_OFF_FG, CARD_BG, CARD_SEL_BG, + CARD_SEL_EDGE, DANGER, 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.origin(), 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 word = l.progress.button(l.ready); + let (bg, fg) = match l.progress { + Progress::Idle if l.ready => (BUTTON_BG, BUTTON_FG), + Progress::Installed => (BUTTON_BG, BUTTON_FG), + Progress::Queued | Progress::Installing => (BUTTON_OFF_BG, ACCENT), + Progress::Refused | Progress::Failed(_) => (BUTTON_OFF_BG, DANGER), + Progress::Idle => (BUTTON_OFF_BG, BUTTON_OFF_FG), + }; + 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..23aec1adba --- /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.blend_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/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..1fd37cd0d8 --- /dev/null +++ b/userland/capsule_app_store/src/store/ui/detail.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 . +//! The selected listing: what it is, who stands behind it, and whether this +//! machine will run it. + +use nonos_app_skeleton::PaintBuffer; + +use super::hex::short; +use super::metrics::{BODY_PX, DETAIL_PAD, SMALL_PX, TITLE_PX}; +use super::text; +use super::wrap::wrap; +use crate::store::state::State; +use crate::store::theme::{ACCENT, FOREGROUND, MUTED, PANE_BG, TITLE}; + +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 += 22; + // Which version, before anything else. Where the bytes come from is + // provenance the install checks, not a label. + if let Some(r) = &state.release { + let mut line = b"Version ".to_vec(); + line.extend_from_slice(r.version.rsplit(|b| *b == b'@').next().unwrap_or(&r.version)); + text::line(fb, left, top, &line, MUTED, SMALL_PX); + top += 20; + } + top += 6; + 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); + if let Some(r) = state.release.as_ref().filter(|r| !r.note.is_empty()) { + text::line(fb, left, top, &r.note, MUTED, SMALL_PX); + top += 24; + } + // One line, so it stays inside the pane under a failure sentence and a note. + let mut line = b"measurement ".to_vec(); + line.extend_from_slice(&short(&listing.measurement)); + text::line(fb, left, top, &line, 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..ab4482848d --- /dev/null +++ b/userland/capsule_app_store/src/store/ui/gates.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 . + +//! Why the selected listing can or cannot be installed: one row per gate. + +use nonos_app_skeleton::PaintBuffer; + +use crate::store::market::{Readiness, GATES}; +use crate::store::theme::{DANGER, MUTED, OK}; +use crate::store::verdict::Verdict; + +use super::metrics::{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; + +/// Paints and returns the y just below the last gate. The verdict itself is +/// the sentence above and the card's button, so it is not repeated here. +pub fn paint(fb: &mut PaintBuffer, x: u32, mut top: i32, r: &Readiness) -> i32 { + if Verdict::of(r) == 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, hue): (&[u8], u32) = if *pass { (b"pass", OK) } else { (b"fail", 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; + } + top +} 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..a3a0868115 --- /dev/null +++ b/userland/capsule_app_store/src/store/ui/mod.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 . + +//! The painter. + +pub mod chrome; +mod card; +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..b029babfd3 --- /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..6ac56d210e --- /dev/null +++ b/userland/capsule_app_store/src/store/ui/standing.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 . + +//! 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, 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, + }; + // Once the person has asked, what happened is the headline; the + // verdict from before they asked would contradict it. + let (line, tone) = + state.current().and_then(|l| l.progress.sentence()).unwrap_or((v.sentence(), hue)); + text::line(fb, left, top, line, tone, BODY_PX); + top += 30; + // Where the gates end, not a guess at their height: a guess once + // put the measurement on top of the last gate. + top = gates::paint(fb, left, top, &r) + 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..49b0a4fb69 --- /dev/null +++ b/userland/capsule_app_store/src/store/ui/status.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 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); + // Enter's word is the card's button, so the hint never disagrees with it. + let enter = state.current().map_or(&b"Install"[..], |l| l.progress.button(l.ready)); + let mut keys = alloc::vec::Vec::with_capacity(96); + keys.extend_from_slice(b"up/down select Enter "); + keys.extend(enter.iter().map(u8::to_ascii_lowercase)); + keys.extend_from_slice(b" / search 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); + 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..9087a41294 --- /dev/null +++ b/userland/capsule_app_store/src/store/verdict.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 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, + } + } + + /// 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_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 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_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); } 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_desktop_shell/src/render/icons.rs b/userland/capsule_desktop_shell/src/render/icons.rs index 10c8f63d6d..dd9ff6fbfd 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/render/topbar/search_hit.rs b/userland/capsule_desktop_shell/src/render/topbar/search_hit.rs index 4528adda53..6906dbd34a 100644 --- a/userland/capsule_desktop_shell/src/render/topbar/search_hit.rs +++ b/userland/capsule_desktop_shell/src/render/topbar/search_hit.rs @@ -27,7 +27,7 @@ pub fn search_hit(ctx: &Context, px: u32, py: u32) -> bool { let mut bbuf = [0u8; 4]; let blen = battery::label(&mut bbuf); let mut sbuf = [b'-'; STAMP_LEN]; - let stamped = stamp(&mut sbuf, ctx.clock_24h); + let stamped = stamp(&mut sbuf, ctx.clock_24h, ctx.tz_hours); let when: &[u8] = if stamped { &sbuf } else { b"--:--" }; match search_box(ctx, &bbuf[..blen], when) { diff --git a/userland/capsule_desktop_shell/src/render/topbar/status.rs b/userland/capsule_desktop_shell/src/render/topbar/status.rs index cb5474302f..4c6a3cb713 100644 --- a/userland/capsule_desktop_shell/src/render/topbar/status.rs +++ b/userland/capsule_desktop_shell/src/render/topbar/status.rs @@ -40,7 +40,7 @@ pub(super) fn status(ctx: &Context) { let blen = battery::label(&mut bbuf); let btext = &bbuf[..blen]; let mut sbuf = [b'-'; STAMP_LEN]; - let stamped = stamp(&mut sbuf, ctx.clock_24h); + let stamped = stamp(&mut sbuf, ctx.clock_24h, ctx.tz_hours); let when: &[u8] = if stamped { &sbuf } else { b"--:--" }; let has_notify = ctx.last_notify_level.is_some(); diff --git a/userland/capsule_desktop_shell/src/server/handlers/notify.rs b/userland/capsule_desktop_shell/src/server/handlers/notify.rs index 0fc11b58bd..8b5e44aa7d 100644 --- a/userland/capsule_desktop_shell/src/server/handlers/notify.rs +++ b/userland/capsule_desktop_shell/src/server/handlers/notify.rs @@ -43,6 +43,11 @@ pub fn handle(ctx: &mut Context, sender_pid: u32, req: &Request, body: &[u8], tx let _ = respond::status(sender_pid, req, E_INVAL, tx); return; } + if !crate::state::indicators::notify_gate::shows(level) { + // Accepted and dropped: the sender learns nothing about the setting. + let _ = respond::status(sender_pid, req, 0, tx); + return; + } ctx.last_notify_level = Some(level); let text_end = (8 + body_len as usize).min(body.len()); ctx.toasts.push(&body[8..text_end], level, mk_time_millis()); diff --git a/userland/capsule_desktop_shell/src/server/runner/refresh_clock.rs b/userland/capsule_desktop_shell/src/server/runner/refresh_clock.rs index 47a0caa0c9..bd3183d0a9 100644 --- a/userland/capsule_desktop_shell/src/server/runner/refresh_clock.rs +++ b/userland/capsule_desktop_shell/src/server/runner/refresh_clock.rs @@ -19,16 +19,20 @@ use nonos_libc::mk_time_millis; use crate::compositor_client::push_damage_commit; use crate::render::layout::menubar_height; use crate::render::paint_chrome; -use crate::state::indicators::{net, policy}; +use crate::state::indicators::{net, notify_gate, policy}; use crate::state::{Context, NotifyLevel}; pub(super) fn refresh_clock(ctx: &mut Context) { if let Some(v) = policy::clock_24h(&mut ctx.policy_port) { ctx.clock_24h = v; } + notify_gate::follow(ctx.policy_port); + if let Some(v) = policy::timezone(ctx.policy_port) { + ctx.tz_hours = v; + } crate::sound::service(); let net_now = net::online(); - if net_now && !ctx.net_was_online { + if net_now && !ctx.net_was_online && notify_gate::shows(NotifyLevel::Info) { ctx.toasts.push(b"network connected", NotifyLevel::Info, mk_time_millis()); } ctx.net_was_online = net_now; diff --git a/userland/capsule_desktop_shell/src/setup/prime/run/build_context.rs b/userland/capsule_desktop_shell/src/setup/prime/run/build_context.rs index 610fac28dc..b654635d16 100644 --- a/userland/capsule_desktop_shell/src/setup/prime/run/build_context.rs +++ b/userland/capsule_desktop_shell/src/setup/prime/run/build_context.rs @@ -42,6 +42,7 @@ pub fn build_context(peers: &Peers, overlay: &Overlay) -> Context { toast_layer_live: false, net_was_online: false, clock_24h: true, + tz_hours: 0, policy_port: 0, next_request_id: 2, desktop_items: alloc::vec::Vec::new(), diff --git a/userland/capsule_desktop_shell/src/sound/alert.rs b/userland/capsule_desktop_shell/src/sound/alert.rs index fd36a2c61b..f6c068cb4d 100644 --- a/userland/capsule_desktop_shell/src/sound/alert.rs +++ b/userland/capsule_desktop_shell/src/sound/alert.rs @@ -18,28 +18,19 @@ use core::sync::atomic::{AtomicBool, AtomicU32, Ordering}; -use nonos_policy_client::{get_bool, lookup}; -use nonos_policy_proto::Field; +use nonos_policy_client::lookup; use crate::state::NotifyLevel; -/// 880 Hz for 90 ms: short enough not to sit over the toast it announces. const ALERT_HZ: u32 = 880; const ALERT_MS: u32 = 90; -/// The gain the audio service's own tone uses. -pub(super) const GAIN: u16 = 0x2000; - -/// The caller runs about once a second, and nobody moves this switch often. const EVERY: u32 = 8; -static ENABLED: AtomicBool = AtomicBool::new(false); static DUE: AtomicBool = AtomicBool::new(false); static PORT: AtomicU32 = AtomicU32::new(0); static TICKS: AtomicU32 = AtomicU32::new(0); -// Info is something that happened on its own. Warn and Error are answers to -// what the reader just did, and those are the ones worth a sound. pub fn mark(level: NotifyLevel) { if matches!(level, NotifyLevel::Info) { return; @@ -47,16 +38,16 @@ pub fn mark(level: NotifyLevel) { DUE.store(true, Ordering::Relaxed); } -/// Clears the flag either way, so a tone marked while the switch was off does -/// not sound the moment it is turned on. pub fn service() { follow(); - if DUE.swap(false, Ordering::Relaxed) && ENABLED.load(Ordering::Relaxed) { - super::play::play(ALERT_HZ, ALERT_MS, GAIN); + if !DUE.swap(false, Ordering::Relaxed) || !super::levels::alerts_on() { + return; + } + if let Some(gain) = super::levels::gain() { + super::play::play(ALERT_HZ, ALERT_MS, gain); } } -/// Off until the store says otherwise, which is the stored default too. fn follow() { if TICKS.fetch_add(1, Ordering::Relaxed) % EVERY != 0 { return; @@ -69,7 +60,5 @@ fn follow() { }; PORT.store(port, Ordering::Relaxed); } - if let Some(value) = get_bool(port, Field::AlertSounds) { - ENABLED.store(value, Ordering::Relaxed); - } + super::levels::follow(port); } diff --git a/userland/capsule_desktop_shell/src/sound/chime.rs b/userland/capsule_desktop_shell/src/sound/chime.rs index 62660b8d3e..c8dfb77712 100644 --- a/userland/capsule_desktop_shell/src/sound/chime.rs +++ b/userland/capsule_desktop_shell/src/sound/chime.rs @@ -35,5 +35,8 @@ pub fn chime() { if get_bool(port, Field::StartupChime) != Some(true) { return; } - super::play::play(CHIME_HZ, CHIME_MS, super::alert::GAIN); + super::levels::follow(port); + if let Some(gain) = super::levels::gain() { + super::play::play(CHIME_HZ, CHIME_MS, gain); + } } diff --git a/userland/capsule_desktop_shell/src/sound/levels.rs b/userland/capsule_desktop_shell/src/sound/levels.rs new file mode 100644 index 0000000000..fe4122fba5 --- /dev/null +++ b/userland/capsule_desktop_shell/src/sound/levels.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 . + +use core::sync::atomic::{AtomicBool, AtomicU32, Ordering}; + +use nonos_policy_client::{get_bool, get_u8}; +use nonos_policy_proto::Field; + +// Gain at Volume 100. The stored default, 64, gives 0x2000, the level the +// shell's tones were tuned at. +const FULL_GAIN: u32 = 12_800; + +static SOUND: AtomicBool = AtomicBool::new(true); +static ALERTS: AtomicBool = AtomicBool::new(false); +static VOLUME: AtomicU32 = AtomicU32::new(64); + +// Read the three sound settings; a field the store does not answer keeps its last value. +pub(super) fn follow(port: u32) { + if let Some(v) = get_bool(port, Field::SoundEnabled) { + SOUND.store(v, Ordering::Relaxed); + } + if let Some(v) = get_bool(port, Field::AlertSounds) { + ALERTS.store(v, Ordering::Relaxed); + } + if let Some(v) = get_u8(port, Field::Volume) { + VOLUME.store(v.min(100) as u32, Ordering::Relaxed); + } +} + +pub(super) fn alerts_on() -> bool { + ALERTS.load(Ordering::Relaxed) +} + +// The gain to play at, or None when sound is off or the volume is zero. +pub(super) fn gain() -> Option { + if !SOUND.load(Ordering::Relaxed) { + return None; + } + let g = FULL_GAIN * VOLUME.load(Ordering::Relaxed) / 100; + (g > 0).then_some(g as u16) +} diff --git a/userland/capsule_desktop_shell/src/sound/mod.rs b/userland/capsule_desktop_shell/src/sound/mod.rs index 5f2db5c8f5..9ee4d5c4c8 100644 --- a/userland/capsule_desktop_shell/src/sound/mod.rs +++ b/userland/capsule_desktop_shell/src/sound/mod.rs @@ -18,6 +18,7 @@ mod alert; mod chime; +mod levels; mod play; pub use alert::{mark, service}; diff --git a/userland/capsule_desktop_shell/src/state/apps.rs b/userland/capsule_desktop_shell/src/state/apps.rs index bc0d735945..63deaa674b 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, @@ -39,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" }, @@ -50,6 +51,7 @@ pub const LAUNCHER_APPS: [LauncherApp; 13] = [ 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", diff --git a/userland/capsule_desktop_shell/src/state/context.rs b/userland/capsule_desktop_shell/src/state/context.rs index 27da8788cb..f7766e7b72 100644 --- a/userland/capsule_desktop_shell/src/state/context.rs +++ b/userland/capsule_desktop_shell/src/state/context.rs @@ -44,6 +44,8 @@ pub struct Context { pub toast_layer_live: bool, pub net_was_online: bool, pub clock_24h: bool, + // Whole hours east of UTC, from the Timezone setting. + pub tz_hours: i8, pub policy_port: u32, pub next_request_id: u32, /// Entries at the VFS root, shown as icons on the desktop. Loaded lazily diff --git a/userland/capsule_desktop_shell/src/state/indicators/clock.rs b/userland/capsule_desktop_shell/src/state/indicators/clock.rs index 8fdde96f26..d10038b5fe 100644 --- a/userland/capsule_desktop_shell/src/state/indicators/clock.rs +++ b/userland/capsule_desktop_shell/src/state/indicators/clock.rs @@ -14,13 +14,9 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -use nonos_libc::{mk_time_rtc, RtcTime}; +use nonos_libc::RtcTime; -pub fn hhmm(buf: &mut [u8; 5], h24: bool) -> bool { - let mut t = RtcTime::default(); - if mk_time_rtc(&mut t as *mut RtcTime) != 0 { - return false; - } +pub fn hhmm(buf: &mut [u8; 5], t: &RtcTime, h24: bool) { let hour = if h24 { t.hour } else { @@ -34,23 +30,4 @@ pub fn hhmm(buf: &mut [u8; 5], h24: bool) -> bool { buf[2] = b':'; buf[3] = b'0' + (t.minute / 10) % 10; buf[4] = b'0' + t.minute % 10; - true -} - -pub fn ymd(buf: &mut [u8; 10]) -> bool { - let mut t = RtcTime::default(); - if mk_time_rtc(&mut t as *mut RtcTime) != 0 { - return false; - } - buf[0] = b'0' + ((t.year / 1000) % 10) as u8; - buf[1] = b'0' + ((t.year / 100) % 10) as u8; - buf[2] = b'0' + ((t.year / 10) % 10) as u8; - buf[3] = b'0' + (t.year % 10) as u8; - buf[4] = b'-'; - buf[5] = b'0' + (t.month / 10) % 10; - buf[6] = b'0' + t.month % 10; - buf[7] = b'-'; - buf[8] = b'0' + (t.day / 10) % 10; - buf[9] = b'0' + t.day % 10; - true } diff --git a/userland/capsule_desktop_shell/src/state/indicators/clock_stamp.rs b/userland/capsule_desktop_shell/src/state/indicators/clock_stamp.rs index 5fe324a773..9616e130f6 100644 --- a/userland/capsule_desktop_shell/src/state/indicators/clock_stamp.rs +++ b/userland/capsule_desktop_shell/src/state/indicators/clock_stamp.rs @@ -15,7 +15,7 @@ // along with this program. If not, see . use super::clock::hhmm; -use nonos_libc::{mk_time_rtc, RtcTime}; +use super::local_time; /// `Thu 20 Aug 02:33` — the menu bar's single-line stamp. pub const STAMP_LEN: usize = 17; @@ -28,11 +28,11 @@ const SHIFT: [i32; 12] = [0, 3, 2, 5, 0, 3, 5, 1, 4, 6, 2, 4]; /// Fill `buf` with the stamp, or leave it untouched and answer `false` when the /// RTC does not respond. -pub fn stamp(buf: &mut [u8; STAMP_LEN], h24: bool) -> bool { - let mut t = RtcTime::default(); - if mk_time_rtc(&mut t as *mut RtcTime) != 0 { +// The day, date and time in the user's time zone. +pub fn stamp(buf: &mut [u8; STAMP_LEN], h24: bool, offset_hours: i8) -> bool { + let Some(t) = local_time::now(offset_hours) else { return false; - } + }; let month = (t.month as usize).clamp(1, 12); buf[..3].copy_from_slice(DAYS[weekday(t.year as i32, month, t.day as i32)]); buf[3] = b' '; @@ -43,9 +43,7 @@ pub fn stamp(buf: &mut [u8; STAMP_LEN], h24: bool) -> bool { buf[10] = b' '; buf[11] = b' '; let mut hm = [b'-'; 5]; - if !hhmm(&mut hm, h24) { - return false; - } + hhmm(&mut hm, &t, h24); buf[12..].copy_from_slice(&hm); true } diff --git a/userland/capsule_desktop_shell/src/state/indicators/local_time.rs b/userland/capsule_desktop_shell/src/state/indicators/local_time.rs new file mode 100644 index 0000000000..da5e6678e8 --- /dev/null +++ b/userland/capsule_desktop_shell/src/state/indicators/local_time.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 nonos_libc::{mk_time_rtc, RtcTime}; + +// The wall clock shifted by the user's whole-hour offset, with the date +// carried across midnight and month ends. None when the RTC does not answer. +pub fn now(offset_hours: i8) -> Option { + let mut t = RtcTime::default(); + if mk_time_rtc(&mut t as *mut RtcTime) != 0 { + return None; + } + let mut hour = t.hour as i32 + offset_hours as i32; + let mut days = days_from_civil(t.year as i32, t.month as i32, t.day as i32); + days += hour.div_euclid(24) as i64; + hour = hour.rem_euclid(24); + let (y, m, d) = civil_from_days(days); + Some(RtcTime { year: y as u16, month: m as u8, day: d as u8, hour: hour as u8, ..t }) +} + +// Days since 1970-01-01 in the proleptic Gregorian calendar (Hinnant). +fn days_from_civil(y: i32, m: i32, d: i32) -> i64 { + let y = if m <= 2 { y - 1 } else { y } as i64; + let era = y.div_euclid(400); + let yoe = y - era * 400; + let mp = (m as i64 + 9) % 12; + let doy = (153 * mp + 2) / 5 + d as i64 - 1; + let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; + era * 146_097 + doe - 719_468 +} + +fn civil_from_days(z: i64) -> (i64, i64, i64) { + let z = z + 719_468; + let era = z.div_euclid(146_097); + let doe = z - era * 146_097; + let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let d = doy - (153 * mp + 2) / 5 + 1; + let m = if mp < 10 { mp + 3 } else { mp - 9 }; + (if m <= 2 { yoe + era * 400 + 1 } else { yoe + era * 400 }, m, d) +} diff --git a/userland/capsule_desktop_shell/src/state/indicators/mod.rs b/userland/capsule_desktop_shell/src/state/indicators/mod.rs index 27e8281c9c..7ec5e39fda 100644 --- a/userland/capsule_desktop_shell/src/state/indicators/mod.rs +++ b/userland/capsule_desktop_shell/src/state/indicators/mod.rs @@ -17,5 +17,7 @@ pub mod battery; pub mod clock; pub mod clock_stamp; +pub mod local_time; pub mod net; +pub mod notify_gate; pub mod policy; diff --git a/userland/capsule_desktop_shell/src/state/indicators/notify_gate.rs b/userland/capsule_desktop_shell/src/state/indicators/notify_gate.rs new file mode 100644 index 0000000000..3103fcecbe --- /dev/null +++ b/userland/capsule_desktop_shell/src/state/indicators/notify_gate.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::{AtomicBool, Ordering}; + +use nonos_policy_client::get_bool; +use nonos_policy_proto::Field; + +use crate::state::NotifyLevel; + +static ENABLED: AtomicBool = AtomicBool::new(true); + +// Follow the Notifications setting; an unanswered read keeps the last value. +pub fn follow(port: u32) { + if port == 0 { + return; + } + if let Some(v) = get_bool(port, Field::NotificationsEnabled) { + ENABLED.store(v, Ordering::Relaxed); + } +} + +// With notifications off, an app's news is dropped; warnings and errors still show. +pub fn shows(level: NotifyLevel) -> bool { + ENABLED.load(Ordering::Relaxed) || !matches!(level, NotifyLevel::Info) +} diff --git a/userland/capsule_desktop_shell/src/state/indicators/policy.rs b/userland/capsule_desktop_shell/src/state/indicators/policy.rs index ce3c2ad8e0..9432c292f6 100644 --- a/userland/capsule_desktop_shell/src/state/indicators/policy.rs +++ b/userland/capsule_desktop_shell/src/state/indicators/policy.rs @@ -63,3 +63,8 @@ pub fn clock_24h(port_slot: &mut u32) -> Option { } Some(rx[HDR_LEN] != 0) } + +// Whole hours east of UTC; needs the port `clock_24h` found this tick. +pub fn timezone(port: u32) -> Option { + (port != 0).then(|| nonos_policy_client::get_i8(port, nonos_policy_proto::Field::Timezone))? +} diff --git a/userland/capsule_driver_virtio_blk/src/main.rs b/userland/capsule_driver_virtio_blk/src/main.rs index d653356338..833203d94f 100644 --- a/userland/capsule_driver_virtio_blk/src/main.rs +++ b/userland/capsule_driver_virtio_blk/src/main.rs @@ -26,7 +26,7 @@ mod queue; mod regs; mod server; mod setup; -use nonos_libc::{heap_init, mk_debug, mk_exit, mk_yield}; +use nonos_libc::{heap_init, mk_debug, mk_exit, mk_yield, Deadline}; #[no_mangle] pub unsafe extern "C" fn _start() -> ! { if heap_init().is_err() { @@ -53,7 +53,11 @@ pub unsafe extern "C" fn _start() -> ! { last = step; } rounds = rounds.wrapping_add(1); - for _ in 0..64 { + // Each round claims and releases the device: back off from + // 50 ms to 5 s so a part that cannot start does not churn. + let wait = (50u64 << rounds.min(7)).min(5_000); + let until = Deadline::after_ms(wait); + while !until.expired() { mk_yield(); } } diff --git a/userland/capsule_driver_virtio_gpu/src/constants/modern.rs b/userland/capsule_driver_virtio_gpu/src/constants/modern.rs index 0e080cc30f..b195f9aac6 100644 --- a/userland/capsule_driver_virtio_gpu/src/constants/modern.rs +++ b/userland/capsule_driver_virtio_gpu/src/constants/modern.rs @@ -28,3 +28,6 @@ pub const MOD_QUEUE_DEVICE: usize = 0x30; pub const FEATURE_PAGE_LOW: u32 = 0; pub const FEATURE_PAGE_HIGH: u32 = 1; pub const VIRTIO_F_VERSION_1_HIGH: u32 = 1; +// Bit 33: the device's DMA goes through the platform IOMMU. Offered only +// when the machine puts the device behind one (QEMU's iommu_platform=on). +pub const VIRTIO_F_ACCESS_PLATFORM_HIGH: u32 = 1 << 1; diff --git a/userland/capsule_driver_virtio_gpu/src/init/modern.rs b/userland/capsule_driver_virtio_gpu/src/init/modern.rs index 069684c114..bddc776b87 100644 --- a/userland/capsule_driver_virtio_gpu/src/init/modern.rs +++ b/userland/capsule_driver_virtio_gpu/src/init/modern.rs @@ -20,11 +20,10 @@ use crate::constants::{ MOD_DEVICE_FEATURE_SELECT, MOD_DEVICE_STATUS, MOD_DRIVER_FEATURE, MOD_DRIVER_FEATURE_SELECT, MOD_QUEUE_DESC, MOD_QUEUE_DEVICE, MOD_QUEUE_DRIVER, MOD_QUEUE_ENABLE, MOD_QUEUE_NOTIFY_OFF, MOD_QUEUE_SELECT, MOD_QUEUE_SIZE, STATUS_ACKNOWLEDGE, STATUS_DRIVER, STATUS_DRIVER_OK, - STATUS_FAILED, STATUS_FEATURES_OK, VIRTIO_F_VERSION_1_HIGH, VIRTIO_GPU_F_EDID, VQ_AVAIL_OFFSET, - VQ_DESC_OFFSET, VQ_MAX_SIZE, VQ_USED_OFFSET, + STATUS_FAILED, STATUS_FEATURES_OK, VIRTIO_F_ACCESS_PLATFORM_HIGH, VIRTIO_F_VERSION_1_HIGH, + VIRTIO_GPU_F_EDID, VQ_AVAIL_OFFSET, VQ_DESC_OFFSET, VQ_MAX_SIZE, VQ_USED_OFFSET, }; use crate::regs::Regs; - pub fn bring_up_modern(regs: Regs, queue_phys: u64) -> Result { unsafe { regs.w8(MOD_DEVICE_STATUS, 0); @@ -33,18 +32,19 @@ pub fn bring_up_modern(regs: Regs, queue_phys: u64) -> Result. -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)); } diff --git a/userland/capsule_linux/Capsule.mk b/userland/capsule_linux/Capsule.mk index f5fe147388..4603bfe15b 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,16 @@ 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 +# The install and run roles (src/userspace/capsule_linux/roles.rs) answer on +# their own endpoints. The spawn gate refuses any endpoint the signed manifest +# does not list, so without these every store install and every run of an +# installed package was refused before the capsule started. +CAPSULE_INSTANCE_ENDPOINTS := service:4938:app.linux.install reply:4939:endpoint.app.linux.install.reply service:4942:app.linux.run reply:4943:endpoint.app.linux.run.reply +CAPSULE_REQUIRED_CAPS := 0x300001939 +# Network (bit 2) is optional: only the install role asks for it, to reach a +# package mirror through net.sockets (roles.rs). A guest runs without it. +CAPSULE_OPTIONAL_CAPS := 0x4 +CAPSULE_CAPS_CEILING := 0x30000193D CAPSULE_KERNEL_MIRROR := src/userspace/capsule_linux include nonos-mk/capsule.mk diff --git a/userland/capsule_linux/Cargo.lock b/userland/capsule_linux/Cargo.lock index 5d8a4577dd..2767973cc0 100644 --- a/userland/capsule_linux/Cargo.lock +++ b/userland/capsule_linux/Cargo.lock @@ -22,6 +22,56 @@ 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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +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" @@ -31,6 +81,66 @@ 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 = "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" +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 = "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" +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" @@ -67,15 +177,50 @@ dependencies = [ name = "nonos_capsule_linux" version = "0.1.0" dependencies = [ + "blake3", "nonos_app_skeleton", + "nonos_ed25519", + "nonos_hash", "nonos_inflate", + "nonos_openpgp", + "nonos_tls", "nonos_userland_libc", + "nonos_xz", + "nonos_zstd", + "sha1", +] + +[[package]] +name = "nonos_ed25519" +version = "0.1.0" +dependencies = [ + "nonos_hash", + "spin", ] +[[package]] +name = "nonos_hash" +version = "0.1.0" + [[package]] name = "nonos_inflate" version = "0.3.0" +[[package]] +name = "nonos_openpgp" +version = "0.1.0" +dependencies = [ + "nonos_hash", + "sha1", +] + +[[package]] +name = "nonos_tls" +version = "0.2.0" +dependencies = [ + "nonos_userland_libc", +] + [[package]] name = "nonos_toolkit" version = "0.3.0" @@ -92,6 +237,17 @@ dependencies = [ "linked_list_allocator", ] +[[package]] +name = "nonos_xz" +version = "0.1.0" +dependencies = [ + "nonos_hash", +] + +[[package]] +name = "nonos_zstd" +version = "0.1.0" + [[package]] name = "owned_ttf_parser" version = "0.25.1" @@ -107,6 +263,23 @@ 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 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" @@ -133,3 +306,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..3e87c7cd39 100644 --- a/userland/capsule_linux/Cargo.toml +++ b/userland/capsule_linux/Cargo.toml @@ -24,6 +24,14 @@ 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" } +nonos_openpgp = { path = "../openpgp" } +nonos_ed25519 = { path = "../nonos_ed25519" } +nonos_zstd = { path = "../zstd" } +nonos_xz = { path = "../xz" } +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/abi/disclosure.txt b/userland/capsule_linux/abi/disclosure.txt new file mode 100644 index 0000000000..73d10c2300 --- /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; an inode derived from the path alone; fixed times and owner | size is of shared installs or the family's own files; the inode says nothing the path does not +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 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/build.rs b/userland/capsule_linux/build.rs new file mode 100644 index 0000000000..2d96db3adc --- /dev/null +++ b/userland/capsule_linux/build.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 . + +//! Pins the package keyrings into the capsule. NONOS_PACMAN_KEYRING and +//! NONOS_DEB_KEYRING each name a keyring file. Unset, pacman's is empty and +//! every pacman install is refused; Debian's is Kali's pinned archive key. A named file that cannot +//! be read fails the build, since an image that silently lost its keyring +//! would look like one built without it. + +use std::path::{Path, PathBuf}; +use std::{env, fs, process}; + +const SETTINGS: [&str; 9] = [ + "NONOS_PACMAN_MIRROR", + "NONOS_PACMAN_HOST", + "NONOS_PACMAN_PATH", + "NONOS_PACMAN_REPOS", + "NONOS_DEB_MIRROR", + "NONOS_DEB_HOST", + "NONOS_DEB_ROOT", + "NONOS_DEB_SUITE", + "NONOS_DEB_COMPONENTS", +]; + +fn main() { + for var in SETTINGS { + println!("cargo:rerun-if-env-changed={var}"); + } + let Some(out) = env::var_os("OUT_DIR").map(PathBuf::from) else { fail("no OUT_DIR") }; + pin("NONOS_PACMAN_KEYRING", &out.join("pacman-keyring.gpg"), None); + // Kali's archive key is pinned by default; see design/package-trust.md. + pin("NONOS_DEB_KEYRING", &out.join("deb-keyring.gpg"), Some("keys/kali/archive-key-2025.asc")); +} + +/// The keyring `var` names, else `default` (relative to this crate), else none. +fn pin(var: &str, to: &Path, default: Option<&str>) { + println!("cargo:rerun-if-env-changed={var}"); + let path = env::var(var).ok().or_else(|| default.map(String::from)); + let ring = match path { + Some(path) => { + println!("cargo:rerun-if-changed={path}"); + fs::read(&path).unwrap_or_else(|e| fail(&format!("{var}={path}: {e}"))) + } + None => Vec::new(), + }; + if let Err(e) = fs::write(to, ring) { + fail(&format!("writing {}: {e}", to.display())); + } +} + +fn fail(why: &str) -> ! { + eprintln!("{why}"); + process::exit(1) +} diff --git a/userland/capsule_linux/design/install-network.md b/userland/capsule_linux/design/install-network.md new file mode 100644 index 0000000000..652b14de34 --- /dev/null +++ b/userland/capsule_linux/design/install-network.md @@ -0,0 +1,19 @@ +# How an install reaches a mirror + +Every package fetch goes over the Nym mixnet, so what this machine installs +is not visible to the network it sits on, nor to the mirror as coming from it. + +One exception, decided 2026-09-27: a mirror on a private or link-local +address (10/8, 172.16/12, 192.168/16, 169.254/16) is dialled directly. A +mixnet exit is on the public internet and cannot reach such an address, and a +LAN or offline mirror is how a machine without a reachable mixnet, or a site +with its own mirror, installs at all. Each such fetch is logged +(`[LINUX] mirror is on the local network: reached directly`). + +What that discloses: to anyone on that local network, that this machine +fetched from that mirror, and which files. Nothing reaches the public +internet directly on this path. What it does not change: every file is still +held to its distribution's signature and checksums, whichever path fetched it. + +The decision is `net/route.rs::is_local`; the proof crate pins which addresses +count, including that anything not a plain dotted quad stays on the mixnet. diff --git a/userland/capsule_linux/design/package-trust.md b/userland/capsule_linux/design/package-trust.md new file mode 100644 index 0000000000..5f2b0f5d86 --- /dev/null +++ b/userland/capsule_linux/design/package-trust.md @@ -0,0 +1,88 @@ +# Package trust anchors + +A pacman or Debian backend verifies nothing until its keyring is pinned at +build time (`NONOS_PACMAN_KEYRING`, `NONOS_DEB_KEYRING`). Without one it +refuses every install and says so. That is the correct state until an anchor +has been established, and it is where both families stand. + +## The rule + +A trust anchor comes from outside the channel it protects, and from more than +one place agreeing: + +1. The distribution's keyring package from its repository, with the + fingerprints read out of the package itself. +2. The distribution's published bootstrap (for BlackArch, `strap.sh`) and the + key it pins, fetched over TLS with the certificate checked. +3. The distribution's own announcement of the key, or of a rotation. + +If all three agree, the key is pinned and the commit records where each came +from and on what date. If they disagree, the disagreement is the finding and +nothing is pinned. A fingerprint recalled from memory, a model's included, is +not a source. + +## What a pinned keyring is trusted for + +Two claims, which the code must not blur: + +- "Key X says these are the distribution's signing keys." That is what a + keyring package signed by X establishes. +- "This package is authentic." That is established only by a signature over + the package, or over the index that names its checksum, from a key the + anchor admits. + +Today `install/pgp` accepts a signature from any key in the pinned file. When +an anchor is pinned, that must narrow to the keys the distribution marks as +trusted for signing (for pacman, the `-trusted` list), not every key the file +happens to contain. + +## BlackArch, 2026-09-27: not pinned + +- Source 2, `https://blackarch.org/strap.sh` over TLS: pins master key + `4345771566D76038C7FEB43863EC0ADBEA87E4E3` (Evan Teitelman) and keyring + version 20251011. +- The keyring tarball `blackarch-keyring-20251011.tar.gz` is signed by + `CBA3C7D4798912702DCF568E67D8BDF42AD93F4E`, not by the key `strap.sh` pins. +- Source 1, `blackarch-keyring-20251011-2-any.pkg.tar.zst` from the BlackArch + repository (SHA-256 matching its database record): `blackarch.gpg` holds + eight primary keys, including both of the above. Its `blackarch-trusted` + list marks four as trusted: `8F9A9793CB8591147C2EC70566E0CDBD1E01F333`, + `A0917C4147A37007CB54C1CFD295AA940EFDDF62`, + `4345771566D76038C7FEB43863EC0ADBEA87E4E3`, + `F9A6E68A711354D84A9B91637533BAFE69A25079`. The tarball's signer, `CBA3…`, + is not among them. +- Source 3: not obtained. The keyring's history is on GitHub, which this + environment's network policy refuses (403), and the news and blog pages on + blackarch.org name no key or rotation. +- Sources 1 and 2 are both served from blackarch.org, the channel the anchor + would protect. + +Finding: the keyring's signer is outside the keyring's own trusted list, and +no independent source was reachable. The backend stays keyless. + +## Kali, 2026-09-27: pinned + +Pinned: `827C8569F2518CC677FECA1AED65462EC8D5E4C5`, "Kali Linux Archive +Automatic Signing Key (2025)", RSA 4096, created 2025-04-17, expires +2028-04-17, in `keys/kali/archive-key-2025.asc` (SHA-256 `bbaef4b3...71b1`). +All three sources name it: + +1. The `kali-archive-keyring` 2025.2 package from kali-rolling (SHA-256 + `9250b08f...c8cf0`, matching its Packages record): `kali-archive-keyring.gpg` + holds this key, and also the old repository key `ED444FF07D8D0BF6`. +2. `https://archive.kali.org/archive-key.asc` over TLS: this key alone. +3. Kali's announcement, `https://www.kali.org/blog/new-kali-archive-signing-key/`: + names this key as the new signing key, and says Kali lost access to the old + one. + +Only the 2025 key is pinned: the old key's holder says they no longer control +it, so a signature under it proves nothing. The kali-rolling `Release` fetched +the same day verifies under the pin, and the proof crate checks that against +the committed copy. + +What it is trusted for: that a `Release` for the suite is Kali's. A package is +authentic only through that `Release`, by the checksum it gives the Packages +file, and the checksum that file gives the `.deb`. + +Known limit: kali-rolling's `Release` has no `Valid-Until`, so an older, validly +signed `Release` replayed by a mirror is not caught. diff --git a/userland/capsule_linux/design/socket-model.md b/userland/capsule_linux/design/socket-model.md new file mode 100644 index 0000000000..419db7ed83 --- /dev/null +++ b/userland/capsule_linux/design/socket-model.md @@ -0,0 +1,62 @@ +# Socket model decision, item 9 + +`bind`, `listen` and `accept4` are in scope. A machine aimed at the Kali and +BlackArch tool set where nothing can listen is not that machine: reverse shells, +local proxies, and payload servers are the normal case, not an exotic one. So +implement all three. + +They are mediated, not free. The rule is the one the rest of this kernel already +uses: a guest does what a capability lets it do, and the capability names the +resource rather than granting a class of operation. + +## The capability + +Add `NetBind`. It is not implied by the capability that lets a guest make an +outbound connection, and a guest holding neither still gets `connect`. + +A holder of `NetBind` carries a bind set: a list of `(interface, port range, +protocol)` triples. `bind` succeeds only when the requested address falls inside +it. Everything else is `EACCES`, with the refused address named in the log. + +Follow the shape the service registry already uses. Runtime registration there is +an allowlist of five endpoints rather than a denylist of reserved names, and that +was the right correction. Do the same here: a guest is given what it may bind, +never a list of what it may not. + +## Defaults + +- A guest with no `NetBind` cannot bind at all. `bind` returns `EACCES`. +- Loopback and external are different resources and are named separately in the + bind set. A guest allowed to bind `127.0.0.1:8080` is not thereby allowed to + bind `0.0.0.0:8080`. Most pentest tooling only needs loopback, so that is the + common grant and the cheap one. +- Port 0, meaning "pick one for me", allocates only from inside the bind set. +- `SO_REUSEADDR` and `SO_REUSEPORT` do not let a guest take a port another guest + holds. Two guests never share a bound port. +- `listen` on an unbound socket is `EINVAL`, as on Linux. It is not an implicit + bind. +- `accept4` returns a socket inheriting the listener's confinement. The accepted + fd carries no authority the listener did not have. + +## Out of scope, refused by name + +Raw sockets, `AF_PACKET`, and anything that reaches the link layer. A guest that +can forge frames is not confined by anything above it. Refuse with `EPERM` and a +named reason in the log, not `ENOSYS`, so the refusal reads as a decision rather +than a gap. + +## What to prove + +1. A guest without `NetBind` cannot bind, for every address. +2. A guest with a bind set cannot bind outside it, including via port 0. +3. Two guests cannot hold the same port, with `SO_REUSEPORT` set on both. +4. An accepted socket carries no authority the listener lacked. +5. A refused bind is logged with the address it asked for. + +Write 1 and 2 as theorems if the decision function is pure enough to extract. +The rest are guest-suite tests in the shape the existing ten use. + +## Order + +Do this after the network block is lifted and items 5 to 8 are closed, because a +listener with no package to run behind it proves nothing. 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/keys/kali/archive-key-2025.asc b/userland/capsule_linux/keys/kali/archive-key-2025.asc new file mode 100644 index 0000000000..5ded9f7059 --- /dev/null +++ b/userland/capsule_linux/keys/kali/archive-key-2025.asc @@ -0,0 +1,29 @@ +-----BEGIN PGP PUBLIC KEY BLOCK----- + +mQINBGgBJJUBEADlMTZVDCjrSXIAuYfL3VZt8OoplUdw3mSPlhIjZQmIo2sdzvAF +EMSCQ+vWeD4VqV9tBtiVx6j8VSfyW18YHHAkvajWDRg5hPLf80wGxrtXYu+vj3Ri +5dOMhrl9fHKIifPOoV3pFTtOk0dB9lkcmtNzjWgwOJduLbjjraE1BBKqc0uaXDCa +RJnPYkQuJQcZxmZVFAo9NP7KSAL1zMvutAd0R3WeMaWpT22nGa3rJj4kj25zV6Kn +qGnv5kQaY2cTlQHnp6EbiLe5sCE7zIOp5CjwIJhyCyn4zT8KqGB8Sw8PEi9mYlSY +wbGzzfAAbBk7Y8xbmvRrkrHzU74jH0iMK566QVu2yl3Dz0hrlliV6vGn2ZWu7qmh +lwXSb+q4u46tDbFjdUjYJG2upx5vOm5SewD9snLB4YN2e2qDeQgY16AfpkJa51+u +PwTeDCbfuQu3irLWcGRZgpOBgsxqCtpZBmF6ED7L8tntoyjZ9WeB8FnTcv7hx5J1 +IPCO4K5TvW0SX6ZKp1Jusbkn5hrrFTjOJHhIDVdioM/wYDKkqJ9e25oGAqPkJYRY +euonU1teK+EOLM7ZIbalhukrw0bgYl9UJRxQMLEhZzoiiCLLiv3oWHAQGFclP+1E +zXgbLBviFAU4+DMXfhA6vy8BmS9oTpleS1p3/EOwf2rX/yt4qF7IW9ZXuQARAQAB +tEBLYWxpIExpbnV4IEFyY2hpdmUgQXV0b21hdGljIFNpZ25pbmcgS2V5ICgyMDI1 +KSA8ZGV2ZWxAa2FsaS5vcmc+iQJUBBMBCgA+FiEEgnyFafJRjMZ3/soa7WVGLsjV +5MUFAmgF7tkCGwMFCQWkfeUFCwkIBwIGFQoJCAsCBBYCAwECHgECF4AACgkQ7WVG +LsjV5MXcjw//XeI6OXY7VcH+hXRcT7W49AwqRfmSaSEWs474G2DQR9UppzvkFCab +uiWl5jrlkeGbVFsiBruJfIlCdYMMnPk8gEm/SEhVRqcZVOjYCWcMlSVB6oU+6tgW +jKPPRDELiq7mTl8S4sEdvUxpsWoMqEQZ1+CsJsw+p+TARGNIrUUdL9hTOoOUpvue +nKNEEfzbKvLk2gj2tKOgr1HcDmVbbmRsL87+UYq1JvA0OzJ0KrhBdTZHJWchAJwa +p+UUog2XrzvXYXWBPfQLsNVkFirmVd1B5vonj3OeNlVU51YriRQ4P4onLrwlfha8 +vUGeNJw/ihXTQFpvmF7fFSRa7Pr5YfWkDZ4BGuEB+kSycu2PMWCXXHdY++cMIlRf +uUg/wvzcwAkS99DJ0EAiOun0oypE5+r5HwfaI9IrJlgZMPlFctyBIGVg2DFZCdLH +VHG1Voq/CU2tgWvWyuHXHVlUiZiWJoj7BbVa88Gj+VyvB/md1xBh0ScmfH4uGgnX +hpLFPIVuR1SJYarovVmtFhAjbqbrAA4Q9utpOeOOVDMD5tuq856/lLh+SWPkRsUy +ZJTwz1Nh0rJ/UJOMSo4ljkkr53iR/IM4woAAaP+0hkZoIDSbVVW5Im1Yj461exl4 +0ltMBMym2KZk/IFOTloSfW7hMmGlqaLfQEH1ryHefIIpkgKJa6WgVxA= +=f+tz +-----END PGP PUBLIC KEY BLOCK----- diff --git a/userland/capsule_linux/src/linux/abi/errno.rs b/userland/capsule_linux/src/linux/abi/errno.rs index 7cbcaebde8..448c49fa73 100644 --- a/userland/capsule_linux/src/linux/abi/errno.rs +++ b/userland/capsule_linux/src/linux/abi/errno.rs @@ -39,6 +39,7 @@ pub const ENFILE: i64 = 23; pub const EMFILE: i64 = 24; pub const ENOTTY: i64 = 25; pub const ESPIPE: i64 = 29; +pub const EROFS: i64 = 30; pub const EPIPE: i64 = 32; pub const ERANGE: i64 = 34; pub const ELOOP: i64 = 40; @@ -49,6 +50,7 @@ pub const ENOTCONN: i64 = 107; pub const ENOTSOCK: i64 = 88; pub const ENOTSUP: i64 = 95; pub const EAFNOSUPPORT: i64 = 97; +pub const ETIMEDOUT: i64 = 110; pub const ECONNREFUSED: i64 = 111; pub const EINPROGRESS: i64 = 115; diff --git a/userland/capsule_linux/src/linux/abi/mod.rs b/userland/capsule_linux/src/linux/abi/mod.rs index fe250836a1..68e04090aa 100644 --- a/userland/capsule_linux/src/linux/abi/mod.rs +++ b/userland/capsule_linux/src/linux/abi/mod.rs @@ -23,3 +23,4 @@ pub mod name; pub mod nr; pub mod nr_path; pub mod nr_high; +pub mod nr_sched; diff --git a/userland/capsule_linux/src/linux/abi/name.rs b/userland/capsule_linux/src/linux/abi/name.rs index 875b825ace..3f5a115440 100644 --- a/userland/capsule_linux/src/linux/abi/name.rs +++ b/userland/capsule_linux/src/linux/abi/name.rs @@ -36,6 +36,7 @@ pub fn of(number: u64) -> &'static [u8] { nr::BRK => b"brk", nr::RT_SIGACTION => b"rt_sigaction", nr::RT_SIGPROCMASK => b"rt_sigprocmask", + nr::RT_SIGRETURN => b"rt_sigreturn", nr::IOCTL => b"ioctl", nr::READV => b"readv", nr::WRITEV => b"writev", diff --git a/userland/capsule_linux/src/linux/abi/nr.rs b/userland/capsule_linux/src/linux/abi/nr.rs index ee370fdb37..1e800243be 100644 --- a/userland/capsule_linux/src/linux/abi/nr.rs +++ b/userland/capsule_linux/src/linux/abi/nr.rs @@ -18,6 +18,7 @@ //! Linux x86_64 syscall numbers, by family. pub use super::nr_high::*; +pub use super::nr_sched::*; pub const READ: u64 = 0; pub const WRITE: u64 = 1; @@ -34,6 +35,7 @@ pub const MUNMAP: u64 = 11; pub const BRK: u64 = 12; pub const RT_SIGACTION: u64 = 13; pub const RT_SIGPROCMASK: u64 = 14; +pub const RT_SIGRETURN: u64 = 15; pub const IOCTL: u64 = 16; pub const PREAD64: u64 = 17; pub const PWRITE64: u64 = 18; @@ -48,6 +50,7 @@ pub const NANOSLEEP: u64 = 35; pub const GETPID: u64 = 39; pub const SOCKET: u64 = 41; pub const CONNECT: u64 = 42; +pub const ACCEPT: u64 = 43; pub const SENDTO: u64 = 44; pub const RECVFROM: u64 = 45; pub const SENDMSG: u64 = 46; diff --git a/userland/capsule_linux/src/linux/abi/nr_high.rs b/userland/capsule_linux/src/linux/abi/nr_high.rs index 4652e125ca..279ee8dbe4 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. @@ -30,13 +29,18 @@ pub const GETDENTS64: u64 = 217; pub const WAIT4: u64 = 61; pub const EPOLL_CTL: u64 = 233; pub const DUP3: u64 = 292; +pub const ACCEPT4: u64 = 288; pub const PIPE2: u64 = 293; pub const TIMERFD_CREATE: u64 = 283; pub const TIMERFD_SETTIME: u64 = 286; +pub const TIMERFD_GETTIME: u64 = 287; +pub const EVENTFD: u64 = 284; +pub const EVENTFD2: u64 = 290; 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; @@ -45,3 +49,9 @@ pub const PRLIMIT64: u64 = 302; pub const GETRANDOM: u64 = 318; pub const MEMFD_CREATE: u64 = 319; pub const RSEQ: u64 = 334; +pub const MREMAP: u64 = 25; +pub const PRCTL: u64 = 157; +pub const SCHED_GETAFFINITY: u64 = 204; +pub const GETCPU: u64 = 309; +pub const MEMBARRIER: u64 = 324; +pub const CLONE3: u64 = 435; diff --git a/userland/capsule_linux/src/linux/abi/nr_path.rs b/userland/capsule_linux/src/linux/abi/nr_path.rs index 733c624bbe..ca08f795b9 100644 --- a/userland/capsule_linux/src/linux/abi/nr_path.rs +++ b/userland/capsule_linux/src/linux/abi/nr_path.rs @@ -56,7 +56,26 @@ pub const FSTATFS: u64 = 138; pub const STATX: u64 = 332; pub const KILL: u64 = 62; pub const TKILL: u64 = 200; +pub const TGKILL: u64 = 234; pub const GETRESUID: u64 = 118; pub const GETRESGID: u64 = 120; pub const PPOLL: u64 = 271; pub const EPOLL_WAIT: u64 = 232; + +// Links, owners, times and nodes: set one of a faithful install. +pub const LINK: u64 = 86; +pub const SYMLINK: u64 = 88; +pub const READLINKAT: u64 = 267; +pub const SYMLINKAT: u64 = 266; +pub const LINKAT: u64 = 265; +pub const RENAMEAT: u64 = 264; +pub const RENAMEAT2: u64 = 316; +pub const CHOWN: u64 = 92; +pub const FCHOWN: u64 = 93; +pub const LCHOWN: u64 = 94; +pub const FCHOWNAT: u64 = 260; +pub const UTIME: u64 = 132; +pub const UTIMES: u64 = 235; +pub const UTIMENSAT: u64 = 280; +pub const MKNOD: u64 = 133; +pub const MKNODAT: u64 = 259; diff --git a/userland/capsule_linux/src/linux/abi/nr_sched.rs b/userland/capsule_linux/src/linux/abi/nr_sched.rs new file mode 100644 index 0000000000..6c1b672706 --- /dev/null +++ b/userland/capsule_linux/src/linux/abi/nr_sched.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 . + +//! Linux x86_64 numbers for the scheduler calls and the other epoll forms. +//! Same contract as `nr`, which re-exports them. + +pub const SCHED_SETPARAM: u64 = 142; +pub const SCHED_GETPARAM: u64 = 143; +pub const SCHED_SETSCHEDULER: u64 = 144; +pub const SCHED_GETSCHEDULER: u64 = 145; +pub const SCHED_GET_PRIORITY_MAX: u64 = 146; +pub const SCHED_GET_PRIORITY_MIN: u64 = 147; +pub const SCHED_SETAFFINITY: u64 = 203; +pub const EPOLL_CREATE: u64 = 213; +pub const EPOLL_PWAIT2: u64 = 441; 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..8b604395bf --- /dev/null +++ b/userland/capsule_linux/src/linux/boot_guest.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 . + +//! 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}; +use crate::linux::start::say; + +/// 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 = 1024; + +/// 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 = read_when_ready()?; + 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, args)) +} + +// The file's bytes, or None once a settled store says it has none. Until the +// VFS has finished loading the store from disk, a missing file may only be +// not loaded yet: on SMP this ran before staging and took busybox instead. +fn read_when_ready() -> Option> { + if !super::settle::wait_settled() { + return None; + } + match store_read(&key(BOOT_GUEST), MAX_NAME) { + Ok(named) => Some(named), + Err("vfs open failed") => None, + Err(_) => { + say(b"[LINUX] boot guest unreadable: store did not answer\n"); + None + } + } +} 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/call/clock.rs b/userland/capsule_linux/src/linux/call/clock.rs new file mode 100644 index 0000000000..534cb4725d --- /dev/null +++ b/userland/capsule_linux/src/linux/call/clock.rs @@ -0,0 +1,74 @@ +// 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 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; + +use super::epoch::family_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 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 }) +} + +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/clone.rs b/userland/capsule_linux/src/linux/call/clone.rs deleted file mode 100644 index 39bdd04d12..0000000000 --- a/userland/capsule_linux/src/linux/call/clone.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 . - - -//! `clone`, for threads only. - -use nonos_libc::{mk_foreign_thread, ForeignFrame}; - -use crate::linux::abi::errno; -use crate::linux::guest::Guest; -use crate::linux::serve::Answer; - -const CLONE_VM: u64 = 0x100; -const CLONE_THREAD: u64 = 0x10000; - -/// musl's `__clone` resumes the child at the instruction after its own -/// `syscall`, with rax zero and rsp pointing at the function and argument -/// it pushed. So the child's entry is the caller's return address and -/// nothing else will do. -pub fn clone(guest: &mut Guest, frame: &ForeignFrame) -> Answer { - let a = frame.args(); - let (flags, stack, tls) = (a[0], a[1], a[4]); - if flags & (CLONE_VM | CLONE_THREAD) != CLONE_VM | CLONE_THREAD { - // A new process, not a thread. That is fork, and fork needs an - // address space copy no peer call offers. - return Answer::value(errno::fail(errno::ENOSYS)); - } - if frame.rip == 0 { - // The kernel is not yet passing the guest's return address, so - // there is nowhere correct to start the child. Refusing beats - // starting it at an address that is not its own. - return Answer::value(errno::fail(errno::ENOSYS)); - } - if stack == 0 { - return Answer::value(errno::fail(errno::EINVAL)); - } - let tid = mk_foreign_thread(guest.pid, frame.rip, stack, tls); - if tid < 0 { - return Answer::value(errno::fail(errno::ENOMEM)); - } - guest.threads.push(tid as u32); - Answer::value(errno::ok(tid as u64)) -} diff --git a/userland/capsule_linux/src/linux/call/ctl.rs b/userland/capsule_linux/src/linux/call/ctl.rs index fc5fbc6687..4c3c129f6a 100644 --- a/userland/capsule_linux/src/linux/call/ctl.rs +++ b/userland/capsule_linux/src/linux/call/ctl.rs @@ -14,10 +14,11 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -//! `ioctl` and `fcntl`. +//! `fcntl`. use crate::linux::abi::errno; -use crate::linux::guest::Guest; +use crate::linux::file::flags::{O_NONBLOCK, O_RDWR, O_WRONLY}; +use crate::linux::guest::{Fd, Guest, Kind}; const F_DUPFD: u64 = 0; const F_GETFD: u64 = 1; @@ -25,13 +26,6 @@ const F_SETFD: u64 = 2; const F_GETFL: u64 = 3; const F_SETFL: u64 = 4; -pub fn ioctl(guest: &Guest, fd: u64, _request: u64) -> u64 { - match guest.fds.get(fd as usize) { - Some(entry) if entry.is_open() => errno::fail(errno::ENOTTY), - _ => errno::fail(errno::EBADF), - } -} - /// The only descriptor flag there is. const FD_CLOEXEC: u64 = 1; @@ -50,12 +44,15 @@ pub fn fcntl(guest: &mut Guest, fd: u64, cmd: u64, arg: u64) -> u64 { errno::ok(0) } /* - * Reported as the read-write the descriptor already has; a request to - * change them is accepted because none of the flags a program sets - * here has an effect. + * O_NONBLOCK is the status flag that changes what a call does here, + * so it is the one kept. The rest a program can set (O_APPEND, + * O_ASYNC, O_DIRECT, O_NOATIME) are accepted and have no effect. */ - F_SETFL => errno::ok(0), - F_GETFL => errno::ok(2), + F_SETFL => { + entry.nonblock = arg & O_NONBLOCK != 0; + errno::ok(0) + } + F_GETFL => errno::ok(status(entry)), /* * Duplication needs a second handle on the server, which the store * does not offer yet. @@ -64,3 +61,14 @@ pub fn fcntl(guest: &mut Guest, fd: u64, cmd: u64, arg: u64) -> u64 { _ => errno::fail(errno::EINVAL), } } + +/// The access mode and O_NONBLOCK. A pipe's ends are read-only and +/// write-only, as `pipe2` makes them; anything else reads as read-write. +fn status(entry: &Fd) -> u64 { + let mode = match entry.kind { + Kind::Pipe if entry.writable => O_WRONLY, + Kind::Pipe => 0, + _ => O_RDWR, + }; + mode | if entry.nonblock { O_NONBLOCK } else { 0 } +} 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/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/futex.rs b/userland/capsule_linux/src/linux/call/futex.rs index 2375d414a6..d077f443b2 100644 --- a/userland/capsule_linux/src/linux/call/futex.rs +++ b/userland/capsule_linux/src/linux/call/futex.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 . - //! `futex`, entirely in this capsule. //! //! A waiter is a guest thread already parked inside its trap, so the wait @@ -25,28 +24,62 @@ use crate::linux::abi::errno; use crate::linux::guest::Guest; use crate::linux::serve::Answer; +use super::futex_requeue::requeue; +use super::futex_time::deadline; + const FUTEX_WAIT: u64 = 0; const FUTEX_WAKE: u64 = 1; +const FUTEX_REQUEUE: u64 = 3; +const FUTEX_CMP_REQUEUE: u64 = 4; +const FUTEX_WAIT_BITSET: u64 = 9; +const FUTEX_WAKE_BITSET: u64 = 10; +/// The operation, without FUTEX_PRIVATE_FLAG and FUTEX_CLOCK_REALTIME. const OP_MASK: u64 = 0x7F; -pub fn futex(guest: &mut Guest, tid: u32, uaddr: u64, op: u64, val: u64) -> Answer { +/// `futex(uaddr, op, val, timeout or val2, uaddr2, val3)`. A bitset is +/// taken as matching every waiter: a wake it would not have chosen is a +/// spurious wake, which every futex caller already loops on. +pub fn futex(guest: &mut Guest, tid: u32, a: [u64; 6]) -> Answer { + let (uaddr, op, val) = (a[0], a[1], a[2]); + let no_bits = a[5] as u32 == 0; match op & OP_MASK { - FUTEX_WAIT => wait(guest, tid, uaddr, val), - FUTEX_WAKE => Answer::value(errno::ok(guest.wake(uaddr, val))), + FUTEX_WAIT_BITSET | FUTEX_WAKE_BITSET if no_bits => { + Answer::value(errno::fail(errno::EINVAL)) + } + FUTEX_WAIT => wait(guest, tid, uaddr, val, deadline(guest, a[3], op, false)), + FUTEX_WAIT_BITSET => wait(guest, tid, uaddr, val, deadline(guest, a[3], op, true)), + FUTEX_WAKE | FUTEX_WAKE_BITSET => Answer::value(errno::ok(guest.wake(uaddr, val))), + FUTEX_REQUEUE => requeue(guest, [uaddr, val, a[3], a[4]], None), + FUTEX_CMP_REQUEUE => requeue(guest, [uaddr, val, a[3], a[4]], Some(a[5] as u32)), _ => Answer::value(errno::fail(errno::ENOSYS)), } } -fn wait(guest: &mut Guest, tid: u32, uaddr: u64, val: u64) -> Answer { - let Some(bytes) = guest.read(uaddr, 4) else { +fn wait( + guest: &mut Guest, + tid: u32, + uaddr: u64, + val: u64, + until: Result, u64>, +) -> Answer { + let until = match until { + Ok(until) => until, + Err(refused) => return Answer::value(refused), + }; + let Some(seen) = super::futex_requeue::word(guest, uaddr) else { return Answer::value(errno::fail(errno::EFAULT)); }; - let seen = u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]); // The word changed between the caller's own check and this one, so // the condition it was going to sleep on is already false. - if seen as u64 != val { + if u64::from(seen) != val & 0xFFFF_FFFF { return Answer::value(errno::fail(errno::EAGAIN)); } + if let Some(when) = until { + if when <= super::now_ms(super::futex_time::CLOCK_MONOTONIC).unwrap_or(0) { + return Answer::value(errno::fail(errno::ETIMEDOUT)); + } + guest.futex_until.push((when, tid)); + } guest.waits.push((tid, uaddr)); Answer::Park } diff --git a/userland/capsule_linux/src/linux/call/futex_requeue.rs b/userland/capsule_linux/src/linux/call/futex_requeue.rs new file mode 100644 index 0000000000..6816d5609e --- /dev/null +++ b/userland/capsule_linux/src/linux/call/futex_requeue.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 . + +//! FUTEX_REQUEUE and FUTEX_CMP_REQUEUE: wake some waiters on one word and +//! move more of them to wait on another, as musl's condition variables do +//! to hand their waiters to the mutex. + +use crate::linux::abi::errno; +use crate::linux::guest::Guest; +use crate::linux::serve::Answer; + +/// `[uaddr, wake, move, uaddr2]`; `expect` is CMP_REQUEUE's val3. Plain +/// requeue answers how many it woke, the compare form how many it woke or +/// moved. +pub fn requeue(guest: &mut Guest, a: [u64; 4], expect: Option) -> Answer { + let [from, wake, moved, to] = a; + let (wake, moved) = (wake as u32 as i32, moved as u32 as i32); + if wake < 0 || moved < 0 { + return Answer::value(errno::fail(errno::EINVAL)); + } + if let Some(want) = expect { + match word(guest, from) { + None => return Answer::value(errno::fail(errno::EFAULT)), + Some(seen) if seen != want => return Answer::value(errno::fail(errno::EAGAIN)), + Some(_) => {} + } + } + let woken = guest.wake(from, wake as u64); + let mut shifted = 0u64; + for w in guest.waits.iter_mut().filter(|w| w.1 == from).take(moved as usize) { + w.1 = to; + shifted += 1; + } + Answer::value(errno::ok(if expect.is_some() { woken + shifted } else { woken })) +} + +/// The futex word at `uaddr`. +pub fn word(guest: &Guest, uaddr: u64) -> Option { + let bytes = guest.read(uaddr, 4)?; + Some(u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]])) +} diff --git a/userland/capsule_linux/src/linux/call/futex_time.rs b/userland/capsule_linux/src/linux/call/futex_time.rs new file mode 100644 index 0000000000..e7344fc843 --- /dev/null +++ b/userland/capsule_linux/src/linux/call/futex_time.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 . + +//! When a futex wait gives up. +//! +//! FUTEX_WAIT takes a relative timeout. FUTEX_WAIT_BITSET takes an absolute +//! one, on CLOCK_MONOTONIC unless FUTEX_CLOCK_REALTIME is set. Every deadline +//! here is on the guest's monotonic clock, in milliseconds, rounded up. + +use crate::linux::abi::errno; +use crate::linux::guest::Guest; + +use super::now_ms; + +pub const CLOCK_MONOTONIC: u64 = 1; +const CLOCK_REALTIME: u64 = 0; +const FUTEX_CLOCK_REALTIME: u64 = 256; +const NSEC: i64 = 1_000_000_000; + +/// None for no timeout; an errno for a timespec Linux refuses. +pub fn deadline(guest: &Guest, at: u64, op: u64, absolute: bool) -> Result, u64> { + if at == 0 { + return Ok(None); + } + let Some(spec) = guest.read(at, 16) else { + return Err(errno::fail(errno::EFAULT)); + }; + let secs = i64::from_le_bytes(spec[..8].try_into().unwrap_or([0; 8])); + let nanos = i64::from_le_bytes(spec[8..16].try_into().unwrap_or([0; 8])); + if secs < 0 || !(0..NSEC).contains(&nanos) { + return Err(errno::fail(errno::EINVAL)); + } + let span = + (secs as u64).saturating_mul(1000).saturating_add((nanos as u64).div_ceil(1_000_000)); + let mono = now_ms(CLOCK_MONOTONIC).unwrap_or(0); + Ok(Some(match (absolute, op & FUTEX_CLOCK_REALTIME != 0) { + (false, _) => mono.saturating_add(span), + (true, false) => span, + // A wall-clock time is a distance from now on the wall clock. + (true, true) => { + mono.saturating_add(span.saturating_sub(now_ms(CLOCK_REALTIME).unwrap_or(0))) + } + })) +} diff --git a/userland/capsule_linux/src/linux/call/glibc.rs b/userland/capsule_linux/src/linux/call/glibc.rs new file mode 100644 index 0000000000..10a142e803 --- /dev/null +++ b/userland/capsule_linux/src/linux/call/glibc.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 . + +//! `prctl`, for what glibc and runtimes ask of it: a thread's name, and the +//! two flags whose honest answer this machine already gives. + +use alloc::collections::BTreeMap; +use core::cell::RefCell; + +use crate::linux::abi::errno; +use crate::linux::guest::Guest; + +const PR_SET_NAME: u64 = 15; +const PR_GET_NAME: u64 = 16; +const PR_SET_NO_NEW_PRIVS: u64 = 38; +const PR_GET_NO_NEW_PRIVS: u64 = 39; +const PR_GET_DUMPABLE: u64 = 3; +const PR_SET_DUMPABLE: u64 = 4; +const NAME_LEN: usize = 16; + +// Per thread in this family, so a name set on one thread is not another's. +// The personality answers one trap at a time on one thread, so a RefCell. +struct Names(RefCell>); +// SAFETY: eK@nonos.systems - only the personality's single serve loop touches it. +unsafe impl Sync for Names {} +static NAMES: Names = Names(RefCell::new(BTreeMap::new())); + +pub fn prctl(guest: &Guest, tid: u32, option: u64, arg: u64) -> u64 { + match option { + PR_SET_NAME => match guest.read(arg, NAME_LEN) { + Some(raw) => { + let mut name = [0u8; NAME_LEN]; + let end = raw.iter().position(|b| *b == 0).unwrap_or(NAME_LEN - 1); + name[..end.min(NAME_LEN - 1)].copy_from_slice(&raw[..end.min(NAME_LEN - 1)]); + NAMES.0.borrow_mut().insert(tid, name); + errno::ok(0) + } + None => errno::fail(errno::EFAULT), + }, + PR_GET_NAME => { + let name = NAMES.0.borrow().get(&tid).copied().unwrap_or([0u8; NAME_LEN]); + match guest.write(arg, &name) == NAME_LEN as i64 { + true => errno::ok(0), + false => errno::fail(errno::EFAULT), + } + } + // Nothing here ever raises privilege, so no-new-privs already holds. + PR_SET_NO_NEW_PRIVS if arg == 1 => errno::ok(0), + PR_GET_NO_NEW_PRIVS => errno::ok(1), + // No core is ever written, so a guest is not dumpable and cannot be made so. + PR_GET_DUMPABLE => errno::ok(0), + PR_SET_DUMPABLE if arg == 0 => errno::ok(0), + _ => errno::fail(errno::EINVAL), + } +} diff --git a/userland/capsule_linux/src/linux/call/glibc_sched.rs b/userland/capsule_linux/src/linux/call/glibc_sched.rs new file mode 100644 index 0000000000..ace66de5fa --- /dev/null +++ b/userland/capsule_linux/src/linux/call/glibc_sched.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 glibc probes before main: how many CPUs, membarrier, clone3, getcpu. + +use crate::linux::abi::errno; +use crate::linux::guest::Guest; + +/* + * One CPU, always. The core count is a fingerprint (section 8: a guest must + * not identify the machine), the same reason the fingerprint suite refuses + * the host's pid count. A thread pool sized from this runs, only narrower. + */ +const CPUS: usize = 1; + +pub fn sched_getaffinity(guest: &Guest, size: u64, mask: u64) -> u64 { + let bytes = CPUS.div_ceil(64) * 8; + if (size as usize) < bytes || size % 8 != 0 { + return errno::fail(errno::EINVAL); + } + let mut out = [0u8; 8]; + out[0] = 1; + match guest.write(mask, &out[..bytes]) == bytes as i64 { + true => errno::ok(bytes as u64), + false => errno::fail(errno::EFAULT), + } +} + +/// `getcpu`: the one CPU and node that affinity reports. +pub fn getcpu(guest: &Guest, cpu: u64, node: u64) -> u64 { + for at in [cpu, node] { + if at != 0 && guest.write(at, &0u32.to_le_bytes()) != 4 { + return errno::fail(errno::EFAULT); + } + } + errno::ok(0) +} + +/// MEMBARRIER_CMD_QUERY answers that no command is offered, which glibc and +/// the runtimes that use it read as "fall back to their own barriers". +pub fn membarrier(cmd: u64) -> u64 { + match cmd { + 0 => errno::ok(0), + _ => errno::fail(errno::EINVAL), + } +} + +/// `clone3` is refused by name: glibc tries it first and falls back to +/// `clone`, which is served, on ENOSYS and only on ENOSYS. +pub fn clone3() -> u64 { + errno::fail(errno::ENOSYS) +} diff --git a/userland/capsule_linux/src/linux/call/io.rs b/userland/capsule_linux/src/linux/call/io.rs index c49c170d62..f5145519bf 100644 --- a/userland/capsule_linux/src/linux/call/io.rs +++ b/userland/capsule_linux/src/linux/call/io.rs @@ -36,6 +36,7 @@ pub fn write(guest: &mut Guest, fd: u64, buf: u64, len: u64) -> u64 { Some(Kind::Socket) => socket_write(guest, fd, buf, len), Some(Kind::Unix) => crate::linux::unix::send(guest, fd, buf, len), Some(Kind::Pipe) => super::pipe_write(guest, fd, buf, len), + Some(Kind::Event) => file::event_write(guest, fd, buf, len), Some(Kind::Resolver) => net::dns::query(guest, fd, buf, len, LOOPBACK_53), Some(Kind::Dir) => errno::fail(errno::EISDIR), _ => errno::fail(errno::EBADF), @@ -46,10 +47,11 @@ pub fn read(guest: &mut Guest, fd: u64, buf: u64, len: u64) -> u64 { // Nothing is typed at a guest yet, and end of file is the truth. Some(Kind::Stdin) => errno::ok(0), Some(Kind::File) => file::read(guest, fd, buf, len), - Some(Kind::Timer) => file::timerfd_read(guest, fd, buf), + Some(Kind::Timer) => file::timerfd_read(guest, fd, buf, len), Some(Kind::Socket) => socket_read(guest, fd, buf, len), Some(Kind::Unix) => crate::linux::unix::recv(guest, fd, buf, len), Some(Kind::Pipe) => super::pipe_read(guest, fd, buf, len), + Some(Kind::Event) => file::event_read(guest, fd, buf, len), Some(Kind::Resolver) => net::dns::answer_out(guest, fd, buf, len).0, Some(Kind::Dir) => errno::fail(errno::EISDIR), _ => errno::fail(errno::EBADF), diff --git a/userland/capsule_linux/src/linux/call/ioctl.rs b/userland/capsule_linux/src/linux/call/ioctl.rs new file mode 100644 index 0000000000..97597bc0d8 --- /dev/null +++ b/userland/capsule_linux/src/linux/call/ioctl.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 . + +//! `ioctl`: the requests that are about a descriptor rather than a device. +//! There is no terminal or device behind any descriptor here, so anything +//! else is ENOTTY, which is also how a program learns it is not on a tty. + +use crate::linux::abi::errno; +use crate::linux::guest::{Guest, Kind}; + +const FIONREAD: u64 = 0x541B; +const FIONBIO: u64 = 0x5421; +const FIONCLEX: u64 = 0x5450; +const FIOCLEX: u64 = 0x5451; + +pub fn ioctl(guest: &mut Guest, fd: u64, request: u64, arg: u64) -> u64 { + let Some(entry) = guest.fds.get_mut(fd as usize).filter(|e| e.is_open()) else { + return errno::fail(errno::EBADF); + }; + match request & 0xFFFF_FFFF { + FIOCLEX | FIONCLEX => { + entry.cloexec = request & 0xFFFF_FFFF == FIOCLEX; + errno::ok(0) + } + FIONBIO => match guest.read(arg, 4) { + Some(raw) => { + let on = raw.iter().any(|&b| b != 0); + if let Some(entry) = guest.fds.get_mut(fd as usize) { + entry.nonblock = on; + } + errno::ok(0) + } + None => errno::fail(errno::EFAULT), + }, + FIONREAD => match waiting(guest, fd) { + Some(n) if guest.write(arg, &(n.min(i32::MAX as u64) as i32).to_le_bytes()) == 4 => { + errno::ok(0) + } + Some(_) => errno::fail(errno::EFAULT), + None => errno::fail(errno::ENOTTY), + }, + _ => errno::fail(errno::ENOTTY), + } +} + +/// Bytes a read would find now: what a pipe holds, or what is left of a +/// file past its offset. None for a descriptor Linux answers ENOTTY for. +fn waiting(guest: &Guest, fd: u64) -> Option { + let entry = guest.fds.get(fd as usize)?; + match entry.kind { + Kind::Pipe if !entry.writable => { + guest.pipes.get(entry.handle as usize).map(|p| p.len() as u64) + } + Kind::File => Some(entry.size.saturating_sub(entry.offset)), + _ => None, + } +} diff --git a/userland/capsule_linux/src/linux/call/life.rs b/userland/capsule_linux/src/linux/call/life.rs index 79b5d5b781..47bf13e0df 100644 --- a/userland/capsule_linux/src/linux/call/life.rs +++ b/userland/capsule_linux/src/linux/call/life.rs @@ -17,12 +17,46 @@ //! Ending a guest. The call never returns to the guest, so the answer //! handed back is only what parks it until the supervisor tears it down. +use nonos_libc::mk_kill; + use crate::linux::abi::errno; use crate::linux::guest::Guest; +use crate::linux::serve::Answer; + +const SIGKILL: u64 = 9; -pub fn exit_thread(guest: &mut Guest, tid: u32) -> u64 { +/// A thread's own exit ends that thread and never returns to it. The word it +/// named with CLONE_CHILD_CLEARTID or set_tid_address is zeroed and one waiter +/// woken, as Linux does; that is what a joiner waits for. Left unanswered, the +/// thread is killed, so it cannot run past its exit. +pub fn exit_thread(guest: &mut Guest, tid: u32) -> Answer { + if let Some(at) = guest.clear_tids.iter().position(|(t, _)| *t == tid) { + let (_, word) = guest.clear_tids.remove(at); + if guest.write(word, &0u32.to_le_bytes()) == 4 { + guest.wake(word, 1); + } + } guest.threads.retain(|t| *t != tid); - errno::ok(0) + guest.signals.set_stack(tid, None); + let rc = mk_kill(u64::from(tid), SIGKILL); + if rc < 0 { + let line = alloc::format!( + "[LINUX] kill refused: exited thread {tid} stays parked, errno {}\n", + -rc + ); + crate::linux::start::say(line.as_bytes()); + } + Answer::Park +} + +/// set_tid_address names the word to clear when the calling thread exits, and +/// answers with its tid. +pub fn set_tid_address(guest: &mut Guest, tid: u32, word: u64) -> Answer { + guest.clear_tids.retain(|(t, _)| *t != tid); + if word != 0 { + guest.clear_tids.push((tid, word)); + } + Answer::value(u64::from(tid)) } pub fn exit(guest: &mut Guest, code: u64) -> u64 { diff --git a/userland/capsule_linux/src/linux/call/mem/map.rs b/userland/capsule_linux/src/linux/call/mem/map.rs index 68c0fb11e0..904072cdbf 100644 --- a/userland/capsule_linux/src/linux/call/mem/map.rs +++ b/userland/capsule_linux/src/linux/call/mem/map.rs @@ -17,7 +17,7 @@ //! `mmap`: anonymous pages, or a private mapping of a file. use crate::linux::abi::errno; -use crate::linux::guest::{span_within, Guest, MMAP_LIMIT, STACK_TOP}; +use crate::linux::guest::{span_within, Guest, MMAP_LIMIT, USER_MAX}; use super::map_anon::{anonymous, memfd}; use super::map_file::file; @@ -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,9 +35,15 @@ 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), + Some(addr) => (addr, USER_MAX), None => (guest.mmap_next, MMAP_LIMIT), }; let Some((at, span)) = span_within(at, req.len, limit) else { diff --git a/userland/capsule_linux/src/linux/call/mem/map_anon.rs b/userland/capsule_linux/src/linux/call/mem/map_anon.rs index 00410f8065..0158b7f263 100644 --- a/userland/capsule_linux/src/linux/call/mem/map_anon.rs +++ b/userland/capsule_linux/src/linux/call/mem/map_anon.rs @@ -44,9 +44,16 @@ pub fn memfd(guest: &mut Guest, req: &MapReq, at: u64, span: u64) -> u64 { } pub fn anonymous(guest: &mut Guest, req: &MapReq, at: u64, span: u64) -> u64 { - let write = req.prot & PROT_WRITE != 0; - let exec = req.prot & PROT_EXEC != 0; - if guest.map(at, span, write, exec) < 0 { + // A PROT_NONE anonymous mapping is a reservation: the runtime that makes it + // (Go's, for one) commits a fraction of it later with a fixed RW mapping. + // Backing the whole span here would spend real frames on address space no + // one has touched, so reserve it and let the first access fault a page in. + let backed = if req.prot == 0 { + guest.reserve(at, span) + } else { + guest.map(at, span, req.prot & PROT_WRITE != 0, req.prot & PROT_EXEC != 0) + }; + if backed < 0 { return errno::fail(errno::ENOMEM); } if req.fixed().is_none() { 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 1bfd0302df..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,28 +32,28 @@ 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 fill_read(guest, req, at) < 0 { + return errno::fail(errno::EACCES); } + // Not proved, since nothing asked to run it: it stays that way. + 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); } 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..423f1a164a 100644 --- a/userland/capsule_linux/src/linux/call/mem/mod.rs +++ b/userland/capsule_linux/src/linux/call/mem/mod.rs @@ -21,12 +21,16 @@ mod map; mod map_anon; mod map_exec; mod map_file; +mod map_fill; mod map_req; mod memory; mod prot; mod prot_span; +mod remap; +mod remap_move; pub use map::mmap; pub use map_req::MapReq; pub use memory::{brk, munmap}; pub use prot::mprotect; +pub use remap::mremap; diff --git a/userland/capsule_linux/src/linux/call/mem/prot.rs b/userland/capsule_linux/src/linux/call/mem/prot.rs index 5136faba6f..9d060a23ef 100644 --- a/userland/capsule_linux/src/linux/call/mem/prot.rs +++ b/userland/capsule_linux/src/linux/call/mem/prot.rs @@ -17,12 +17,14 @@ //! `mprotect`, and the rule that makes it necessary. use crate::linux::abi::errno; -use crate::linux::guest::{span_within, Guest, STACK_TOP}; +use crate::linux::guest::{span_within, Guest, USER_MAX}; use super::prot_span::protect_span; pub const PROT_WRITE: u64 = 2; pub const PROT_EXEC: u64 = 4; +/// PROT_READ, PROT_WRITE and PROT_EXEC together: any access at all. +const PROT_ANY: u64 = 7; /// A request for both at once. pub fn wx_refused(prot: u64) -> bool { @@ -40,11 +42,49 @@ pub fn mprotect(guest: &mut Guest, addr: u64, len: u64, prot: u64) -> u64 { * Checked, because `len` is the guest's: `addr + len` wraps and the * span computed from the wrapped value comes out enormous. */ - let Some((start, span)) = span_within(addr, len, STACK_TOP) else { + let Some((start, span)) = span_within(addr, len, USER_MAX) else { return errno::fail(errno::EINVAL); }; - if protect_span(guest, start, span, prot) < 0 { - return errno::fail(errno::EACCES); + /* + * 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); + } + let end = start + span; + let mut at = start; + while at < end { + let Some(r) = guest.regions.iter().find(|r| r.at <= at && at < r.at + r.len).copied() + else { + // Linux refuses a span with no mapping in it at all. + return errno::fail(errno::ENOMEM); + }; + let upto = end.min(r.at + r.len); + let piece = upto - at; + if !r.backed { + /* + * A PROT_NONE reservation has no pages for the kernel to + * reprotect. Asking for access commits it, which is how musl makes + * a thread stack: reserve with PROT_NONE, then mprotect the part + * it uses to read-write. PROT_NONE on it changes nothing. + */ + if prot & PROT_ANY == 0 { + at = upto; + continue; + } + if guest.commit(at, piece, prot & PROT_WRITE != 0, prot & PROT_EXEC != 0) < 0 { + return errno::fail(errno::ENOMEM); + } + } + // Every page is present now; this sets `prot` on all of them, + // including any the guest touched while the span was reserved. + if protect_span(guest, at, piece, prot) < 0 { + return errno::fail(errno::EACCES); + } + at = upto; } errno::ok(0) } diff --git a/userland/capsule_linux/src/linux/call/mem/remap.rs b/userland/capsule_linux/src/linux/call/mem/remap.rs new file mode 100644 index 0000000000..add8ae3a4e --- /dev/null +++ b/userland/capsule_linux/src/linux/call/mem/remap.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 . + +//! `mremap`: shrink in place, grow in place when the pages after are free, +//! or move with MREMAP_MAYMOVE. glibc's realloc of a large block is this. + +use crate::linux::abi::errno; +use crate::linux::guest::{page_up, span_within, Guest, MMAP_LIMIT, PAGE}; + +const MAYMOVE: u64 = 1; + +pub fn mremap(guest: &mut Guest, old: u64, old_len: u64, new_len: u64, flags: u64) -> u64 { + // MREMAP_FIXED and DONTUNMAP choose the destination; neither is offered. + if flags & !MAYMOVE != 0 || old % PAGE != 0 || old_len == 0 || new_len == 0 { + return errno::fail(errno::EINVAL); + } + let (old_len, new_len) = (page_up(old_len), page_up(new_len)); + if guest.mapped_from(old) < old_len { + return errno::fail(errno::EFAULT); + } + let Some(r) = guest.regions.iter().find(|r| r.at <= old && old < r.at + r.len).copied() else { + return errno::fail(errno::EFAULT); + }; + // Code was proved where it was mapped; a moved copy would not be. + if r.exec { + return errno::fail(errno::EPERM); + } + if new_len <= old_len { + if new_len < old_len && guest.unmap(old + new_len, old_len - new_len) < 0 { + return errno::fail(errno::EINVAL); + } + return errno::ok(old); + } + let tail = old + old_len; + let grow = new_len - old_len; + let free = !guest.regions.iter().any(|g| g.at < tail + grow && tail < g.at + g.len); + if free + && span_within(tail, grow, MMAP_LIMIT).is_some() + && guest.map(tail, grow, r.write, false) >= 0 + { + guest.mmap_next = guest.mmap_next.max(tail + grow); + return errno::ok(old); + } + if flags & MAYMOVE == 0 { + return errno::fail(errno::ENOMEM); + } + super::remap_move::moved(guest, old, old_len, new_len, r.write) +} diff --git a/userland/capsule_linux/src/linux/call/mem/remap_move.rs b/userland/capsule_linux/src/linux/call/mem/remap_move.rs new file mode 100644 index 0000000000..b8b3930d7e --- /dev/null +++ b/userland/capsule_linux/src/linux/call/mem/remap_move.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 . + +//! `mremap` when the block cannot grow where it is: moved to fresh pages. + +use crate::linux::abi::errno; +use crate::linux::guest::{span_within, Guest, MMAP_LIMIT}; + +const PROT_READ: u64 = 1; + +// A fresh span at the mapping cursor, the old bytes copied in, the old span gone. +pub(super) fn moved(guest: &mut Guest, old: u64, old_len: u64, new_len: u64, write: bool) -> u64 { + let Some((at, span)) = span_within(guest.mmap_next, new_len, MMAP_LIMIT) else { + return errno::fail(errno::ENOMEM); + }; + let Some(bytes) = guest.read(old, old_len as usize) else { + return errno::fail(errno::EFAULT); + }; + // Writable while the bytes go in; the old protection after. + if guest.map(at, span, true, false) < 0 { + return errno::fail(errno::ENOMEM); + } + guest.mmap_next += span; + if guest.write(at, &bytes) < bytes.len() as i64 { + return errno::fail(errno::EFAULT); + } + if !write { + let _ = super::prot::mprotect(guest, at, span, PROT_READ); + } + let _ = guest.unmap(old, old_len); + errno::ok(at) +} diff --git a/userland/capsule_linux/src/linux/call/mod.rs b/userland/capsule_linux/src/linux/call/mod.rs index 23894c0e09..88652b43fb 100644 --- a/userland/capsule_linux/src/linux/call/mod.rs +++ b/userland/capsule_linux/src/linux/call/mod.rs @@ -18,23 +18,35 @@ mod console; mod ctl; +mod clock; +mod epoch; mod cwd; mod futex; +mod futex_requeue; +mod futex_time; mod ident; mod io; +mod ioctl; mod io_socket; mod life; mod limits; mod limits_table; +mod glibc; +mod glibc_sched; mod mem; mod pipe; mod pipe_dup; mod pipe_end; mod pipe_io; +mod pipe_poll; mod pipe_read; +mod sched; mod session; +pub mod sigframe; mod signal; mod signal_send; +mod signal_stack; +mod sigreturn; mod sleep; mod spawn; mod thread; @@ -44,24 +56,36 @@ mod uname; mod vector; mod vector_read; -pub use ctl::{fcntl, ioctl}; +pub use ctl::fcntl; +pub use ioctl::ioctl; pub use cwd::{chdir, fchdir, getcwd}; pub use futex::futex; pub use ident::{getppid, setuid}; pub use io::{close, read, write}; -pub use life::{exit, exit_thread}; +pub use life::{exit, exit_thread, set_tid_address}; pub use limits::{getrlimit, prlimit64}; -pub use mem::{brk, mmap, mprotect, munmap, MapReq}; +pub use glibc::prctl; +pub use glibc_sched::{clone3, getcpu, membarrier, sched_getaffinity}; +pub use mem::{brk, mmap, mprotect, mremap, munmap, MapReq}; pub use pipe::pipe2; pub use pipe_dup::{dup, dup2}; pub use pipe_io::write as pipe_write; +pub use pipe_poll::bits as pipe_bits; pub use pipe_read::read as pipe_read; +pub use sched::{ + priority_bound, sched_getparam, sched_getscheduler, sched_setaffinity, sched_setparam, + sched_setscheduler, +}; pub use session::{getpgid, getsid, setpgid, setsid}; -pub use signal::{rt_sigaction, rt_sigprocmask, sigaltstack}; +pub use signal::{rt_sigaction, rt_sigprocmask}; +pub use signal_stack::sigaltstack; +pub use sigreturn::rt_sigreturn; pub use signal_send::kill; -pub use sleep::nanosleep; -pub use spawn::{clone, execve, fork, wait4}; -pub use thread::{arch_prctl, clock_gettime, getrandom}; +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}; pub use uname::uname; diff --git a/userland/capsule_linux/src/linux/call/pipe.rs b/userland/capsule_linux/src/linux/call/pipe.rs index 61a1debe7f..dd124b0eb1 100644 --- a/userland/capsule_linux/src/linux/call/pipe.rs +++ b/userland/capsule_linux/src/linux/call/pipe.rs @@ -21,7 +21,7 @@ use alloc::vec::Vec; use crate::linux::abi::errno; use crate::linux::guest::{Fd, Guest}; -use crate::linux::file::flags::O_CLOEXEC; +use crate::linux::file::flags::{O_CLOEXEC, O_NONBLOCK}; use crate::linux::file::install; pub fn pipe2(guest: &mut Guest, out: u64, flags: u64) -> u64 { @@ -33,11 +33,10 @@ pub fn pipe2(guest: &mut Guest, out: u64, flags: u64) -> u64 { let Some(write_end) = install(guest, Fd::pipe(buffer, true)) else { return errno::fail(errno::EMFILE); }; - if flags & O_CLOEXEC != 0 { - for end in [read_end, write_end] { - if let Some(fd) = guest.fds.get_mut(end as usize) { - fd.cloexec = true; - } + for end in [read_end, write_end] { + if let Some(fd) = guest.fds.get_mut(end as usize) { + fd.cloexec = flags & O_CLOEXEC != 0; + fd.nonblock = flags & O_NONBLOCK != 0; } } let mut pair = [0u8; 8]; diff --git a/userland/capsule_linux/src/linux/call/pipe_dup.rs b/userland/capsule_linux/src/linux/call/pipe_dup.rs index ca2273a1d8..6a29b0d2eb 100644 --- a/userland/capsule_linux/src/linux/call/pipe_dup.rs +++ b/userland/capsule_linux/src/linux/call/pipe_dup.rs @@ -17,11 +17,13 @@ //! `dup` and `dup2`: a second descriptor onto the same thing. use crate::linux::abi::errno; -use crate::linux::file::install; +use crate::linux::file::{install, MAX_FDS}; use crate::linux::guest::{Fd, Guest, Kind}; /// `dup2` puts the copy at a number the caller chose, which is how a -/// shell wires a pipe onto stdout before it runs a command. +/// shell wires a pipe onto stdout before it runs a command. A number past +/// the table is EBADF; one already open is closed first, as Linux closes +/// it, so its buffered bytes are written and its socket let go. pub fn dup2(guest: &mut Guest, from: u64, to: u64) -> u64 { let Some(source) = guest.fds.get(from as usize).filter(|f| f.is_open()).map(Fd::clone_of) else { @@ -30,6 +32,12 @@ pub fn dup2(guest: &mut Guest, from: u64, to: u64) -> u64 { if from == to { return errno::ok(to); } + if to >= MAX_FDS as u64 { + return errno::fail(errno::EBADF); + } + if guest.fds.get(to as usize).is_some_and(|f| f.is_open()) { + let _ = super::close(guest, to); + } while guest.fds.len() <= to as usize { guest.fds.push(Fd::empty(Kind::Free)); } diff --git a/userland/capsule_linux/src/linux/call/pipe_end.rs b/userland/capsule_linux/src/linux/call/pipe_end.rs index f1980ded64..4aa1fe1edf 100644 --- a/userland/capsule_linux/src/linux/call/pipe_end.rs +++ b/userland/capsule_linux/src/linux/call/pipe_end.rs @@ -29,3 +29,10 @@ pub fn end_of(guest: &Guest, fd: u64) -> Option<(usize, bool)> { false => None, } } + +/// Whether the other end of pipe `slot` is open anywhere in the family, seen +/// from the end that is `writable` or not. A pipe made during this answer is +/// not in the family's note yet, and both its ends are open. +pub fn other_end_open(guest: &Guest, slot: usize, writable: bool) -> bool { + guest.pipe_ends.get(slot).is_none_or(|&(read, write)| if writable { read } else { write }) +} diff --git a/userland/capsule_linux/src/linux/call/pipe_io.rs b/userland/capsule_linux/src/linux/call/pipe_io.rs index 896ff0d4e4..73104cc12f 100644 --- a/userland/capsule_linux/src/linux/call/pipe_io.rs +++ b/userland/capsule_linux/src/linux/call/pipe_io.rs @@ -19,11 +19,13 @@ use crate::linux::abi::errno; use crate::linux::guest::Guest; -use super::pipe_end::end_of; +use super::pipe_end::{end_of, other_end_open}; /// What one pipe will hold before a writer is told to wait. Linux uses /// sixty-four kilobytes and programs are written around that number. -const CAPACITY: usize = 64 << 10; +pub(super) const CAPACITY: usize = 64 << 10; +/// A write this size or smaller goes in whole or not at all. +const PIPE_BUF: usize = 4096; pub fn write(guest: &mut Guest, fd: u64, buf: u64, len: u64) -> u64 { let Some((slot, writable)) = end_of(guest, fd) else { @@ -32,9 +34,14 @@ pub fn write(guest: &mut Guest, fd: u64, buf: u64, len: u64) -> u64 { if !writable { return errno::fail(errno::EBADF); } + // Nobody can ever read it: Linux refuses the write rather than keep it. + if !other_end_open(guest, slot, true) { + return errno::fail(errno::EPIPE); + } let room = CAPACITY.saturating_sub(guest.pipes[slot].len()); - if room == 0 { - // A full pipe blocks on Linux until a reader drains it. + // Linux makes the writer wait here, and so does the serve loop's `waits` + // unless the descriptor is non-blocking. + if room == 0 || (len as usize <= PIPE_BUF && room < len as usize) { return errno::fail(errno::EAGAIN); } let take = (len as usize).min(room); diff --git a/userland/capsule_linux/src/linux/call/pipe_poll.rs b/userland/capsule_linux/src/linux/call/pipe_poll.rs new file mode 100644 index 0000000000..5fce6e389a --- /dev/null +++ b/userland/capsule_linux/src/linux/call/pipe_poll.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 pipe end can do now, in poll's bits, as Linux's `pipe_poll` says +//! it: a read end is readable while it holds bytes and hung up once no write +//! end is left; a write end is writable while there is room and in error +//! once no read end is left. + +use crate::linux::guest::Guest; +use crate::linux::net::{POLLERR, POLLHUP}; + +use super::pipe_end::{end_of, other_end_open}; +use super::pipe_io::CAPACITY; + +const POLLIN: u16 = 0x001; +const POLLOUT: u16 = 0x004; +const POLLNVAL: u16 = 0x020; + +pub fn bits(guest: &Guest, fd: u64) -> u16 { + let Some((slot, writable)) = end_of(guest, fd) else { + return POLLNVAL; + }; + let held = guest.pipes[slot].len(); + let other = other_end_open(guest, slot, writable); + match writable { + false => flag(held > 0, POLLIN) | flag(!other, POLLHUP), + true => flag(held < CAPACITY, POLLOUT) | flag(!other, POLLERR), + } +} + +fn flag(on: bool, bit: u16) -> u16 { + if on { + bit + } else { + 0 + } +} diff --git a/userland/capsule_linux/src/linux/call/pipe_read.rs b/userland/capsule_linux/src/linux/call/pipe_read.rs index fc588e4154..25a0963d8f 100644 --- a/userland/capsule_linux/src/linux/call/pipe_read.rs +++ b/userland/capsule_linux/src/linux/call/pipe_read.rs @@ -21,7 +21,7 @@ use crate::linux::abi::errno; use crate::linux::guest::Guest; -use super::pipe_end::end_of; +use super::pipe_end::{end_of, other_end_open}; pub fn read(guest: &mut Guest, fd: u64, buf: u64, len: u64) -> u64 { let Some((slot, writable)) = end_of(guest, fd) else { @@ -30,9 +30,16 @@ pub fn read(guest: &mut Guest, fd: u64, buf: u64, len: u64) -> u64 { if writable { return errno::fail(errno::EBADF); } + if len == 0 { + return errno::ok(0); + } let have = guest.pipes[slot].len(); if have == 0 { - return errno::fail(errno::EAGAIN); + // Empty with no write end left anywhere is end of file. + return match other_end_open(guest, slot, false) { + true => errno::fail(errno::EAGAIN), + false => errno::ok(0), + }; } let take = (len as usize).min(have); let bytes: alloc::vec::Vec = guest.pipes[slot].drain(..take).collect(); diff --git a/userland/capsule_linux/src/linux/call/sched.rs b/userland/capsule_linux/src/linux/call/sched.rs new file mode 100644 index 0000000000..6984117b08 --- /dev/null +++ b/userland/capsule_linux/src/linux/call/sched.rs @@ -0,0 +1,107 @@ +// 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 scheduler calls, answered as Linux answers an unprivileged process +//! on the one CPU the guest is shown. Scheduling is the kernel's: a policy a +//! guest names changes nothing, and a real-time one is refused, since no +//! guest holds the privilege Linux asks for it. + +use crate::linux::abi::errno; +use crate::linux::guest::Guest; + +const SCHED_OTHER: u64 = 0; +const SCHED_FIFO: u64 = 1; +const SCHED_RR: u64 = 2; +const SCHED_BATCH: u64 = 3; +const SCHED_IDLE: u64 = 5; +const SCHED_RESET_ON_FORK: u64 = 0x4000_0000; + +/// 0 is the caller; anything else must be one of the guest's own threads. +fn own(guest: &Guest, pid: u64) -> bool { + pid == 0 || guest.owns(pid as u32) +} + +pub fn sched_getscheduler(guest: &Guest, pid: u64) -> u64 { + match own(guest, pid) { + true => errno::ok(SCHED_OTHER), + false => errno::fail(errno::ESRCH), + } +} + +pub fn sched_setscheduler(guest: &Guest, pid: u64, policy: u64, param: u64) -> u64 { + if !own(guest, pid) { + return errno::fail(errno::ESRCH); + } + let Some(priority) = priority(guest, param) else { + return errno::fail(if param == 0 { errno::EINVAL } else { errno::EFAULT }); + }; + match policy & !SCHED_RESET_ON_FORK { + SCHED_OTHER | SCHED_BATCH | SCHED_IDLE if priority == 0 => errno::ok(0), + // The priority is checked before the privilege, as Linux orders them. + SCHED_FIFO | SCHED_RR if (1..=99).contains(&priority) => errno::fail(errno::EPERM), + _ => errno::fail(errno::EINVAL), + } +} + +pub fn sched_getparam(guest: &Guest, pid: u64, param: u64) -> u64 { + if !own(guest, pid) { + return errno::fail(errno::ESRCH); + } + match guest.write(param, &0i32.to_le_bytes()) == 4 { + true => errno::ok(0), + false => errno::fail(errno::EFAULT), + } +} + +/// Under SCHED_OTHER the only priority is zero. +pub fn sched_setparam(guest: &Guest, pid: u64, param: u64) -> u64 { + if !own(guest, pid) { + return errno::fail(errno::ESRCH); + } + match priority(guest, param) { + Some(0) => errno::ok(0), + Some(_) => errno::fail(errno::EINVAL), + None => errno::fail(if param == 0 { errno::EINVAL } else { errno::EFAULT }), + } +} + +/// `sched_get_priority_max` and `_min`: the range each policy has on Linux. +pub fn priority_bound(policy: u64, max: bool) -> u64 { + match policy { + SCHED_FIFO | SCHED_RR => errno::ok(if max { 99 } else { 1 }), + SCHED_OTHER | SCHED_BATCH | SCHED_IDLE => errno::ok(0), + _ => errno::fail(errno::EINVAL), + } +} + +/// Any mask that includes the one CPU is accepted; one that leaves it out +/// leaves the thread nowhere to run. +pub fn sched_setaffinity(guest: &Guest, pid: u64, size: u64, mask: u64) -> u64 { + if !own(guest, pid) { + return errno::fail(errno::ESRCH); + } + match (size, guest.read(mask, 1)) { + (0, _) => errno::fail(errno::EINVAL), + (_, None) => errno::fail(errno::EFAULT), + (_, Some(first)) if first[0] & 1 == 0 => errno::fail(errno::EINVAL), + _ => errno::ok(0), + } +} + +fn priority(guest: &Guest, param: u64) -> Option { + let raw = guest.read(param, 4).filter(|_| param != 0)?; + Some(i32::from_le_bytes([raw[0], raw[1], raw[2], raw[3]])) +} diff --git a/userland/capsule_linux/src/linux/call/sigframe.rs b/userland/capsule_linux/src/linux/call/sigframe.rs new file mode 100644 index 0000000000..aae24b4f48 --- /dev/null +++ b/userland/capsule_linux/src/linux/call/sigframe.rs @@ -0,0 +1,108 @@ +// 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 `rt_sigframe` x86-64 puts on a thread's stack to enter a signal handler, +//! and where to read it back on return. Pure, so the layout is checked without +//! a guest. It matches Linux `struct rt_sigframe`: pretcode u64, ucontext at +//! +8, siginfo at +312. Within the ucontext, `uc_stack` is at +16, the +//! sigcontext (`uc_mcontext`) at +40 and `uc_sigmask` at +296, as musl, glibc +//! and Go all read them; the sigcontext starts with the 18 words +//! `mk_foreign_context` uses, in that order. A handler that reads or edits its +//! context (Go's does, to preempt) finds each register where Linux puts it. + +use alloc::vec::Vec; + +pub const WORDS: usize = 18; // r8..r15,rdi,rsi,rbp,rbx,rdx,rax,rcx,rsp,rip,rflags +const FRAME_SIZE: usize = 440; +const UC_OFF: usize = 8; +pub const SIGCONTEXT_OFF: usize = 40; // uc_mcontext within the ucontext +pub const SIGMASK_OFF: usize = 296; // uc_sigmask within the ucontext +const STACK_OFF: usize = 16; // uc_stack within the ucontext +const INFO_OFF: usize = 312; +const REDZONE: u64 = 128; // the System V red zone below rsp +const SS_ONSTACK: u64 = 1; +const SS_DISABLE: u64 = 2; + +fn put(buf: &mut [u8], at: usize, v: u64) { + buf[at..at + 8].copy_from_slice(&v.to_le_bytes()); +} +/// Where the frame lands, the bytes to write there, and the registers that +/// enter the handler. `alt` is the thread's alternate stack (base, size), and +/// `onstack` is the handler's SA_ONSTACK. `None` if the frame does not fit. +pub fn build( + regs: &[u64; WORDS], + handler: u64, + restorer: u64, + signum: u32, + blocked: u64, + alt: Option<(u64, u64)>, + onstack: bool, +) -> Option<(u64, Vec, [u64; WORDS])> { + let rsp = regs[15]; + let on_alt = alt.is_some_and(|(sp, size)| rsp > sp && rsp - sp <= size); + // Linux's get_sigframe: below the red zone, or at the top of the + // alternate stack for a handler that asked for it when the thread is not + // already running there. Then 16-aligned and down 8, so the handler sees + // rsp+8 aligned as a call would leave it. + let top = match alt { + Some((sp, size)) if onstack && !on_alt => sp.checked_add(size)?, + _ => rsp.checked_sub(REDZONE)?, + }; + let frame = (top.checked_sub(FRAME_SIZE as u64)? & !15u64).checked_sub(8)?; + // A frame that would run off the bottom of the alternate stack is not + // written over whatever lies below it. + if let Some((sp, _)) = alt.filter(|_| onstack) { + if frame <= sp { + return None; + } + } + let mut buf = alloc::vec![0u8; FRAME_SIZE]; + put(&mut buf, 0, restorer); + // uc_stack: the alternate stack, flagged as Linux's sas_ss_flags gives it + // for the interrupted rsp. + let (ss_sp, ss_size, ss_flags) = match alt { + None => (0, 0, SS_DISABLE), + Some((sp, size)) => (sp, size, if on_alt { SS_ONSTACK } else { 0 }), + }; + put(&mut buf, UC_OFF + STACK_OFF, ss_sp); + put(&mut buf, UC_OFF + STACK_OFF + 8, ss_flags); + put(&mut buf, UC_OFF + STACK_OFF + 16, ss_size); + let mc = UC_OFF + SIGCONTEXT_OFF; + for (i, w) in regs.iter().enumerate() { + put(&mut buf, mc + i * 8, *w); + } + put(&mut buf, UC_OFF + SIGMASK_OFF, blocked); + put(&mut buf, INFO_OFF, u64::from(signum)); // siginfo: si_signo + let mut out = [0u64; WORDS]; + out[8] = u64::from(signum); // rdi + out[9] = frame + INFO_OFF as u64; // rsi, &siginfo + out[12] = frame + UC_OFF as u64; // rdx, &ucontext + out[15] = frame; // rsp at the frame; rax stays 0, no vector registers + out[16] = handler; // rip + out[17] = regs[17]; // rflags, the kernel masks it + Some((frame, buf, out)) +} + +/// The 18 words a returning frame carries, from the ucontext the guest's rsp +/// points at: the trampoline's `ret` left rsp there. +pub fn returned(uc: &[u8]) -> Option<[u64; WORDS]> { + let mut out = [0u64; WORDS]; + for (i, slot) in out.iter_mut().enumerate() { + let at = SIGCONTEXT_OFF + i * 8; + *slot = u64::from_le_bytes(uc.get(at..at + 8)?.try_into().ok()?); + } + Some(out) +} diff --git a/userland/capsule_linux/src/linux/call/signal.rs b/userland/capsule_linux/src/linux/call/signal.rs index 9ad517d36f..9948c74483 100644 --- a/userland/capsule_linux/src/linux/call/signal.rs +++ b/userland/capsule_linux/src/linux/call/signal.rs @@ -14,56 +14,54 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . - -//! Signal dispositions, recorded and never delivered. -//! -//! Delivery means pushing a frame onto a guest thread's stack and -//! redirecting it, which needs the guest's register state, and the trap -//! mechanism hands out a frame but no way to rewrite one. So the -//! handlers a program installs are remembered and nothing is ever -//! raised. That is a real limit and it is recorded here rather than -//! hidden behind a success: a program whose correctness depends on -//! SIGALRM firing will hang, not misbehave quietly. - +//! Signal dispositions, recorded here and delivered on the return path in +//! `serve::deliver`: a handler is kept with its flags, restorer and mask. use crate::linux::abi::errno; +use crate::linux::guest::sigstate::{SigAction, NSIG}; use crate::linux::guest::Guest; -/// Linux refuses to let these two be caught, and so does this. +// SIGKILL/SIGSTOP cannot be caught; `struct sigaction` is 32 bytes. const SIGKILL: u64 = 9; const SIGSTOP: u64 = 19; - -/// The largest signal number Linux defines. -const NSIG: u64 = 64; +const SIGACTION_LEN: usize = 32; pub fn rt_sigaction(guest: &mut Guest, signum: u64, act: u64, old: u64) -> u64 { - if signum == 0 || signum > NSIG || signum == SIGKILL || signum == SIGSTOP { + if signum == 0 || signum > NSIG as u64 || signum == SIGKILL || signum == SIGSTOP { return errno::fail(errno::EINVAL); } - if old != 0 && guest.write(old, &[0u8; SIGACTION_LEN]) < SIGACTION_LEN as i64 { + let n = signum as usize; + if old != 0 + && guest.write(old, &encode(guest.signals.action(n).unwrap_or_default())) + < SIGACTION_LEN as i64 + { return errno::fail(errno::EFAULT); } if act != 0 { - guest.handlers[signum as usize - 1] = true; + match guest.read(act, SIGACTION_LEN) { + Some(raw) => guest.signals.set(n, decode(&raw)), + None => return errno::fail(errno::EFAULT), + } } errno::ok(0) } -/// `struct sigaction` on x86_64: handler, flags, restorer, mask. -const SIGACTION_LEN: usize = 32; +fn decode(raw: &[u8]) -> SigAction { + let w = |i: usize| u64::from_le_bytes(raw[i..i + 8].try_into().unwrap_or([0; 8])); + SigAction { handler: w(0), flags: w(8), restorer: w(16), mask: w(24) } +} +fn encode(a: SigAction) -> [u8; SIGACTION_LEN] { + let mut b = [0u8; SIGACTION_LEN]; + b[0..8].copy_from_slice(&a.handler.to_le_bytes()); + b[8..16].copy_from_slice(&a.flags.to_le_bytes()); + b[16..24].copy_from_slice(&a.restorer.to_le_bytes()); + b[24..32].copy_from_slice(&a.mask.to_le_bytes()); + b +} -/// The mask is recorded nowhere because nothing is ever raised against -/// it. Reporting an empty old mask is true: no signal is pending. +/// The old mask reads back empty: nothing is held back, delivery ignores it. pub fn rt_sigprocmask(guest: &Guest, old: u64) -> u64 { if old != 0 && guest.write(old, &[0u8; 8]) < 8 { return errno::fail(errno::EFAULT); } errno::ok(0) } - -/// An alternate stack for a handler that will never run. -pub fn sigaltstack(guest: &Guest, old: u64) -> u64 { - if old != 0 && guest.write(old, &[0u8; 24]) < 24 { - return errno::fail(errno::EFAULT); - } - errno::ok(0) -} diff --git a/userland/capsule_linux/src/linux/call/signal_send.rs b/userland/capsule_linux/src/linux/call/signal_send.rs index e023636db7..43df7c3357 100644 --- a/userland/capsule_linux/src/linux/call/signal_send.rs +++ b/userland/capsule_linux/src/linux/call/signal_send.rs @@ -14,39 +14,52 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -//! `kill` and `tkill`, for the guest's own threads and children. +//! `kill`, `tkill` and `tgkill`, for the guest's own threads and children. +//! A signal the process catches is queued and delivered on that thread's next +//! return; one whose default is to ignore is dropped; a fatal default ends it. use nonos_libc::mk_kill; use crate::linux::abi::errno; +use crate::linux::guest::sigstate::NSIG; use crate::linux::guest::Guest; -/// The only signals the kernel can carry. Anything else is accepted as -/// a request it cannot honour rather than silently dropped. -const SIGKILL: u64 = 9; -const SIGTERM: u64 = 15; +/// Signals whose default action is to be ignored: child status, urgent data, +/// window size, and a continue with nothing stopped. +const IGNORED_DEFAULT: [u64; 4] = [17, 23, 28, 18]; pub fn kill(guest: &mut Guest, pid: u64, signo: u64) -> u64 { let target = pid as u32; - /* - * A guest may signal itself, its threads and its children, and nothing - * else. - */ + // A guest may signal itself, its threads and its children, nothing else. if !guest.owns(target) && !guest.children.contains(&target) { return errno::fail(errno::ESRCH); } if signo == 0 { - return errno::ok(0); + return errno::ok(0); // an existence check, not a signal } - if signo != SIGKILL && signo != SIGTERM { + if signo > NSIG as u64 { return errno::fail(errno::EINVAL); } - /* - * A thread that was parked in a futex has to be let out before it - * can be collected; the reply is the wake. - */ - guest.waits.retain(|(w, _)| *w != target); + let act = guest.signals.action(signo as usize).unwrap_or_default(); + if act.catches() { + guest.signals.raise(target, signo as u8); + // A thread running its own code makes no call to deliver on: the + // kernel stops it at its next tick and hands it here. One parked in + // a call, the caller included, gets it with that call's answer. + let _ = nonos_libc::mk_foreign_interrupt(target); + return errno::ok(0); + } + if act.ignores() || IGNORED_DEFAULT.contains(&signo) { + return errno::ok(0); + } + terminate(guest, target, signo) +} + +/// The default action of an uncaught, non-ignored signal is to end the thread. +fn terminate(guest: &mut Guest, target: u32, signo: u64) -> u64 { + guest.forget_waits(target); guest.threads.retain(|t| *t != target); + guest.signals.forget(target); match mk_kill(target as u64, signo) { n if n < 0 => errno::fail(errno::EPERM), _ => errno::ok(0), diff --git a/userland/capsule_linux/src/linux/call/signal_stack.rs b/userland/capsule_linux/src/linux/call/signal_stack.rs new file mode 100644 index 0000000000..3675fc8986 --- /dev/null +++ b/userland/capsule_linux/src/linux/call/signal_stack.rs @@ -0,0 +1,98 @@ +// 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 . + +//! `sigaltstack`: a thread names, reads or disables its alternate signal +//! stack, with Linux's answers (`do_sigaltstack` in `kernel/signal.c`): the +//! old setting is what was there before this call, a thread running on its +//! alternate stack may not change it (EPERM), a stack under MINSIGSTKSZ is +//! ENOMEM, and a mode other than 0, SS_ONSTACK or SS_DISABLE is EINVAL. + +use nonos_libc::{mk_foreign_context, ForeignRegs}; + +use crate::linux::abi::errno; +use crate::linux::guest::sigstack::{AltStack, SS_DISABLE, SS_ONSTACK}; +use crate::linux::guest::Guest; + +/// `stack_t` is 24 bytes: ss_sp, ss_flags (an int, then 4 bytes of padding), +/// ss_size. +const STACK_T: usize = 24; +/// x86-64's MINSIGSTKSZ, the smallest stack the kernel accepts. +const MINSIGSTKSZ: u64 = 2048; +/// Linux 4.7's flag bit that disarms the stack while a handler runs on it. +const SS_AUTODISARM: u32 = 1 << 31; +const RSP: usize = 15; + +pub fn sigaltstack(guest: &mut Guest, tid: u32, new: u64, old: u64) -> u64 { + let wanted = match new { + 0 => None, + at => match guest.read(at, STACK_T) { + Some(raw) => Some(decode(&raw)), + None => return errno::fail(errno::EFAULT), + }, + }; + let rsp = rsp_of(tid); + let now = guest.signals.stack(tid); + let before = encode(now, rsp); + if let Some((sp, flags, size)) = wanted { + if now.is_some_and(|s| s.holds(rsp)) { + return errno::fail(errno::EPERM); + } + if flags & SS_AUTODISARM != 0 { + crate::linux::start::say(b"[LINUX] unserved sigaltstack SS_AUTODISARM\n"); + return errno::fail(errno::EINVAL); + } + match flags { + SS_DISABLE => guest.signals.set_stack(tid, None), + 0 | SS_ONSTACK if size < MINSIGSTKSZ => return errno::fail(errno::ENOMEM), + 0 | SS_ONSTACK => guest.signals.set_stack(tid, Some(AltStack { sp, size })), + _ => return errno::fail(errno::EINVAL), + } + } + if old != 0 && guest.write(old, &before) < STACK_T as i64 { + return errno::fail(errno::EFAULT); + } + errno::ok(0) +} + +/// The thread's stack pointer where it made the call. +fn rsp_of(tid: u32) -> u64 { + let mut regs: ForeignRegs = [0; 18]; + if mk_foreign_context(tid, &mut regs) == 0 { + regs[RSP] + } else { + 0 + } +} + +fn decode(raw: &[u8]) -> (u64, u32, u64) { + let word = |at: usize| u64::from_le_bytes(raw[at..at + 8].try_into().unwrap_or([0; 8])); + let flags = u32::from_le_bytes(raw[8..12].try_into().unwrap_or([0; 4])); + (word(0), flags, word(16)) +} + +/// A `stack_t` for `stack`, its flags as Linux's `sas_ss_flags` gives them. +pub fn encode(stack: Option, rsp: u64) -> [u8; STACK_T] { + let (sp, size, flags) = match stack { + None => (0, 0, SS_DISABLE), + Some(s) if s.holds(rsp) => (s.sp, s.size, SS_ONSTACK), + Some(s) => (s.sp, s.size, 0), + }; + let mut out = [0u8; STACK_T]; + out[0..8].copy_from_slice(&sp.to_le_bytes()); + out[8..12].copy_from_slice(&flags.to_le_bytes()); + out[16..24].copy_from_slice(&size.to_le_bytes()); + out +} diff --git a/userland/capsule_linux/src/linux/call/sigreturn.rs b/userland/capsule_linux/src/linux/call/sigreturn.rs new file mode 100644 index 0000000000..fa0dae2762 --- /dev/null +++ b/userland/capsule_linux/src/linux/call/sigreturn.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 . + +//! `rt_sigreturn`: a thread leaving a signal handler. Its rsp points at the +//! ucontext the frame carried, so the saved registers are read back from +//! there and the kernel resumes the thread into them. Nothing is replied: the +//! thread is no longer in the syscall, it is back where the signal interrupted. + +use nonos_libc::{mk_foreign_context, mk_foreign_signal, ForeignRegs, SIGNAL_RETURN}; + +use super::sigframe::{returned, SIGCONTEXT_OFF, WORDS}; +use crate::linux::guest::Guest; +use crate::linux::serve::Answer; + +/// rsp in the register word order. +const RSP: usize = 15; + +pub fn rt_sigreturn(guest: &Guest, tid: u32) -> Answer { + let mut regs: ForeignRegs = [0; WORDS]; + if mk_foreign_context(tid, &mut regs) != 0 { + return Answer::Park; + } + // The trampoline's `ret` left rsp at the ucontext; the sigcontext follows. + let want = SIGCONTEXT_OFF + WORDS * 8; + let Some(bytes) = guest.read(regs[RSP], want) else { + return Answer::Park; + }; + let Some(restored) = returned(&bytes) else { + return Answer::Park; + }; + let _ = mk_foreign_signal(tid, &restored, SIGNAL_RETURN); + Answer::Park +} 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/spawn/clone.rs b/userland/capsule_linux/src/linux/call/spawn/clone.rs index 3ecdb0caed..f643bfdd21 100644 --- a/userland/capsule_linux/src/linux/call/spawn/clone.rs +++ b/userland/capsule_linux/src/linux/call/spawn/clone.rs @@ -24,13 +24,19 @@ use crate::linux::serve::Answer; const CLONE_VM: u64 = 0x100; const CLONE_THREAD: u64 = 0x10000; +const CLONE_SETTLS: u64 = 0x80000; +const CLONE_CHILD_CLEARTID: u64 = 0x20_0000; -/// musl's `__clone` resumes the child at the instruction after its own -/// `syscall`, with rax zero and rsp pointing at the function and argument it -/// pushed. +/// A Linux clone child resumes at the instruction after its parent's +/// `syscall`, on its parent's registers with rax zero and rsp the new stack. +/// Both runtimes that start threads here call through a register in the +/// child: musl's `__clone` pops the argument and calls r9, Go's calls r12. pub fn clone(guest: &mut Guest, frame: &ForeignFrame) -> Answer { let a = frame.args(); - let (flags, stack, tls) = (a[0], a[1], a[4]); + let (flags, stack) = (a[0], a[1]); + // The fifth argument is a thread pointer only when the flag says so; + // without it the child keeps its parent's. + let tls = if flags & CLONE_SETTLS != 0 { a[4] } else { 0 }; if flags & (CLONE_VM | CLONE_THREAD) != CLONE_VM | CLONE_THREAD { /* * A new process, not a thread. That is fork, and fork needs an @@ -48,10 +54,17 @@ pub fn clone(guest: &mut Guest, frame: &ForeignFrame) -> Answer { if stack == 0 { return Answer::value(errno::fail(errno::EINVAL)); } - let tid = mk_foreign_thread(guest.pid, frame.rip, stack, tls); + let tid = mk_foreign_thread(guest.pid, frame.rip, stack, tls, frame.pid); if tid < 0 { return Answer::value(errno::fail(errno::ENOMEM)); } - guest.threads.push(tid as u32); - Answer::value(errno::ok(tid as u64)) + let tid = tid as u32; + guest.threads.push(tid); + // The SETTID words get the guest's number for the tid, which only the + // family knows: `serve::clone_tid` writes them with the reply. + // Zeroed and woken when the thread exits: musl's join waits on it. + if flags & CLONE_CHILD_CLEARTID != 0 { + guest.clear_tids.push((tid, a[3])); + } + Answer::value(errno::ok(u64::from(tid))) } diff --git a/userland/capsule_linux/src/linux/call/spawn/exec.rs b/userland/capsule_linux/src/linux/call/spawn/exec.rs index e45cb53116..4ec31fc284 100644 --- a/userland/capsule_linux/src/linux/call/spawn/exec.rs +++ b/userland/capsule_linux/src/linux/call/spawn/exec.rs @@ -40,12 +40,13 @@ 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), }; super::exec_threads::reap(guest, pid); clear(guest); + guest.signals.clear_stacks(); match load_over(guest, pid, &program, &env) { Some(()) => Answer::Park, None => Answer::value(errno::fail(errno::ENOEXEC)), 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 Answer { - let child = mk_foreign_fork(guest.pid); +pub fn fork(guest: &mut Guest, caller: u32) -> Answer { + let child = mk_foreign_fork(caller); if child < 0 { return Answer::value(errno::fail(errno::ENOMEM)); } @@ -33,7 +33,22 @@ 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. The kernel fork carries the forking thread's own FS to the + * child, which is right whichever thread forked; the personality's single + * fs_base is only the last thread to set one and would be wrong here. + */ + /* + * The child's state goes to the serve loop before the child runs, so its + * first trap finds a guest that owns it. + */ + let mut state = guest.fork_state(child); + // The child's one thread has the forking thread's alternate stack. + state.signals.stack_for_child(caller, child); + guest.forked.push(state); 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/fork_copy.rs b/userland/capsule_linux/src/linux/call/spawn/fork_copy.rs index 5a165b451c..3cf86191a5 100644 --- a/userland/capsule_linux/src/linux/call/spawn/fork_copy.rs +++ b/userland/capsule_linux/src/linux/call/spawn/fork_copy.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 . - //! Copying a parent's spans into the child it just made. use crate::linux::guest::{Guest, Region}; @@ -24,6 +23,11 @@ use nonos_libc::peer::{mk_peer_map, mk_peer_write, PEER_PROT_EXEC, PEER_PROT_WRI pub(super) fn copy_spans(guest: &mut Guest, child: u32) -> bool { let spans = guest.regions.clone(); for span in spans { + // An unbacked reservation has no frames to copy; the child reserves it + // the same way, and its own first access faults a page in. + if !span.backed { + continue; + } if mk_peer_map(child, span.at, span.len, prot_of(&span)) < 0 { return false; } 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/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/env.rs b/userland/capsule_linux/src/linux/env.rs index 1face4d2e4..90d0ae5f0d 100644 --- a/userland/capsule_linux/src/linux/env.rs +++ b/userland/capsule_linux/src/linux/env.rs @@ -27,5 +27,10 @@ pub fn default() -> Vec> { b"PWD=/".to_vec(), b"SHELL=/bin/sh".to_vec(), b"LANG=C.UTF-8".to_vec(), + // libwayland-client will not look for a display without a runtime + // directory; /run is private to each guest, and any path ending in + // wayland-0 reaches the personality's compositor (unix/path.rs). + b"XDG_RUNTIME_DIR=/run/user/0".to_vec(), + b"WAYLAND_DISPLAY=wayland-0".to_vec(), ] } diff --git a/userland/capsule_linux/src/linux/file/at.rs b/userland/capsule_linux/src/linux/file/at.rs index 81cc736c2d..171bfd0d73 100644 --- a/userland/capsule_linux/src/linux/file/at.rs +++ b/userland/capsule_linux/src/linux/file/at.rs @@ -29,11 +29,12 @@ use super::resolve::visible; /// guest opened. pub fn resolve_at(guest: &Guest, dirfd: u64, path: u64) -> 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/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/close.rs b/userland/capsule_linux/src/linux/file/close.rs index 50fb922843..5b7d710494 100644 --- a/userland/capsule_linux/src/linux/file/close.rs +++ b/userland/capsule_linux/src/linux/file/close.rs @@ -32,6 +32,7 @@ pub fn close(guest: &mut Guest, fd: u64) -> u64 { */ let flushed = flush(entry); *entry = Fd::empty(Kind::Free); + super::epoll::forget(guest, fd); match flushed { true => errno::ok(0), false => errno::fail(errno::EIO), diff --git a/userland/capsule_linux/src/linux/file/dir.rs b/userland/capsule_linux/src/linux/file/dir.rs index 9dfe5053c3..a71e5a6b9d 100644 --- a/userland/capsule_linux/src/linux/file/dir.rs +++ b/userland/capsule_linux/src/linux/file/dir.rs @@ -17,12 +17,12 @@ //! Directory open. The listing is snapshotted here, which is all POSIX //! promises a directory stream. -use alloc::string::String; use alloc::vec::Vec; use crate::linux::abi::errno; use crate::linux::guest::{Fd, Guest}; +use super::dir_children::children; use super::{resolve, slot, store}; pub fn open(guest: &mut Guest, path: Vec) -> u64 { @@ -31,31 +31,14 @@ 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), } } - -// OP_LIST returns whole keys at any depth. Cut at the first separator -// past the prefix and dedupe, or every file below shows up as a sibling. -fn children(at: &[u8], keys: Vec) -> Vec { - let cut = at.len() + 1; - let mut out: Vec = Vec::new(); - for key in keys { - let bytes = key.as_bytes(); - if bytes.len() <= cut { - continue; - } - let rest = &bytes[cut..]; - let end = rest.iter().position(|b| *b == b'/').unwrap_or(rest.len()); - let Ok(name) = core::str::from_utf8(&rest[..end]) else { - continue; - }; - if !out.iter().any(|seen| seen == name) { - out.push(String::from(name)); - } - } - out -} diff --git a/userland/capsule_linux/src/linux/file/dir_children.rs b/userland/capsule_linux/src/linux/file/dir_children.rs new file mode 100644 index 0000000000..050e1ce2a1 --- /dev/null +++ b/userland/capsule_linux/src/linux/file/dir_children.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 names directly below a directory, from the store's flat listing. + +use alloc::string::String; +use alloc::vec::Vec; + +// OP_LIST returns whole keys at any depth. Cut at the first separator +// past the prefix and dedupe, or every file below shows up as a sibling. +pub fn children(at: &[u8], keys: Vec) -> Vec { + let cut = at.len() + 1; + let mut out: Vec = Vec::new(); + for key in keys { + let bytes = key.as_bytes(); + // The listing matches bytes, so `/linux` also returns `/linux-deb/..`: + // a key is below `at` only when a separator follows it. + if bytes.len() <= cut || bytes.get(at.len()) != Some(&b'/') { + continue; + } + let rest = &bytes[cut..]; + let end = rest.iter().position(|b| *b == b'/').unwrap_or(rest.len()); + let Ok(name) = core::str::from_utf8(&rest[..end]) else { + continue; + }; + if !out.iter().any(|seen| seen == name) { + out.push(String::from(name)); + } + } + out +} diff --git a/userland/capsule_linux/src/linux/file/epoll.rs b/userland/capsule_linux/src/linux/file/epoll.rs index 9e30e4b803..51435cfbde 100644 --- a/userland/capsule_linux/src/linux/file/epoll.rs +++ b/userland/capsule_linux/src/linux/file/epoll.rs @@ -17,7 +17,7 @@ //! `epoll_create1` and `epoll_ctl`: the interest list a program keeps. use crate::linux::abi::errno; -use crate::linux::guest::{Fd, Guest, Kind}; +use crate::linux::guest::{Fd, Guest, Kind, Watch}; use super::slot::install; @@ -36,6 +36,11 @@ pub fn epoll_create(guest: &mut Guest) -> u64 { } } +/// Add, change or drop one entry, refused as Linux refuses it: a closed +/// descriptor is EBADF, a regular file or directory EPERM (always ready, so +/// never worth waiting on; Go's os.Open falls back to blocking reads on it), +/// watching the list itself EINVAL, adding twice EEXIST, changing or +/// dropping what is not there ENOENT. pub fn epoll_ctl(guest: &mut Guest, ep: u64, op: u64, fd: u64, event: u64) -> u64 { let entry = match op { EPOLL_CTL_DEL => None, @@ -45,16 +50,39 @@ pub fn epoll_ctl(guest: &mut Guest, ep: u64, op: u64, fd: u64, event: u64) -> u6 }, _ => return errno::fail(errno::EINVAL), }; - let Some(list) = guest.fds.get_mut(ep as usize).filter(|f| f.kind == Kind::Epoll) else { + let open = |n: u64| guest.fds.get(n as usize).is_some_and(|f| f.is_open()); + if !open(ep) || !open(fd) { return errno::fail(errno::EBADF); + } + if guest.fds.get(fd as usize).is_some_and(|f| matches!(f.kind, Kind::File | Kind::Dir)) { + return errno::fail(errno::EPERM); + } + let Some(list) = guest.fds.get_mut(ep as usize).filter(|f| f.kind == Kind::Epoll) else { + return errno::fail(errno::EINVAL); }; - list.watch.retain(|(f, _, _)| *f != fd); + let present = list.watch.iter().any(|w| w.fd == fd); + match op { + _ if fd == ep => return errno::fail(errno::EINVAL), + EPOLL_CTL_ADD if present => return errno::fail(errno::EEXIST), + EPOLL_CTL_MOD | EPOLL_CTL_DEL if !present => return errno::fail(errno::ENOENT), + _ => {} + } + // A change re-arms the entry: it is looked at afresh, as a new one is. + list.watch.retain(|w| w.fd != fd); if let Some((events, data)) = entry { - list.watch.push((fd, events, data)); + list.watch.push(Watch::new(fd, events, data)); } errno::ok(0) } +/// Drop `fd` from every interest list, as Linux does when a descriptor is +/// closed, so a later descriptor given its number starts unregistered. +pub fn forget(guest: &mut Guest, fd: u64) { + for list in guest.fds.iter_mut().filter(|f| f.kind == Kind::Epoll) { + list.watch.retain(|w| w.fd != fd); + } +} + fn read_event(guest: &Guest, at: u64) -> Option<(u32, u64)> { let raw = guest.read(at, EVENT_LEN)?; let events = u32::from_le_bytes([raw[0], raw[1], raw[2], raw[3]]); diff --git a/userland/capsule_linux/src/linux/file/epoll_arm.rs b/userland/capsule_linux/src/linux/file/epoll_arm.rs new file mode 100644 index 0000000000..e5ecd79ad0 --- /dev/null +++ b/userland/capsule_linux/src/linux/file/epoll_arm.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 . + +//! Re-arming an edge-triggered epoll entry. +//! +//! A program using EPOLLET reads or writes until a call answers EAGAIN and +//! only then waits. That answer is where the next rise must be reported +//! again, even if the family never looked while the readiness was low. + +use crate::linux::abi::{errno, nr}; +use crate::linux::guest::{Guest, Kind}; + +const EPOLLIN: u32 = 0x001; +const EPOLLOUT: u32 = 0x004; + +/// The calls that wait for a descriptor to become readable, and writable. +const READS: [u64; 7] = + [nr::READ, nr::READV, nr::PREAD64, nr::RECVFROM, nr::RECVMSG, nr::ACCEPT, nr::ACCEPT4]; +const WRITES: [u64; 5] = [nr::WRITE, nr::WRITEV, nr::PWRITE64, nr::SENDTO, nr::SENDMSG]; + +/// After a call on `fd` answered `value`, forget that its readiness was seen. +pub fn rearm(guest: &mut Guest, number: u64, fd: u64, value: u64) { + let again = value == errno::fail(errno::EAGAIN); + let bits = if again && READS.contains(&number) { + EPOLLIN + } else if again && WRITES.contains(&number) { + EPOLLOUT + } else if number == nr::CONNECT && value == errno::fail(errno::EINPROGRESS) { + EPOLLOUT + } else { + return; + }; + for list in guest.fds.iter_mut().filter(|f| f.kind == Kind::Epoll) { + for w in list.watch.iter_mut().filter(|w| w.fd == fd) { + w.fired &= !bits; + } + } +} diff --git a/userland/capsule_linux/src/linux/file/epoll_wait.rs b/userland/capsule_linux/src/linux/file/epoll_wait.rs index 0b30b275d1..d36069008e 100644 --- a/userland/capsule_linux/src/linux/file/epoll_wait.rs +++ b/userland/capsule_linux/src/linux/file/epoll_wait.rs @@ -15,40 +15,58 @@ // along with this program. If not, see . //! `epoll_wait`: which of the watched descriptors are ready now. +//! +//! A level-triggered entry is reported for as long as its readiness holds. +//! An EPOLLET entry is reported when readiness rises: bits already seen at +//! the last look are left out until they fall, or until a call on the +//! descriptor answers EAGAIN (`epoll_arm`). An EPOLLONESHOT entry reports +//! once and then nothing until it is modified. use alloc::vec::Vec; use crate::linux::abi::errno; -use crate::linux::guest::{Guest, Kind}; -use crate::linux::net::ready; +use crate::linux::guest::{Guest, Kind, EPOLLET, EPOLLONESHOT}; +use crate::linux::net::{ready, POLLERR, POLLHUP}; use super::epoll::EVENT_LEN; +/// The most events one call can ask for, as Linux bounds it. +const MOST: u64 = (i32::MAX as u64) / EVENT_LEN as u64; + +/// Report what is ready now, never waiting; `waits` does the waiting. pub fn epoll_wait(guest: &mut Guest, ep: u64, out: u64, max: u64) -> u64 { + // maxevents is an int, and one of zero or less is refused. + if max == 0 || max > MOST { + return errno::fail(errno::EINVAL); + } let Some(list) = guest.fds.get(ep as usize).filter(|f| f.kind == Kind::Epoll) else { return errno::fail(errno::EBADF); }; - let watch = list.watch.clone(); + let mut watch = list.watch.clone(); let mut blob: Vec = Vec::new(); let mut hits = 0u64; - for (fd, wanted, data) in watch { + for w in watch.iter_mut().filter(|w| w.armed) { if hits >= max { break; } - let live = u32::from(ready(guest, fd)) & wanted; + // Hang-up and error are reported whether they were asked for or not. + let level = u32::from(ready(guest, w.fd)) & (w.events | u32::from(POLLHUP | POLLERR)); + let live = if w.events & EPOLLET != 0 { level & !w.fired } else { level }; + w.fired = level; if live == 0 { continue; } + w.armed = w.events & EPOLLONESHOT == 0; blob.extend_from_slice(&live.to_le_bytes()); - blob.extend_from_slice(&data.to_le_bytes()); + blob.extend_from_slice(&w.data.to_le_bytes()); hits += 1; } - if blob.is_empty() { - return errno::ok(0); - } - if guest.write(out, &blob) < blob.len() as i64 { + if !blob.is_empty() && guest.write(out, &blob) < blob.len() as i64 { return errno::fail(errno::EFAULT); } - let _ = EVENT_LEN; + // What was reported, and what was seen, is kept only once it is delivered. + if let Some(list) = guest.fds.get_mut(ep as usize) { + list.watch = watch; + } errno::ok(hits) } diff --git a/userland/capsule_linux/src/linux/file/eventfd.rs b/userland/capsule_linux/src/linux/file/eventfd.rs new file mode 100644 index 0000000000..8e61cbc86b --- /dev/null +++ b/userland/capsule_linux/src/linux/file/eventfd.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 . + +//! `eventfd2` and `eventfd`: a counter one side adds to and the other takes, +//! which is how one thread wakes another out of `epoll_wait`. Go's runtime +//! makes one for its poller at the first timer and throws if it cannot. + +use crate::linux::abi::errno; +use crate::linux::guest::{Event, Fd, Guest, Kind}; + +use super::flags::{O_CLOEXEC, O_NONBLOCK}; +use super::slot::install; + +const EFD_SEMAPHORE: u64 = 1; +/// The most a counter holds. A write that would pass it waits. +pub const MOST: u64 = u64::MAX - 1; + +const POLLIN: u16 = 0x001; +const POLLOUT: u16 = 0x004; +const POLLNVAL: u16 = 0x020; + +pub fn eventfd2(guest: &mut Guest, initval: u64, flags: u64) -> u64 { + if flags & !(EFD_SEMAPHORE | O_CLOEXEC | O_NONBLOCK) != 0 { + return errno::fail(errno::EINVAL); + } + let slot = guest.events.len(); + // The starting value is an unsigned int. + guest + .events + .push(Event { count: initval & 0xFFFF_FFFF, semaphore: flags & EFD_SEMAPHORE != 0 }); + let mut fd = Fd::empty(Kind::Event); + fd.handle = slot as u32; + fd.cloexec = flags & O_CLOEXEC != 0; + fd.nonblock = flags & O_NONBLOCK != 0; + match install(guest, fd) { + Some(n) => errno::ok(n), + None => errno::fail(errno::EMFILE), + } +} + +/// The counter `fd` names, if it is an eventfd. +pub fn slot_of(guest: &Guest, fd: u64) -> Option { + let entry = guest.fds.get(fd as usize).filter(|f| f.kind == Kind::Event)?; + let slot = entry.handle as usize; + (slot < guest.events.len()).then_some(slot) +} + +/// Readable while the count is above zero, writable while one more fits. +pub fn bits(guest: &Guest, fd: u64) -> u16 { + let Some(slot) = slot_of(guest, fd) else { + return POLLNVAL; + }; + let count = guest.events[slot].count; + let readable = if count > 0 { POLLIN } else { 0 }; + readable | if count < MOST { POLLOUT } else { 0 } +} diff --git a/userland/capsule_linux/src/linux/file/eventfd_io.rs b/userland/capsule_linux/src/linux/file/eventfd_io.rs new file mode 100644 index 0000000000..9ab1b27334 --- /dev/null +++ b/userland/capsule_linux/src/linux/file/eventfd_io.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 . + +//! Reading and writing an eventfd: eight bytes, the count as a u64. +//! +//! Each answers EAGAIN where Linux would wait. Whether the caller waits +//! instead is decided by who called, from the descriptor's O_NONBLOCK. + +use crate::linux::abi::errno; +use crate::linux::guest::{Event, Guest}; + +use super::eventfd::{slot_of, MOST}; + +const WORD: u64 = 8; + +/// Take the whole count, or one of it under EFD_SEMAPHORE. +pub fn read(guest: &mut Guest, fd: u64, buf: u64, len: u64) -> u64 { + let Some(slot) = slot_of(guest, fd) else { + return errno::fail(errno::EBADF); + }; + if len < WORD { + return errno::fail(errno::EINVAL); + } + let Event { count, semaphore } = guest.events[slot]; + if count == 0 { + return errno::fail(errno::EAGAIN); + } + let take = if semaphore { 1 } else { count }; + // Written before it is taken, so a bad buffer leaves the count as it was. + if guest.write(buf, &take.to_le_bytes()) < WORD as i64 { + return errno::fail(errno::EFAULT); + } + guest.events[slot].count = count - take; + errno::ok(WORD) +} + +/// Add to the count. All ones is refused, as Linux refuses it. +pub fn write(guest: &mut Guest, fd: u64, buf: u64, len: u64) -> u64 { + let Some(slot) = slot_of(guest, fd) else { + return errno::fail(errno::EBADF); + }; + if len < WORD { + return errno::fail(errno::EINVAL); + } + let Some(raw) = guest.read(buf, WORD as usize) else { + return errno::fail(errno::EFAULT); + }; + let add = u64::from_le_bytes(raw[..8].try_into().unwrap_or([0xFF; 8])); + if add == u64::MAX { + return errno::fail(errno::EINVAL); + } + let count = guest.events[slot].count; + if add > MOST - count { + return errno::fail(errno::EAGAIN); + } + guest.events[slot].count = count + add; + errno::ok(WORD) +} diff --git a/userland/capsule_linux/src/linux/file/family.rs b/userland/capsule_linux/src/linux/file/family.rs new file mode 100644 index 0000000000..7663ab62e2 --- /dev/null +++ b/userland/capsule_linux/src/linux/file/family.rs @@ -0,0 +1,74 @@ +// 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 distribution this process works in. The name the system passes +//! says so, `deb:` or `pacman:` or neither for Alpine, and it is read once +//! at start: each family keeps its own tree, so a Debian `jq` never lands +//! on Alpine's `/usr/bin/jq` and a glibc loader never meets a musl one. + +use core::sync::atomic::{AtomicU8, Ordering}; + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum Family { + Alpine = 0, + Debian = 1, + Pacman = 2, +} + +/// Per family: its tree (Alpine keeps the original `/linux`), and where it +/// records what each package starts as, outside every tree a guest writes. +const PLACES: [(&[u8], &[u8]); 3] = [ + (b"/linux", b"/nonos/linux/apps"), + (b"/linux-deb", b"/nonos/linux/apps-deb"), + (b"/linux-pacman", b"/nonos/linux/apps-pacman"), +]; + +static CHOSEN: AtomicU8 = AtomicU8::new(Family::Alpine as u8); + +/// Split `name` into its family and the package, without choosing. +pub fn split(name: &str) -> (Family, &str) { + match (name.strip_prefix("deb:"), name.strip_prefix("pacman:")) { + (Some(pkg), _) => (Family::Debian, pkg), + (_, Some(pkg)) => (Family::Pacman, pkg), + _ => (Family::Alpine, name), + } +} + +/// Work in `name`'s family from here on, and return the package. +pub fn choose(name: &str) -> &str { + let (family, pkg) = split(name); + CHOSEN.store(family as u8, Ordering::Relaxed); + pkg +} + +pub fn chosen() -> Family { + match CHOSEN.load(Ordering::Relaxed) { + 1 => Family::Debian, + 2 => Family::Pacman, + _ => Family::Alpine, + } +} + +/// A family's tree and its records. +pub fn places(family: Family) -> (&'static [u8], &'static [u8]) { + PLACES[family as usize] +} +pub fn root() -> &'static [u8] { + places(chosen()).0 +} +pub fn records() -> &'static [u8] { + places(chosen()).1 +} diff --git a/userland/capsule_linux/src/linux/file/flags.rs b/userland/capsule_linux/src/linux/file/flags.rs index 33326c5708..36a29e9efa 100644 --- a/userland/capsule_linux/src/linux/file/flags.rs +++ b/userland/capsule_linux/src/linux/file/flags.rs @@ -22,6 +22,7 @@ pub const O_RDWR: u64 = 0o2; pub const O_CREAT: u64 = 0o100; pub const O_TRUNC: u64 = 0o1000; pub const O_APPEND: u64 = 0o2000; +pub const O_NONBLOCK: u64 = 0o4000; pub const O_DIRECTORY: u64 = 0o200000; pub const O_CLOEXEC: u64 = 0o2000000; diff --git a/userland/capsule_linux/src/linux/file/link.rs b/userland/capsule_linux/src/linux/file/link.rs new file mode 100644 index 0000000000..9dda297b27 --- /dev/null +++ b/userland/capsule_linux/src/linux/file/link.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 . + +//! `symlinkat` and `linkat`; the plain forms are these at AT_FDCWD. +//! +//! A symbolic link joins the family's link table, where the image's own links +//! are, and only where the guest may write. A hard link is the same bytes +//! under a second name, copied: the store has no inodes to share, and a copy +//! keeps what programs rely on, that removing the old name leaves the new. + +use crate::linux::abi::errno; +use crate::linux::guest::Guest; + +use super::at::resolve_at; +use super::path::read_path; +use super::resolve::key; +use super::{meta::stat, store_read, store_write}; + +/// Largest file a hard link copies; the same bound an exec image has. +const MAX_LINKED: u32 = 64 << 20; + +pub fn symlinkat(guest: &Guest, target: u64, dirfd: u64, path: u64) -> u64 { + let (Some(to), Some(at)) = (read_path(guest, target), resolve_at(guest, dirfd, path)) else { + return errno::fail(errno::EFAULT); + }; + if let Err(e) = free_and_writable(guest, &at) { + return errno::fail(e); + } + match guest.links.add(at, to) { + true => errno::ok(0), + false => errno::fail(errno::EEXIST), + } +} + +pub fn linkat(guest: &Guest, olddir: u64, old: u64, newdir: u64, new: u64) -> u64 { + let (Some(from), Some(at)) = (resolve_at(guest, olddir, old), resolve_at(guest, newdir, new)) + else { + return errno::fail(errno::EFAULT); + }; + let from = guest.links.follow(from, true); + if let Err(e) = free_and_writable(guest, &at) { + return errno::fail(e); + } + let Ok(bytes) = store_read(&key(&from), MAX_LINKED) else { + return errno::fail(errno::ENOENT); + }; + match store_write(&key(&at), &bytes) { + Ok(()) => errno::ok(0), + Err(_) => errno::fail(errno::EIO), + } +} + +// A new name must not exist as a file or a link, and must be somewhere the +// guest may write: the shared tree is read-only to it. +fn free_and_writable(guest: &Guest, at: &[u8]) -> Result<(), i64> { + if stat::look(at).is_some() || guest.links.target(at).is_some() { + return Err(errno::EEXIST); + } + key(at).writable().map_err(|_| errno::EROFS) +} diff --git a/userland/capsule_linux/src/linux/file/meta/mod.rs b/userland/capsule_linux/src/linux/file/meta/mod.rs index 84fa778942..6921433dff 100644 --- a/userland/capsule_linux/src/linux/file/meta/mod.rs +++ b/userland/capsule_linux/src/linux/file/meta/mod.rs @@ -24,7 +24,7 @@ mod statfs; mod statx; pub use perms::{chmod, faccessat, fchmod, fchmodat}; -pub use query::{access, readlink}; +pub use query::{access, readlinkat}; pub use stat::{fstat, look, newfstatat}; pub use statfs::statfs; pub use statx::statx; diff --git a/userland/capsule_linux/src/linux/file/meta/query.rs b/userland/capsule_linux/src/linux/file/meta/query.rs index a88df72755..ddb7066979 100644 --- a/userland/capsule_linux/src/linux/file/meta/query.rs +++ b/userland/capsule_linux/src/linux/file/meta/query.rs @@ -27,20 +27,26 @@ 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 { - let Some(name) = path::read_path(guest, path_ptr) else { +/// A link's target, from the family's table. A path that exists and is not a +/// link is EINVAL, as Linux answers. `readlink` is this at AT_FDCWD. +pub fn readlinkat(guest: &Guest, dirfd: u64, path_ptr: u64, buf: u64, len: u64) -> u64 { + let Some(full) = super::super::at::resolve_at(guest, dirfd, path_ptr) else { return errno::fail(errno::EFAULT); }; - let full = resolve::visible(&guest.cwd, &name); + 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..90e0ee2845 100644 --- a/userland/capsule_linux/src/linux/file/meta/stat.rs +++ b/userland/capsule_linux/src/linux/file/meta/stat.rs @@ -21,7 +21,7 @@ use crate::linux::guest::{Guest, Kind}; use super::super::flags::AT_FDCWD; use super::super::{path, resolve, store}; -use super::statbuf::{build, STAT_LEN}; +use super::statbuf::{build, inode, STAT_LEN}; /// Size and whether it is a directory, or nothing when the path is /// absent. `full` is guest-visible and is confined here. @@ -42,7 +42,8 @@ pub fn fstat(guest: &mut Guest, fd: u64, out: u64) -> u64 { Kind::File => (entry.size.max(entry.pending.len() as u64), false), _ => (0, false), }; - write_out(guest, out, size, is_dir) + let ino = inode(&entry.path); + write_out(guest, out, size, is_dir, ino) } pub fn newfstatat(guest: &mut Guest, dirfd: u64, path_ptr: u64, out: u64) -> u64 { @@ -52,15 +53,15 @@ 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), + Some((size, is_dir)) => write_out(guest, out, size, is_dir, inode(&full)), None => errno::fail(errno::ENOENT), } } -fn write_out(guest: &Guest, out: u64, size: u64, is_dir: bool) -> u64 { - if guest.write(out, &build(size, is_dir)) < STAT_LEN as i64 { +fn write_out(guest: &Guest, out: u64, size: u64, is_dir: bool, ino: u64) -> u64 { + if guest.write(out, &build(size, is_dir, ino)) < STAT_LEN as i64 { return errno::fail(errno::EFAULT); } errno::ok(0) diff --git a/userland/capsule_linux/src/linux/file/meta/statbuf.rs b/userland/capsule_linux/src/linux/file/meta/statbuf.rs index 844180c60b..a3c0876a17 100644 --- a/userland/capsule_linux/src/linux/file/meta/statbuf.rs +++ b/userland/capsule_linux/src/linux/file/meta/statbuf.rs @@ -22,15 +22,27 @@ pub const STAT_LEN: usize = 144; pub const S_IFREG: u32 = 0o100000; pub const S_IFDIR: u32 = 0o040000; +const OFF_INO: usize = 8; const OFF_NLINK: usize = 16; const OFF_MODE: usize = 24; const OFF_SIZE: usize = 48; const OFF_BLKSIZE: usize = 56; const OFF_BLOCKS: usize = 64; -pub fn build(size: u64, is_dir: bool) -> [u8; STAT_LEN] { +/// A file's number, stable for its path and never zero. Distinct numbers are +/// how a loader tells two libraries apart; zero for every file made each +/// dlopen after the first hand back the library already loaded. +pub fn inode(path: &[u8]) -> u64 { + let fold = path + .iter() + .fold(0xcbf2_9ce4_8422_2325u64, |h, b| (h ^ u64::from(*b)).wrapping_mul(0x100_0000_01b3)); + fold | 1 +} + +pub fn build(size: u64, is_dir: bool, ino: u64) -> [u8; STAT_LEN] { let mut out = [0u8; STAT_LEN]; let mode = if is_dir { S_IFDIR | 0o755 } else { S_IFREG | 0o644 }; + put64(&mut out, OFF_INO, ino); put64(&mut out, OFF_NLINK, 1); put32(&mut out, OFF_MODE, mode); put64(&mut out, OFF_SIZE, size); 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/file/meta/statx.rs b/userland/capsule_linux/src/linux/file/meta/statx.rs index e20db264ee..d1375b89e6 100644 --- a/userland/capsule_linux/src/linux/file/meta/statx.rs +++ b/userland/capsule_linux/src/linux/file/meta/statx.rs @@ -22,16 +22,17 @@ use crate::linux::guest::Guest; use super::super::at::resolve_at; use super::super::resolve::key; use super::super::store; +use super::statbuf::inode; /// `struct statx` is 256 bytes. const STATX: usize = 256; -/// The bits for the fields the store can answer: type, mode, size and -/// mtime. Nothing else is claimed. +/// The bits for the fields answered: type, mode, inode and size. Times are +/// not claimed; a real mtime on a shared file would date its install. const STATX_TYPE: u32 = 0x0001; const STATX_MODE: u32 = 0x0002; const STATX_SIZE: u32 = 0x0200; -const STATX_MTIME: u32 = 0x0020; +const STATX_INO: u32 = 0x0100; const S_IFDIR: u16 = 0o040_000; const S_IFREG: u16 = 0o100_000; @@ -40,18 +41,18 @@ pub fn statx(guest: &Guest, dirfd: u64, path: u64, out: u64) -> u64 { let Some(at) = resolve_at(guest, dirfd, path) else { return errno::fail(errno::EFAULT); }; - let Ok((size, is_dir, mtime, readonly)) = store::stat_full(&key(&at)) else { + let Ok((size, is_dir, _, readonly)) = store::stat_full(&key(&at)) else { return errno::fail(errno::ENOENT); }; let mode = if is_dir { S_IFDIR } else { S_IFREG } | if readonly { 0o555 } else { 0o755 }; let mut buf = [0u8; STATX]; - buf[0..4].copy_from_slice(&(STATX_TYPE | STATX_MODE | STATX_SIZE | STATX_MTIME).to_le_bytes()); + buf[0..4].copy_from_slice(&(STATX_TYPE | STATX_MODE | STATX_INO | STATX_SIZE).to_le_bytes()); buf[4..8].copy_from_slice(&4096u32.to_le_bytes()); // stx_blksize buf[28..30].copy_from_slice(&mode.to_le_bytes()); // stx_mode + buf[32..40].copy_from_slice(&inode(&at).to_le_bytes()); // stx_ino buf[40..48].copy_from_slice(&size.to_le_bytes()); // stx_size buf[48..56].copy_from_slice(&size.div_ceil(512).to_le_bytes()); // stx_blocks - buf[96..104].copy_from_slice(&(mtime / 1000).to_le_bytes()); // stx_mtime.sec match guest.write(out, &buf) { n if n < 0 => errno::fail(errno::EFAULT), _ => errno::ok(0), diff --git a/userland/capsule_linux/src/linux/file/mknod.rs b/userland/capsule_linux/src/linux/file/mknod.rs new file mode 100644 index 0000000000..891240eec0 --- /dev/null +++ b/userland/capsule_linux/src/linux/file/mknod.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 . + +//! `mknodat`: a regular file is an empty file; no device node or fifo is made. + +use crate::linux::abi::errno; +use crate::linux::guest::Guest; + +use super::at::resolve_at; +use super::meta::stat; +use super::owner::refused; +use super::resolve::key; + +const S_IFMT: u64 = 0o170000; +const S_IFREG: u64 = 0o100000; + +/// A regular file is an empty file; devices and fifos are not made here. +pub fn mknodat(guest: &Guest, dirfd: u64, path: u64, mode: u64) -> u64 { + if mode & S_IFMT != S_IFREG && mode & S_IFMT != 0 { + return refused(b"[LINUX] refused mknod: no device nodes or fifos\n"); + } + let Some(at) = resolve_at(guest, dirfd, path) else { + return errno::fail(errno::EFAULT); + }; + if stat::look(&at).is_some() { + return errno::fail(errno::EEXIST); + } + if key(&at).writable().is_err() { + return errno::fail(errno::EROFS); + } + match super::store_write(&key(&at), &[]) { + Ok(()) => errno::ok(0), + Err(_) => errno::fail(errno::EIO), + } +} diff --git a/userland/capsule_linux/src/linux/file/mod.rs b/userland/capsule_linux/src/linux/file/mod.rs index 33c21506ea..51bf8ea960 100644 --- a/userland/capsule_linux/src/linux/file/mod.rs +++ b/userland/capsule_linux/src/linux/file/mod.rs @@ -17,22 +17,32 @@ //! The filesystem a guest sees. mod at; +mod clamp; pub(super) mod close; mod cstr; mod dir; +mod dir_children; mod dirent; mod dirents; mod dirops; mod epoll; +mod epoll_arm; mod epoll_wait; +mod eventfd; +mod eventfd_io; +pub mod family; pub mod flags; mod fsync; +mod link; mod memfd; mod memfd_map; mod meta; +mod mknod; mod open; +mod owner; mod path; mod pread; +mod private; mod read; mod regular; mod rename; @@ -44,6 +54,7 @@ mod store; mod store_name; mod timerfd; mod timerfd_read; +mod timerfd_spec; mod write; pub use close::close; @@ -51,22 +62,29 @@ pub use cstr::read_cstr; pub use dirents::getdents64; pub use dirops::{mkdirat, rmdir, unlinkat}; pub use epoll::{epoll_create, epoll_ctl}; +pub use epoll_arm::rearm; pub use epoll_wait::epoll_wait; +pub use eventfd::{bits as event_bits, eventfd2}; +pub use eventfd_io::{read as event_read, write as event_write}; pub use fsync::fsync; +pub use link::{linkat, symlinkat}; pub use memfd::{ftruncate, is_memfd, memfd_create}; pub use memfd_map::{mapped_at, set_mapped, staged}; pub use meta::{ - access, chmod, faccessat, fchmod, fchmodat, fstat, look, newfstatat, readlink, statfs, statx, + access, chmod, faccessat, fchmod, fchmodat, fstat, look, newfstatat, readlinkat, statfs, statx, }; +pub use mknod::mknodat; pub use open::openat; +pub use owner::{fchown_ids, fchownat, utimensat}; 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 rename::{rename, renameat2}; 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 timerfd::{timerfd_create, timerfd_settime}; -pub use timerfd_read::read as timerfd_read; +pub use timerfd::{timerfd_create, timerfd_gettime, timerfd_settime}; +pub use timerfd_read::{bits as timer_bits, read as timerfd_read}; pub use write::write; 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/file/owner.rs b/userland/capsule_linux/src/linux/file/owner.rs new file mode 100644 index 0000000000..e579fbcaa2 --- /dev/null +++ b/userland/capsule_linux/src/linux/file/owner.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 . + +//! Owners and times: what the store does not record. +//! +//! The store keeps bytes under names and nothing else, so every file reports +//! uid 0, gid 0 and time zero, and the guest runs as uid 0. A change that +//! would leave that true is answered; one that would need the store to keep +//! something it cannot is refused by name, never reported done. + +use nonos_libc::mk_debug; + +use crate::linux::abi::errno; +use crate::linux::guest::Guest; + +use super::at::resolve_at; +use super::meta::stat; + +const KEEP: u32 = u32::MAX; +const UTIME_OMIT: u64 = (1 << 30) - 2; + +/// `fchownat`; `chown`, `lchown` and `fchown` are this at other bases. +pub fn fchownat(guest: &Guest, dirfd: u64, path: u64, uid: u64, gid: u64) -> u64 { + let Some(at) = resolve_at(guest, dirfd, path) else { + return errno::fail(errno::EFAULT); + }; + if stat::look(&guest.links.follow(at, true)).is_none() { + return errno::fail(errno::ENOENT); + } + fchown_ids(uid, gid) +} + +/// `fchown` on an open descriptor: only the owner every file already has. +pub fn fchown_ids(uid: u64, gid: u64) -> u64 { + match [uid as u32, gid as u32].iter().all(|id| *id == 0 || *id == KEEP) { + true => errno::ok(0), + false => refused(b"[LINUX] refused chown: owners are not recorded\n"), + } +} + +/// Times are not kept, so only a call that changes neither is answered. +pub fn utimensat(guest: &Guest, times: u64) -> u64 { + let omitted = + |at: u64| guest.read(at + 8, 8).map(|n| u64::from_le_bytes(n.try_into().unwrap_or([0; 8]))); + if times != 0 && omitted(times) == Some(UTIME_OMIT) && omitted(times + 16) == Some(UTIME_OMIT) { + return errno::ok(0); + } + refused(b"[LINUX] refused utimensat: times are not recorded\n") +} + +pub(super) fn refused(line: &[u8]) -> u64 { + let _ = mk_debug(line.as_ptr(), line.len()); + errno::fail(errno::EPERM) +} 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/rename.rs b/userland/capsule_linux/src/linux/file/rename.rs index 489c043016..f97e27994f 100644 --- a/userland/capsule_linux/src/linux/file/rename.rs +++ b/userland/capsule_linux/src/linux/file/rename.rs @@ -35,3 +35,25 @@ pub fn rename(guest: &Guest, old: u64, new: u64) -> u64 { Err(_) => errno::fail(errno::ENOENT), } } + +const RENAME_NOREPLACE: u64 = 1; + +/// `renameat` and `renameat2`. NOREPLACE refuses an existing target; +/// EXCHANGE would need two names swapped at once, which the store cannot +/// do, so it is refused rather than done as two renames that could half-fail. +pub fn renameat2(guest: &Guest, olddir: u64, old: u64, newdir: u64, new: u64, flags: u64) -> u64 { + if flags & !RENAME_NOREPLACE != 0 { + return errno::fail(errno::EINVAL); + } + let (Some(from), Some(to)) = (resolve_at(guest, olddir, old), resolve_at(guest, newdir, new)) + else { + return errno::fail(errno::EFAULT); + }; + if flags & RENAME_NOREPLACE != 0 && super::meta::stat::look(&to).is_some() { + return errno::fail(errno::EEXIST); + } + match store_name::rename(&key(&from), &key(&to)) { + Ok(()) => errno::ok(0), + Err(_) => errno::fail(errno::ENOENT), + } +} 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/file/root.rs b/userland/capsule_linux/src/linux/file/root.rs index 4155b82e70..795085f0df 100644 --- a/userland/capsule_linux/src/linux/file/root.rs +++ b/userland/capsule_linux/src/linux/file/root.rs @@ -18,20 +18,29 @@ use alloc::vec::Vec; -/// Where the Linux world is kept. Every path a guest sees is relative -/// to this, and it never appears in anything handed back to a guest. -pub const ROOT: &[u8] = b"/linux"; +// Where the Linux world is kept: the chosen family's tree. Every path a +// guest sees is relative to it, and it never appears in anything handed +// back to a guest. +use super::family::root as family_root; /// 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 { - let mut out = Vec::with_capacity(ROOT.len() + visible.len()); - out.extend_from_slice(ROOT); + // 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 root = family_root(); + let mut out = Vec::with_capacity(root.len() + visible.len()); + out.extend_from_slice(root); /* * The guest's root is the store's `/linux`, not `/linux/`: a trailing * separator makes every listing prefix wrong by one byte and every @@ -40,10 +49,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 9b215fc3c2..ec6dcce7dc 100644 --- a/userland/capsule_linux/src/linux/file/store.rs +++ b/userland/capsule_linux/src/linux/file/store.rs @@ -31,15 +31,28 @@ 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) } +/* + * 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/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/file/timerfd.rs b/userland/capsule_linux/src/linux/file/timerfd.rs index d123a9dc77..3396b85ec9 100644 --- a/userland/capsule_linux/src/linux/file/timerfd.rs +++ b/userland/capsule_linux/src/linux/file/timerfd.rs @@ -14,37 +14,91 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -//! `timerfd_create`, `timerfd_settime`, and reading one. +//! `timerfd_create`, `timerfd_settime` and `timerfd_gettime`, as Linux +//! defines them: a timer on a named clock, one-shot or periodic, set +//! relative to now or to an absolute time on its clock. use crate::linux::abi::errno; -use crate::linux::guest::{Fd, Guest, Kind}; +use crate::linux::call::now_ms; +use crate::linux::guest::{Fd, Guest, Kind, Timer}; +use super::flags::{O_CLOEXEC, O_NONBLOCK}; use super::slot::install; +use super::timerfd_spec::{read_spec, write_spec}; -/// `struct itimerspec`: interval seconds and nanoseconds, then the -/// value's seconds and nanoseconds. Four eight byte fields. -const ITIMERSPEC_LEN: usize = 32; +const CLOCK_MONOTONIC: u64 = 1; +/// REALTIME, MONOTONIC, BOOTTIME, REALTIME_ALARM, BOOTTIME_ALARM. +const CLOCKS: [u64; 5] = [0, 1, 7, 8, 9]; +const TFD_TIMER_ABSTIME: u64 = 1; +const TFD_TIMER_CANCEL_ON_SET: u64 = 2; -pub fn timerfd_create(guest: &mut Guest) -> u64 { - match install(guest, Fd::empty(Kind::Timer)) { +pub fn timerfd_create(guest: &mut Guest, clock: u64, flags: u64) -> u64 { + if !CLOCKS.contains(&clock) || flags & !(O_NONBLOCK | O_CLOEXEC) != 0 { + return errno::fail(errno::EINVAL); + } + let slot = guest.timers.len(); + guest.timers.push(Timer { clock, ..Timer::default() }); + let mut fd = Fd::empty(Kind::Timer); + fd.handle = slot as u32; + fd.nonblock = flags & O_NONBLOCK != 0; + fd.cloexec = flags & O_CLOEXEC != 0; + match install(guest, fd) { Some(n) => errno::ok(n), None => errno::fail(errno::EMFILE), } } -pub fn timerfd_settime(guest: &mut Guest, fd: u64, spec: u64) -> u64 { - let Some(raw) = guest.read(spec, ITIMERSPEC_LEN) else { - return errno::fail(errno::EFAULT); +/// Arm or disarm, writing the previous setting to `old` when it is named. +pub fn timerfd_settime(guest: &mut Guest, fd: u64, flags: u64, new: u64, old: u64) -> u64 { + let Some(slot) = slot_of(guest, fd) else { + return errno::fail(errno::EBADF); + }; + if flags & !(TFD_TIMER_ABSTIME | TFD_TIMER_CANCEL_ON_SET) != 0 { + return errno::fail(errno::EINVAL); + } + let (every, value) = match read_spec(guest, new) { + Ok(pair) => pair, + Err(refused) => return refused, }; - let secs = u64::from_le_bytes(raw[16..24].try_into().unwrap_or([0; 8])); - let nanos = u64::from_le_bytes(raw[24..32].try_into().unwrap_or([0; 8])); - let delay = secs * 1000 + nanos / 1_000_000; - let now = nonos_libc::mk_uptime_ms().max(0) as u64; - match guest.fds.get_mut(fd as usize).filter(|f| f.kind == Kind::Timer) { - Some(entry) => { - entry.expiry = if delay == 0 { 0 } else { now + delay }; - errno::ok(0) + if old != 0 && write_spec(guest, old, current(guest, slot)) < 0 { + return errno::fail(errno::EFAULT); + } + let now = now_ms(CLOCK_MONOTONIC).unwrap_or(0); + let timer = &mut guest.timers[slot]; + timer.every = every; + timer.due = match (value, flags & TFD_TIMER_ABSTIME != 0) { + (0, _) => 0, + (span, false) => now.saturating_add(span), + // An absolute time is a distance from now on the timer's own clock. + (at, true) => { + let on_clock = now_ms(timer.clock).unwrap_or(now); + now.saturating_add(at.saturating_sub(on_clock)).max(1) } - None => errno::fail(errno::EBADF), + }; + errno::ok(0) +} + +pub fn timerfd_gettime(guest: &mut Guest, fd: u64, out: u64) -> u64 { + let Some(slot) = slot_of(guest, fd) else { + return errno::fail(errno::EBADF); + }; + match write_spec(guest, out, current(guest, slot)) < 0 { + true => errno::fail(errno::EFAULT), + false => errno::ok(0), } } + +/// The interval, and what is left before the next firing. +fn current(guest: &Guest, slot: usize) -> (u64, u64) { + let now = now_ms(CLOCK_MONOTONIC).unwrap_or(0); + let timer = guest.timers[slot]; + let left = if timer.due == 0 { 0 } else { timer.due.saturating_sub(now).max(1) }; + (timer.every, left) +} + +/// The timer `fd` names, if it is a timerfd. +pub fn slot_of(guest: &Guest, fd: u64) -> Option { + let entry = guest.fds.get(fd as usize).filter(|f| f.kind == Kind::Timer)?; + let slot = entry.handle as usize; + (slot < guest.timers.len()).then_some(slot) +} diff --git a/userland/capsule_linux/src/linux/file/timerfd_read.rs b/userland/capsule_linux/src/linux/file/timerfd_read.rs index e267ec1b60..708569faea 100644 --- a/userland/capsule_linux/src/linux/file/timerfd_read.rs +++ b/userland/capsule_linux/src/linux/file/timerfd_read.rs @@ -14,26 +14,47 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -//! Reading a timer, and whether it has fired. +//! Reading a timer: how many times it has fired since the last read, as a +//! u64, and whether it has fired at all, for poll and epoll. use crate::linux::abi::errno; -use crate::linux::guest::{Guest, Kind}; +use crate::linux::call::now_ms; +use crate::linux::guest::Guest; -/// A read reports how many times it has fired, which is one or none. -pub fn read(guest: &mut Guest, fd: u64, buf: u64) -> u64 { - let now = nonos_libc::mk_uptime_ms().max(0) as u64; - let fired = match guest.fds.get(fd as usize) { - Some(e) if e.kind == Kind::Timer => e.expiry != 0 && now >= e.expiry, - _ => return errno::fail(errno::EBADF), +use super::timerfd::slot_of; + +const CLOCK_MONOTONIC: u64 = 1; +const POLLIN: u16 = 0x001; +const POLLNVAL: u16 = 0x020; + +/// EAGAIN until it has fired; whether the caller waits is decided by who +/// called, from the descriptor's O_NONBLOCK. +pub fn read(guest: &mut Guest, fd: u64, buf: u64, len: u64) -> u64 { + let Some(slot) = slot_of(guest, fd) else { + return errno::fail(errno::EBADF); }; - if !fired { - return errno::fail(errno::EAGAIN); + if len < 8 { + return errno::fail(errno::EINVAL); } - if let Some(entry) = guest.fds.get_mut(fd as usize) { - entry.expiry = 0; + let now = now_ms(CLOCK_MONOTONIC).unwrap_or(0); + let mut timer = guest.timers[slot]; + let times = timer.take(now); + if times == 0 { + return errno::fail(errno::EAGAIN); } - if guest.write(buf, &1u64.to_le_bytes()) < 8 { + // Written before it is taken, so a bad buffer leaves the count as it was. + if guest.write(buf, ×.to_le_bytes()) < 8 { return errno::fail(errno::EFAULT); } + guest.timers[slot] = timer; errno::ok(8) } + +/// Readable once it has fired, and never writable. +pub fn bits(guest: &Guest, fd: u64) -> u16 { + match slot_of(guest, fd) { + Some(slot) if guest.timers[slot].fired(now_ms(CLOCK_MONOTONIC).unwrap_or(0)) => POLLIN, + Some(_) => 0, + None => POLLNVAL, + } +} diff --git a/userland/capsule_linux/src/linux/file/timerfd_spec.rs b/userland/capsule_linux/src/linux/file/timerfd_spec.rs new file mode 100644 index 0000000000..01da88dd19 --- /dev/null +++ b/userland/capsule_linux/src/linux/file/timerfd_spec.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 . + +//! `struct itimerspec`: the interval, then the value, each a timespec of +//! seconds and nanoseconds. Kept here in milliseconds, rounded up. + +use crate::linux::abi::errno; +use crate::linux::guest::Guest; + +const LEN: usize = 32; +const NSEC: i64 = 1_000_000_000; + +/// `(interval, value)`, or EFAULT or EINVAL as Linux refuses them. +pub fn read_spec(guest: &Guest, at: u64) -> Result<(u64, u64), u64> { + let Some(raw) = guest.read(at, LEN) else { + return Err(errno::fail(errno::EFAULT)); + }; + let word = |i: usize| i64::from_le_bytes(raw[i * 8..i * 8 + 8].try_into().unwrap_or([0; 8])); + let mut ms = [0u64; 2]; + for (k, (secs, nanos)) in [(word(0), word(1)), (word(2), word(3))].into_iter().enumerate() { + if secs < 0 || !(0..NSEC).contains(&nanos) { + return Err(errno::fail(errno::EINVAL)); + } + ms[k] = + (secs as u64).saturating_mul(1000).saturating_add((nanos as u64).div_ceil(1_000_000)); + } + Ok((ms[0], ms[1])) +} + +/// Write `(interval, value)` in milliseconds as an itimerspec. +pub fn write_spec(guest: &mut Guest, at: u64, (every, left): (u64, u64)) -> i64 { + let mut raw = [0u8; LEN]; + for (i, ms) in [every, left].into_iter().enumerate() { + raw[i * 16..i * 16 + 8].copy_from_slice(&(ms / 1000).to_le_bytes()); + raw[i * 16 + 8..i * 16 + 16].copy_from_slice(&((ms % 1000) * 1_000_000).to_le_bytes()); + } + guest.write(at, &raw) +} diff --git a/userland/capsule_linux/src/linux/guest/blocked.rs b/userland/capsule_linux/src/linux/guest/blocked.rs new file mode 100644 index 0000000000..57600f28d7 --- /dev/null +++ b/userland/capsule_linux/src/linux/guest/blocked.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 . + +//! A call waiting on descriptors, left parked in its trap until it can +//! complete or its deadline passes. + +#[derive(Clone, Copy)] +pub struct Blocked { + pub tid: u32, + /// The call and its arguments as the guest gave them, so the family can + /// try it again whenever something may have changed. + pub nr: u64, + pub args: [u64; 6], + /// Monotonic milliseconds after which it is answered with nothing ready. + /// None waits for as long as it takes. + pub deadline: Option, +} diff --git a/userland/capsule_linux/src/linux/guest/event.rs b/userland/capsule_linux/src/linux/guest/event.rs new file mode 100644 index 0000000000..a0687868af --- /dev/null +++ b/userland/capsule_linux/src/linux/guest/event.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 . + +//! The object behind an eventfd. +//! +//! It is the counter, not the descriptor: dup, fork and every thread reach +//! one counter through their own descriptors, so it is kept with the pipes +//! as the family's, and a descriptor names it by index. + +#[derive(Clone, Copy)] +pub struct Event { + pub count: u64, + /// EFD_SEMAPHORE: a read takes one from the count rather than all of it. + pub semaphore: bool, +} diff --git a/userland/capsule_linux/src/linux/guest/fd.rs b/userland/capsule_linux/src/linux/guest/fd.rs index 913bd2f712..96c45f23b7 100644 --- a/userland/capsule_linux/src/linux/guest/fd.rs +++ b/userland/capsule_linux/src/linux/guest/fd.rs @@ -42,17 +42,16 @@ pub struct Fd { pub writable: bool, /// The net.sockets handle behind a socket descriptor. pub handle: u32, - /// An epoll interest list: descriptor, events, and the token the - /// program gets back, which is its own and never interpreted. - pub watch: Vec<(u64, u32, u64)>, - /// When a timer next fires, in milliseconds of uptime. - pub expiry: u64, + /// An epoll interest list. + pub watch: Vec, /// Datagrams waiting to be read, oldest first, each with the address it /// should appear to come from. pub replies: Vec<(Vec, [u8; 6])>, /// Closed by exec rather than carried into the new program. A shell /// leaves its own descriptors set this way before it runs a command. pub cloexec: bool, + /// O_NONBLOCK: a call that would wait is answered EAGAIN instead. + pub nonblock: bool, } impl Fd { diff --git a/userland/capsule_linux/src/linux/guest/fd_dup.rs b/userland/capsule_linux/src/linux/guest/fd_dup.rs index c55a42d80f..f8b78ec639 100644 --- a/userland/capsule_linux/src/linux/guest/fd_dup.rs +++ b/userland/capsule_linux/src/linux/guest/fd_dup.rs @@ -33,6 +33,10 @@ impl Fd { let mut fd = Fd::empty(from.kind); fd.handle = from.handle; fd.writable = from.writable; + fd.nonblock = from.nonblock; + // Linux shares the interest list itself; a copy keeps what was + // registered when the descriptor was duplicated or the process forked. + fd.watch = from.watch.clone(); fd.size = from.size; fd.offset = from.offset; fd.path = from.path.clone(); diff --git a/userland/capsule_linux/src/linux/guest/fd_empty.rs b/userland/capsule_linux/src/linux/guest/fd_empty.rs index 4ecd269277..d91eb80ab2 100644 --- a/userland/capsule_linux/src/linux/guest/fd_empty.rs +++ b/userland/capsule_linux/src/linux/guest/fd_empty.rs @@ -36,9 +36,9 @@ impl Fd { writable: false, handle: 0, watch: Vec::new(), - expiry: 0, replies: Vec::new(), cloexec: false, + nonblock: false, } } } diff --git a/userland/capsule_linux/src/linux/guest/fd_kind.rs b/userland/capsule_linux/src/linux/guest/fd_kind.rs index 3ca6048af4..6888e6dd07 100644 --- a/userland/capsule_linux/src/linux/guest/fd_kind.rs +++ b/userland/capsule_linux/src/linux/guest/fd_kind.rs @@ -42,4 +42,6 @@ pub enum Kind { Pipe, /// A datagram socket a program opened to talk to a nameserver. Resolver, + /// An eventfd: a counter one thread adds to and another takes from. + Event, } 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..54b26502ad --- /dev/null +++ b/userland/capsule_linux/src/linux/guest/fork_state.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 . + +//! 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.signals = self.signals.clone(); + 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.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 62bc60b465..1f12ca7841 100644 --- a/userland/capsule_linux/src/linux/guest/handle.rs +++ b/userland/capsule_linux/src/linux/guest/handle.rs @@ -27,26 +27,38 @@ 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>, + /// For each pipe, whether a read end and a write end are open anywhere + /// in the family. Filled when the family lends the buffers. + pub pipe_ends: Vec<(bool, bool)>, + /// eventfd counters, named by index from their descriptors. The + /// family's, lent with the pipes. + pub events: Vec, + /// timerfd timers, the same way. + pub timers: Vec, /// Children this guest has forked, for wait to report on. pub children: Vec, /// Tids of this guest's threads, not counting itself. pub threads: Vec, + /// The word each thread asked to have cleared when it exits, from + /// CLONE_CHILD_CLEARTID or set_tid_address: zeroed and woken then, + /// which is what a joiner waits for. + pub clear_tids: Vec<(u32, u64)>, /// Threads parked in a futex wait, with the word they wait on. pub waits: Vec<(u32, u64)>, + /// The futex waits that have a timeout: the monotonic deadline, and who. + pub futex_until: Vec<(u64, u32)>, /// The display connection, when the guest has opened one. pub display: crate::linux::unix::Conn, /// The Wayland objects that connection has created. 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`. - pub handlers: [bool; 64], + /// Signal dispositions and what is raised against this process's threads. + pub signals: super::sigqueue::Signals, /// What a relative path is relative to. pub cwd: Vec, /// Names this guest has resolved, each with the address it was given. @@ -60,7 +72,18 @@ 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, + /// 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)>, + /// Threads parked in a sleep: the monotonic deadline, and who. + pub sleepers: Vec<(u64, u32)>, + /// Calls parked until a descriptor they wait on is ready. + pub blocked: Vec, + /// 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 91602fc326..0d8b798db0 100644 --- a/userland/capsule_linux/src/linux/guest/handle_new.rs +++ b/userland/capsule_linux/src/linux/guest/handle_new.rs @@ -31,10 +31,15 @@ impl Guest { fds: Fd::standard(), regions: Vec::new(), pipes: Vec::new(), + pipe_ends: Vec::new(), + events: Vec::new(), + timers: Vec::new(), children: Vec::new(), threads: Vec::new(), + clear_tids: Vec::new(), waits: Vec::new(), - handlers: [false; 64], + futex_until: Vec::new(), + signals: super::sigqueue::Signals::default(), display: Default::default(), objects: Default::default(), scene: Default::default(), @@ -50,6 +55,12 @@ impl Guest { pgid: pid, sid: pid, umask: crate::linux::call::DEFAULT_UMASK, + forked: Vec::new(), + ended: Vec::new(), + waiting: None, + sleepers: Vec::new(), + blocked: Vec::new(), + links: Default::default(), } } } diff --git a/userland/capsule_linux/src/linux/guest/layout.rs b/userland/capsule_linux/src/linux/guest/layout.rs index 20a536f5e0..b8ea92e5b4 100644 --- a/userland/capsule_linux/src/linux/guest/layout.rs +++ b/userland/capsule_linux/src/linux/guest/layout.rs @@ -19,8 +19,9 @@ /// The heap, growing up from here as `brk` moves. pub const BRK_BASE: u64 = 0x0000_1000_0000; -/// Anonymous and file mappings, growing up from here. -pub const MMAP_BASE: u64 = 0x0000_2000_0000; +/// Anonymous and file mappings, growing up from here: high above the images +/// and the stack, with room to the top of user space for large reservations. +pub const MMAP_BASE: u64 = 0x0000_0001_0000_0000; /// Where the loader biases a position-independent executable. pub const EXEC_BASE: u64 = 0x0000_4000_0000; @@ -35,8 +36,13 @@ pub const STACK_TOP: u64 = 0x0000_7FFF_F000; /// The stack a guest gets. pub const STACK_SIZE: u64 = 1 << 20; -/// The break may not reach the mapping area. -pub const BRK_LIMIT: u64 = MMAP_BASE; +/// The break may not reach the images above it. +pub const BRK_LIMIT: u64 = 0x0000_2000_0000; -/// A mapping may not reach the images above it. -pub const MMAP_LIMIT: u64 = EXEC_BASE; +/// A mapping may not reach the top of the window left below the stack. +pub const MMAP_LIMIT: u64 = 0x0000_7F00_0000_0000; + +/// The ceiling for any mapping: one page below the top of user space. A +/// runtime that reserves a large address range, as Go's page allocator does, +/// needs the room, and a reservation backs no frames until it is touched. +pub const USER_MAX: u64 = 0x0000_7FFF_FFFF_F000; 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..1a710dedf8 --- /dev/null +++ b/userland/capsule_linux/src/linux/guest/links.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 . + +//! 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 core::cell::RefCell; + +use crate::linux::file::visible; + +/// Linux gives up at forty; a table this small never legitimately nears it. +const MAX_HOPS: usize = 16; + +/// Shared by every process in the family through one `Rc`, so a link one of +/// them makes is there for the others, as on Linux. +#[derive(Default)] +pub struct Links(pub(super) RefCell, 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> { + self.0.borrow().iter().find(|(from, _)| from == path).map(|(_, to)| to.clone()) + } + + fn first_in(&self, path: &[u8], last: bool) -> Option<(usize, Vec)> { + 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_add.rs b/userland/capsule_linux/src/linux/guest/links_add.rs new file mode 100644 index 0000000000..78d5aa1003 --- /dev/null +++ b/userland/capsule_linux/src/linux/guest/links_add.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 . + +//! A link made at run time, by `symlink`, joining the family's table. + +use alloc::vec::Vec; + +use super::links::Links; + +impl Links { + /// A new link at `path`; false when `path` already is one. + pub fn add(&self, path: Vec, target: Vec) -> bool { + if self.target(&path).is_some() { + return false; + } + self.0.borrow_mut().push((path, target)); + true + } +} 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..b0579d4ff3 --- /dev/null +++ b/userland/capsule_linux/src/linux/guest/links_list.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 . + +//! 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 all = self.0.borrow(); + let names = all.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/links_load.rs b/userland/capsule_linux/src/linux/guest/links_load.rs new file mode 100644 index 0000000000..710d4c22a2 --- /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(core::cell::RefCell::new(pairs.collect())) + } +} diff --git a/userland/capsule_linux/src/linux/guest/mem_map.rs b/userland/capsule_linux/src/linux/guest/mem_map.rs index a615617e05..83404d6f44 100644 --- a/userland/capsule_linux/src/linux/guest/mem_map.rs +++ b/userland/capsule_linux/src/linux/guest/mem_map.rs @@ -19,15 +19,16 @@ use nonos_libc::peer::{mk_peer_map, PEER_PROT_EXEC, PEER_PROT_WRITE}; use super::handle::Guest; -use super::layout::STACK_TOP; +use super::layout::USER_MAX; use super::mem::{span_within, MAX_SPAN}; use super::region::Region; +use super::region_cut::cut; impl Guest { /// Pages covering `[addr, addr + len)`. pub fn map(&mut self, addr: u64, len: u64, write: bool, exec: bool) -> i64 { // Bounded by the top of the guest's area, which is the stack. - let Some((start, span)) = span_within(addr, len, STACK_TOP) else { + let Some((start, span)) = span_within(addr, len, USER_MAX) else { return -1; }; let mut prot = 0; @@ -50,7 +51,58 @@ 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, + backed: true, + }); + 0 + } + + /// Back `[at, at + len)` of a reservation with the given protection, the + /// commit a fixed mmap makes. Pages the guest has not touched get zeroed + /// frames; pages it has touched keep their contents, since peer_map skips + /// a page that is already there. The span is then recorded as backed, in + /// place of the reservation it came from, so fork copies it. + pub fn commit(&mut self, at: u64, len: u64, write: bool, exec: bool) -> i64 { + let mut prot = 0; + if write { + prot |= PEER_PROT_WRITE; + } + if exec { + prot |= PEER_PROT_EXEC; + } + let mut done = 0; + while done < len { + let take = (len - done).min(MAX_SPAN); + let rc = mk_peer_map(self.pid, at + done, take, prot); + if rc < 0 { + return rc; + } + done += take; + } + self.regions = cut(&self.regions, at, len); + self.regions.push(Region { at, len, write, exec, unproven: false, backed: true }); + 0 + } + + /// Take `len` of address space at `addr` without backing it: a PROT_NONE + /// reservation. Bytes appear, zeroed, when the guest first touches them. + pub fn reserve(&mut self, addr: u64, len: u64) -> i64 { + let Some((start, span)) = span_within(addr, len, USER_MAX) else { + return -1; + }; + self.regions.push(Region { + at: start, + len: span, + write: true, + exec: false, + unproven: false, + backed: false, + }); 0 } } diff --git a/userland/capsule_linux/src/linux/guest/mem_unmap.rs b/userland/capsule_linux/src/linux/guest/mem_unmap.rs index 320220351c..682335fee9 100644 --- a/userland/capsule_linux/src/linux/guest/mem_unmap.rs +++ b/userland/capsule_linux/src/linux/guest/mem_unmap.rs @@ -19,14 +19,14 @@ use nonos_libc::peer::mk_peer_unmap; use super::handle::Guest; -use super::layout::STACK_TOP; +use super::layout::USER_MAX; use super::mem::{span_within, MAX_SPAN}; use super::region_cut::cut; impl Guest { /// Return `[addr, addr + len)` to the kernel. pub fn unmap(&mut self, addr: u64, len: u64) -> i64 { - let Some((start, span)) = span_within(addr, len, STACK_TOP) else { + let Some((start, span)) = span_within(addr, len, USER_MAX) else { return -1; }; let mut done = 0; diff --git a/userland/capsule_linux/src/linux/guest/mod.rs b/userland/capsule_linux/src/linux/guest/mod.rs index 19f20d91c8..c1b8e7b04d 100644 --- a/userland/capsule_linux/src/linux/guest/mod.rs +++ b/userland/capsule_linux/src/linux/guest/mod.rs @@ -17,14 +17,24 @@ //! A hosted process: what it is, what it has open, and how this capsule //! reaches into it. +mod blocked; +mod event; mod fd; mod fd_dup; mod fd_empty; mod fd_kind; mod fd_make; +mod fork_state; mod handle; mod handle_new; +pub mod sigqueue; +pub mod sigstack; +pub mod sigstate; mod layout; +mod links; +mod links_add; +mod links_list; +mod links_load; mod mem; mod mem_copy; mod mem_map; @@ -32,14 +42,22 @@ mod mem_unmap; mod region; mod region_cut; mod region_find; +mod region_mark; mod threads; +mod timer; +mod watch; +pub use blocked::Blocked; +pub use event::Event; 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, + STACK_TOP, USER_MAX, }; pub use mem::{page_down, page_up, span_within, MAX_SPAN, PAGE}; pub use region::Region; +pub use timer::Timer; +pub use watch::{Watch, EPOLLET, EPOLLONESHOT}; diff --git a/userland/capsule_linux/src/linux/guest/region.rs b/userland/capsule_linux/src/linux/guest/region.rs index 67ff889c33..8caf7b1a1f 100644 --- a/userland/capsule_linux/src/linux/guest/region.rs +++ b/userland/capsule_linux/src/linux/guest/region.rs @@ -22,4 +22,11 @@ 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, + /// False for a PROT_NONE reservation: address space taken, no frames yet. + /// The kernel demand-fills a page on first access, so reserving a large + /// span and committing a little costs only what is touched; fork skips it. + pub backed: 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)) + } +} diff --git a/userland/capsule_linux/src/linux/guest/sigqueue.rs b/userland/capsule_linux/src/linux/guest/sigqueue.rs new file mode 100644 index 0000000000..a05a425a41 --- /dev/null +++ b/userland/capsule_linux/src/linux/guest/sigqueue.rs @@ -0,0 +1,74 @@ +// 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 . + +//! Signals raised against a process's threads and not yet delivered, with the +//! disposition of each. The queue names the thread a signal is for. + +use alloc::vec::Vec; + +use super::sigstack::AltStack; +use super::sigstate::{SigAction, NSIG}; + +#[derive(Clone)] +pub struct Signals { + actions: [SigAction; NSIG], + pending: Vec<(u32, u8)>, + /// Each thread's alternate signal stack (`sigstack.rs`). + pub(super) stacks: Vec<(u32, AltStack)>, +} + +impl Default for Signals { + fn default() -> Self { + Self { actions: [SigAction::default(); NSIG], pending: Vec::new(), stacks: Vec::new() } + } +} + +impl Signals { + /// Record a disposition; `signum` is 1..=NSIG. + pub fn set(&mut self, signum: usize, act: SigAction) { + if (1..=NSIG).contains(&signum) { + self.actions[signum - 1] = act; + } + } + + pub fn action(&self, signum: usize) -> Option { + (1..=NSIG).contains(&signum).then(|| self.actions[signum - 1]) + } + + /// Queue a signal against a thread. A standard signal already pending is + /// not queued twice, as Linux coalesces non-realtime signals. + pub fn raise(&mut self, tid: u32, signum: u8) { + if !self.pending.iter().any(|p| *p == (tid, signum)) { + self.pending.push((tid, signum)); + } + } + + /// The next signal for `tid` its disposition catches, removed. Signals + /// with no handler are left for the caller to default. + pub fn take_caught(&mut self, tid: u32) -> Option<(u8, SigAction)> { + let at = self + .pending + .iter() + .position(|(t, s)| *t == tid && self.actions[*s as usize - 1].catches())?; + let signum = self.pending.remove(at).1; + Some((signum, self.actions[signum as usize - 1])) + } + + /// Drop every signal pending for a thread that has gone. + pub fn forget(&mut self, tid: u32) { + self.pending.retain(|(t, _)| *t != tid); + } +} diff --git a/userland/capsule_linux/src/linux/guest/sigstack.rs b/userland/capsule_linux/src/linux/guest/sigstack.rs new file mode 100644 index 0000000000..d05823b62d --- /dev/null +++ b/userland/capsule_linux/src/linux/guest/sigstack.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 . + +//! Each thread's alternate signal stack, as `sigaltstack` sets it: where a +//! handler installed with SA_ONSTACK is entered, so that a program whose own +//! stacks are small or are not its to use (a Go goroutine's) never has a +//! handler run on them. Linux keeps one per thread: a fork gives the child +//! the forking thread's, a new thread starts with none, and execve clears it. + +use super::sigqueue::Signals; + +/// `sigaltstack`'s flags, from Linux's `include/uapi/linux/signal.h`. +pub const SS_ONSTACK: u32 = 1; +pub const SS_DISABLE: u32 = 2; + +/// Where a thread's alternate stack is: its lowest address and its size. +#[derive(Clone, Copy, PartialEq, Eq)] +pub struct AltStack { + pub sp: u64, + pub size: u64, +} + +impl AltStack { + /// True when `rsp` is on this stack, as Linux's `on_sig_stack` tests it: + /// above the base, and no further than its size. + pub fn holds(&self, rsp: u64) -> bool { + rsp > self.sp && rsp - self.sp <= self.size + } +} + +impl Signals { + pub fn stack(&self, tid: u32) -> Option { + self.stacks.iter().find(|(t, _)| *t == tid).map(|(_, s)| *s) + } + + /// Set or, with None, disable a thread's alternate stack. + pub fn set_stack(&mut self, tid: u32, stack: Option) { + self.stacks.retain(|(t, _)| *t != tid); + if let Some(s) = stack { + self.stacks.push((tid, s)); + } + } + + /// A forked child's copy: its one thread has the forking thread's stack. + pub fn stack_for_child(&mut self, forker: u32, child: u32) { + let kept = self.stack(forker); + self.stacks.clear(); + self.set_stack(child, kept); + } + + /// execve: the program that follows has set no stack. + pub fn clear_stacks(&mut self) { + self.stacks.clear(); + } +} diff --git a/userland/capsule_linux/src/linux/guest/sigstate.rs b/userland/capsule_linux/src/linux/guest/sigstate.rs new file mode 100644 index 0000000000..ae31b50252 --- /dev/null +++ b/userland/capsule_linux/src/linux/guest/sigstate.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 . + +//! A signal's disposition: what the guest asked to happen when it fires. +//! Process-wide, as on Linux. + +/// The largest signal Linux defines. +pub const NSIG: usize = 64; + +/// `struct sigaction` as the guest passes it: handler, flags, restorer, mask. +#[derive(Clone, Copy, Default)] +pub struct SigAction { + pub handler: u64, + pub flags: u64, + pub restorer: u64, + pub mask: u64, +} + +impl SigAction { + /// SIG_DFL is a null handler and SIG_IGN is 1; neither enters guest code. + pub fn catches(&self) -> bool { + self.handler > 1 + } + pub fn ignores(&self) -> bool { + self.handler == 1 + } +} diff --git a/userland/capsule_linux/src/linux/guest/threads.rs b/userland/capsule_linux/src/linux/guest/threads.rs index 4bef8fd9be..ffd95a7d90 100644 --- a/userland/capsule_linux/src/linux/guest/threads.rs +++ b/userland/capsule_linux/src/linux/guest/threads.rs @@ -48,10 +48,19 @@ impl Guest { continue; } let (tid, _) = self.waits.remove(i); + self.futex_until.retain(|&(_, t)| t != tid); if mk_foreign_reply(tid, 0) >= 0 { woken += 1; } } woken } + + /// Drop every wait `tid` is parked in, for a thread that is being ended: + /// a wait left behind could later take what a live thread waits for. + pub fn forget_waits(&mut self, tid: u32) { + self.waits.retain(|&(w, _)| w != tid); + self.futex_until.retain(|&(_, t)| t != tid); + self.blocked.retain(|w| w.tid != tid); + } } diff --git a/userland/capsule_linux/src/linux/guest/timer.rs b/userland/capsule_linux/src/linux/guest/timer.rs new file mode 100644 index 0000000000..b7ad5c50fe --- /dev/null +++ b/userland/capsule_linux/src/linux/guest/timer.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 object behind a timerfd. +//! +//! Like an eventfd's counter it is the object, not the descriptor: dup, fork +//! and every thread reach one timer through their own descriptors, so it is +//! kept with the family's shared objects and a descriptor names it by index. +//! Times are on the guest's monotonic clock, in milliseconds. + +#[derive(Clone, Copy, Default)] +pub struct Timer { + /// When it next fires; zero while disarmed. + pub due: u64, + /// How often it fires after that; zero for once. + pub every: u64, + /// The clock it was made on, which an absolute time is read against. + pub clock: u64, +} + +impl Timer { + pub fn fired(&self, now: u64) -> bool { + self.due != 0 && now >= self.due + } + + /// How many times it has fired by `now`, moving it past them: a + /// one-shot timer disarms, a periodic one steps to its next time. + pub fn take(&mut self, now: u64) -> u64 { + if !self.fired(now) { + return 0; + } + if self.every == 0 { + self.due = 0; + return 1; + } + let times = 1 + (now - self.due) / self.every; + self.due += times * self.every; + times + } +} diff --git a/userland/capsule_linux/src/linux/guest/watch.rs b/userland/capsule_linux/src/linux/guest/watch.rs new file mode 100644 index 0000000000..48c5ce4b0e --- /dev/null +++ b/userland/capsule_linux/src/linux/guest/watch.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 . + +//! One entry of an epoll interest list. + +/// Report a readiness once as it rises, not for as long as it holds. +pub const EPOLLET: u32 = 1 << 31; +/// Report once, then nothing until the entry is modified. +pub const EPOLLONESHOT: u32 = 1 << 30; + +#[derive(Clone, Copy)] +pub struct Watch { + pub fd: u64, + /// What the program asked for, EPOLLET and EPOLLONESHOT included. + pub events: u32, + /// The token the program gets back, its own and never interpreted. + pub data: u64, + /// The readiness seen at the last look. Under EPOLLET only what was not + /// already in it is reported. + pub fired: u32, + /// Cleared once an EPOLLONESHOT entry has reported. + pub armed: bool, +} + +impl Watch { + pub fn new(fd: u64, events: u32, data: u64) -> Watch { + Watch { fd, events, data, fired: 0, armed: true } + } +} diff --git a/userland/capsule_linux/src/linux/heap.rs b/userland/capsule_linux/src/linux/heap.rs new file mode 100644 index 0000000000..b669aa9443 --- /dev/null +++ b/userland/capsule_linux/src/linux/heap.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 much memory this process takes, decided from its arguments before +//! anything is allocated. + +use nonos_libc::{heap_init, heap_init_sized, mk_args}; + +/// An install holds a distribution's index while it resolves a closure. +/// Kali's main is 21 MB fetched and 85 MB inflated, parsed into records +/// beside it; Alpine's is a few. A run takes the default. +const INSTALL_HEAP: usize = 320 << 20; + +pub fn init() { + let mut buf = [0u8; 256]; + let n = mk_args(buf.as_mut_ptr(), buf.len()); + if n > 0 && buf.starts_with(b"install\0") { + if heap_init_sized(INSTALL_HEAP).is_ok() { + return; + } + // Alpine's index still fits the default; a larger one fails where + // it is read, with that reason, instead of here without one. + let line = b"[LINUX] no room for a large index, installing in the default heap\n"; + let _ = nonos_libc::mk_debug(line.as_ptr(), line.len()); + } + let _ = heap_init(); +} diff --git a/userland/capsule_linux/src/linux/image/interp_ld.rs b/userland/capsule_linux/src/linux/image/interp_ld.rs index 2c30e7fc35..96be792e85 100644 --- a/userland/capsule_linux/src/linux/image/interp_ld.rs +++ b/userland/capsule_linux/src/linux/image/interp_ld.rs @@ -29,10 +29,19 @@ const MAX_IMAGE: u32 = 64 << 20; /// Where the interpreter's entry point ended up. pub(super) fn place(guest: &mut Guest, path: &[u8]) -> Result { - // Already confined: the path came out of the guest's own image. - let at = visible(b"/", path); + // Confined by `visible`, and followed through the guest's links: Debian + // names its loader by /lib64, a link into /usr. The loader is proved by + // the path it resolves to, never the link's. + let at = guest.links.follow(visible(b"/", path), true); let raw: Vec = store_read(&key(&at), MAX_IMAGE).map_err(|_| LoadError::Interp)?; - if crate::linux::attest::verify(&at, &raw).is_err() { + if let Err(why) = crate::linux::attest::verify(&at, &raw) { + // Which interpreter, and whether its proof was missing or refused. + let mut line = alloc::vec::Vec::from(&b"[LINUX] interpreter "[..]); + line.extend_from_slice(&at); + line.extend_from_slice(b": "); + line.extend_from_slice(why.as_bytes()); + line.push(b'\n'); + let _ = nonos_libc::mk_debug(line.as_ptr(), line.len()); return Err(LoadError::Unproven); } let ld = load_at(guest, &raw, INTERP_BASE).map_err(|_| LoadError::Interp)?; 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; 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/auth/checksum.rs b/userland/capsule_linux/src/linux/install/auth/checksum.rs new file mode 100644 index 0000000000..e4d0a175f8 --- /dev/null +++ b/userland/capsule_linux/src/linux/install/auth/checksum.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 `C:` field of an index record: the SHA-1 of a package's control +//! member, written `Q1` and then base64. + +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..8ab9321289 --- /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. + +pub(in crate::linux::install) 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..7e678cf9dd --- /dev/null +++ b/userland/capsule_linux/src/linux/install/auth/package.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 . + +//! A downloaded package, checked against the index record that named it. + +use alloc::vec::Vec; + +use nonos_inflate::members_within; + +use super::super::unpacked::MAX_INFLATED; +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_within(apk, MAX_INFLATED)?; + 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..a7317328bc --- /dev/null +++ b/userland/capsule_linux/src/linux/install/auth/signature.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 signature an index carries in its first member. + +use alloc::vec::Vec; + +use nonos_inflate::members_within; + +use super::super::tar::{entries, Entry}; +use super::super::unpacked::MAX_INFLATED; +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_within(raw, MAX_INFLATED)?; + 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..b7a696830b --- /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` and pacman's +/// `fetch` make one, so bytes nothing authenticated cannot be unpacked. +pub struct Verified { + files: Vec, +} + +impl Verified { + pub(crate) fn checked(files: Vec) -> Self { + Self { files } + } + + pub fn files(&self) -> &[u8] { + &self.files + } +} diff --git a/userland/capsule_linux/src/linux/install/deb/ar.rs b/userland/capsule_linux/src/linux/install/deb/ar.rs new file mode 100644 index 0000000000..7c0338d223 --- /dev/null +++ b/userland/capsule_linux/src/linux/install/deb/ar.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 ar archive a .deb is: a magic line, then members each behind a +//! 60-byte header, padded to an even offset. + +use alloc::vec::Vec; + +const MAGIC: &[u8] = b"!\n"; +const HEADER: usize = 60; + +/// Every member's name and bytes, or None if any header is malformed. +pub fn members(d: &[u8]) -> Option> { + let mut at = MAGIC.len(); + if !d.starts_with(MAGIC) { + return None; + } + let mut out = Vec::new(); + while at < d.len() { + let h = d.get(at..at + HEADER)?; + if &h[58..60] != b"`\n" { + return None; + } + let name = trim(&h[..16]); + let name = name.strip_suffix(b"/").unwrap_or(name); + let size: usize = core::str::from_utf8(trim(&h[48..58])).ok()?.parse().ok()?; + let end = (at + HEADER).checked_add(size)?; + out.push((name, d.get(at + HEADER..end)?)); + at = end + (size & 1); + } + Some(out) +} + +fn trim(field: &[u8]) -> &[u8] { + let end = field.iter().rposition(|&b| b != b' ').map_or(0, |i| i + 1); + &field[..end] +} diff --git a/userland/capsule_linux/src/linux/install/deb/fetch.rs b/userland/capsule_linux/src/linux/install/deb/fetch.rs new file mode 100644 index 0000000000..8bbde709f5 --- /dev/null +++ b/userland/capsule_linux/src/linux/install/deb/fetch.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 . + +//! One .deb: held to the market's pin when it is the one chosen and to the +//! SHA-256 the signed index gives it, then its data member unpacked. +//! Maintainer scripts in the control member are never run. + +use super::ar::members; +use super::packages::Record; +use super::source::Source; +use crate::linux::install::auth::Verified; +use crate::linux::install::unpacked::unpacked; + +pub fn fetch(src: &Source, r: &Record, pin: Option<&[u8; 32]>) -> Option { + let deb = src.get(&r.filename)?; + if pin.is_some_and(|want| blake3::hash(&deb).as_bytes() != want) { + return refuse(b"[LINUX] package is not the one the market listed\n"); + } + if Some(nonos_hash::sha256(&deb)) != r.sha256 { + return refuse(b"[LINUX] package does not match its signed index\n"); + } + let parts = members(&deb)?; + if parts.first() != Some(&(b"debian-binary".as_slice(), b"2.0\n".as_slice())) { + return refuse(b"[LINUX] refused: not a version 2.0 .deb\n"); + } + let (_, data) = parts.iter().find(|(name, _)| name.starts_with(b"data.tar"))?; + Some(Verified::checked(unpacked(data)?)) +} + +fn refuse(line: &[u8]) -> Option { + let _ = nonos_libc::mk_debug(line.as_ptr(), line.len()); + None +} diff --git a/userland/capsule_linux/src/linux/install/deb/fields.rs b/userland/capsule_linux/src/linux/install/deb/fields.rs new file mode 100644 index 0000000000..0cb043ed0c --- /dev/null +++ b/userland/capsule_linux/src/linux/install/deb/fields.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 . + +//! Dependency lists and pool paths, as a Packages stanza writes them. + +use alloc::string::String; +use alloc::vec::Vec; + +/// `libc6 (>= 2.34), libpcap0.8 | libpcap0.8t64, python3:any` as groups of +/// bare names. +pub fn needs(v: &str) -> Vec> { + let name = |t: &str| String::from(t.trim().split([' ', '(', ':', '[']).next().unwrap_or("")); + v.split(',') + .map(|g| g.split('|').map(name).filter(|n| !n.is_empty()).collect::>()) + .filter(|g| !g.is_empty()) + .collect() +} + +/// A pool path that stays under the mirror's root. +pub fn safe(path: &str) -> bool { + let chars = path.bytes().all(|b| b.is_ascii_alphanumeric() || b"._+~-/:%".contains(&b)); + let parts = path.split('/').all(|c| !c.is_empty() && c != "." && c != ".."); + chars && parts && path.ends_with(".deb") +} diff --git a/userland/capsule_linux/src/linux/install/deb/index.rs b/userland/capsule_linux/src/linux/install/deb/index.rs new file mode 100644 index 0000000000..c430a0ee04 --- /dev/null +++ b/userland/capsule_linux/src/linux/install/deb/index.rs @@ -0,0 +1,74 @@ +// 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 suite's index: its Release verified against the pinned keyring, then +//! each component's Packages file held to the checksum the Release gives it. + +use alloc::format; +use alloc::string::String; +use alloc::vec::Vec; + +use nonos_openpgp::{dearmor, Key}; + +use super::packages::{stanzas, Record}; +use super::release::sums; +use super::source::{components, Source}; +use crate::linux::install::pgp::signed; +use crate::linux::install::unpacked::decompressed; + +const ARCH: &str = "binary-amd64"; +const LISTS: [&str; 2] = ["Packages.xz", "Packages.gz"]; + +/// None if the Release does not verify, or a Packages file does not match it. +pub fn load(src: &Source, ring: &[Key]) -> Option> { + let release = src.get(&format!("dists/{}/Release", src.suite))?; + let sig = src.get(&format!("dists/{}/Release.gpg", src.suite))?; + let sig = dearmor(&sig).unwrap_or(sig); + if !signed(ring, &sig, &release) { + return None; + } + let sums = sums(&String::from_utf8_lossy(&release)); + let mut out = Vec::new(); + for comp in components() { + let Some((path, sum)) = LISTS.iter().find_map(|l| { + let path = format!("{comp}/{ARCH}/{l}"); + sums.iter().find(|s| s.path == path).map(|s| (path, s)) + }) else { + continue; + }; + let raw = src.get(&format!("dists/{}/{path}", src.suite))?; + if raw.len() != sum.size || nonos_hash::sha256(&raw) != sum.sha256 { + say(b"[LINUX] refused: a Packages file does not match its Release\n"); + return None; + } + let text = decompressed(&raw)?; + // The compressed bytes are checked and spent; free them before the + // parse holds records beside the inflated text. + drop(raw); + out.extend(stanzas(&String::from_utf8_lossy(&text))); + } + (!out.is_empty()).then_some(out) +} + +fn say(line: &[u8]) { + let _ = nonos_libc::mk_debug(line.as_ptr(), line.len()); +} + +/// A package by its own name first, then by a name it provides. +pub fn find<'a>(index: &'a [Record], want: &str) -> Option<&'a Record> { + let named = index.iter().find(|r| r.name == want); + named.or_else(|| index.iter().find(|r| r.provides.iter().any(|p| p == want))) +} diff --git a/userland/capsule_linux/src/linux/install/deb/keyring.rs b/userland/capsule_linux/src/linux/install/deb/keyring.rs new file mode 100644 index 0000000000..4dd3f992c0 --- /dev/null +++ b/userland/capsule_linux/src/linux/install/deb/keyring.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 . + +//! The archive keys a Release must be signed by, pinned into this capsule +//! when it is built from the file NONOS_DEB_KEYRING names, armored as +//! Debian and Kali publish them or binary. + +use alloc::vec::Vec; + +use nonos_openpgp::{dearmor, keys, Key}; + +const RING: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/deb-keyring.gpg")); + +/// None when the image was built without one; nothing then verifies. +pub fn pinned() -> Option> { + match RING.starts_with(b"-----BEGIN") { + true => keys(&dearmor(RING)?), + false => keys(RING), + } +} diff --git a/userland/capsule_linux/src/linux/install/deb/merged_usr.rs b/userland/capsule_linux/src/linux/install/deb/merged_usr.rs new file mode 100644 index 0000000000..5b3069cb1e --- /dev/null +++ b/userland/capsule_linux/src/linux/install/deb/merged_usr.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 . + +//! The merged-/usr layout every Debian system has. /lib64, /lib, /bin and +//! /sbin are links into /usr, and a program's interpreter is named through +//! one (jq asks for /lib64/ld-linux-x86-64.so.2, which libc6 ships under +//! /usr). base-files lays these links down on a real system, and it is in no +//! program's dependency closure, so an install lays them down itself. + +use alloc::vec::Vec; + +use crate::linux::install::place_links::record; + +const MERGED: [(&[u8], &[u8]); 4] = [ + (b"/bin", b"usr/bin"), + (b"/sbin", b"usr/sbin"), + (b"/lib", b"usr/lib"), + (b"/lib64", b"usr/lib64"), +]; + +/// Add the links the tree does not have yet; one it already has is kept. +pub fn lay_out() -> Option { + let links: Vec<(Vec, Vec)> = + MERGED.iter().map(|(from, to)| (from.to_vec(), to.to_vec())).collect(); + record(&links) +} diff --git a/userland/capsule_linux/src/linux/install/deb/mod.rs b/userland/capsule_linux/src/linux/install/deb/mod.rs new file mode 100644 index 0000000000..a4662e1dbc --- /dev/null +++ b/userland/capsule_linux/src/linux/install/deb/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 . + +//! Debian's package format, as Kali publishes it: a signed Release, the +//! Packages files it vouches for, and .debs each held to its checksum there. + +mod ar; +mod fetch; +mod fields; +mod index; +mod keyring; +mod merged_usr; +mod packages; +mod release; +mod run; +mod source; + +pub use run::install; diff --git a/userland/capsule_linux/src/linux/install/deb/packages.rs b/userland/capsule_linux/src/linux/install/deb/packages.rs new file mode 100644 index 0000000000..1356269456 --- /dev/null +++ b/userland/capsule_linux/src/linux/install/deb/packages.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 . + +//! A Packages index: one stanza per package, `Field: value` lines with +//! continuation lines indented, a blank line between stanzas. + +use alloc::string::String; +use alloc::vec::Vec; + +use super::super::hex::hex32; +use super::fields::{needs, safe}; + +#[derive(Default, Clone)] +pub struct Record { + pub name: String, + pub version: String, + /// Relative to the mirror's root, as `pool/main/...`. + pub filename: String, + pub sha256: Option<[u8; 32]>, + /// Each need is a group of alternatives, any one of which will do. + pub depends: Vec>, + pub provides: Vec, +} + +/// Every installable stanza. One without a name, version, safe file name or +/// checksum is dropped rather than half-used. +pub fn stanzas(text: &str) -> Vec { + let mut out = Vec::new(); + for stanza in text.split("\n\n") { + let mut r = Record::default(); + // Continuation lines are skipped: no field read here spans lines. + for line in stanza.lines().filter(|l| !l.starts_with([' ', '\t'])) { + let Some((field, value)) = line.split_once(':') else { + continue; + }; + let value = value.trim(); + match field { + "Package" => r.name = String::from(value), + "Version" => r.version = String::from(value), + "Filename" => r.filename = String::from(value), + "SHA256" => r.sha256 = hex32(value), + "Depends" | "Pre-Depends" => r.depends.extend(needs(value)), + "Provides" => r.provides.extend(needs(value).into_iter().flatten()), + _ => {} + } + } + if !r.name.is_empty() && !r.version.is_empty() && safe(&r.filename) && r.sha256.is_some() { + out.push(r); + } + } + out +} diff --git a/userland/capsule_linux/src/linux/install/deb/release.rs b/userland/capsule_linux/src/linux/install/deb/release.rs new file mode 100644 index 0000000000..1deda434d3 --- /dev/null +++ b/userland/capsule_linux/src/linux/install/deb/release.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 . + +//! A suite's Release file: the SHA-256 of every index it vouches for. +//! +//! Valid-Until is not checked: this machine has no clock it trusts for it, +//! so a replayed older Release is not caught here. It is said, not hidden. + +use alloc::string::String; +use alloc::vec::Vec; + +use super::super::hex::hex32; + +pub struct Sum { + pub sha256: [u8; 32], + pub size: usize, + pub path: String, +} + +pub fn sums(text: &str) -> Vec { + let mut out = Vec::new(); + let mut in_sha = false; + for line in text.lines() { + if !line.starts_with(' ') { + in_sha = line.trim_end() == "SHA256:"; + continue; + } + let f: Vec<&str> = line.split_whitespace().collect(); + if let (true, [hex, size, path]) = (in_sha, f.as_slice()) { + if let (Some(sha256), Ok(size)) = (hex32(hex), size.parse()) { + out.push(Sum { sha256, size, path: String::from(*path) }); + } + } + } + out +} diff --git a/userland/capsule_linux/src/linux/install/deb/run.rs b/userland/capsule_linux/src/linux/install/deb/run.rs new file mode 100644 index 0000000000..ea893ae8d4 --- /dev/null +++ b/userland/capsule_linux/src/linux/install/deb/run.rs @@ -0,0 +1,74 @@ +// 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 . + +//! `install deb:`: the package and everything it depends on, taking +//! the first alternative of each need the index has. + +use alloc::string::String; +use alloc::vec; +use alloc::vec::Vec; + +use super::fetch::fetch; +use super::index::{find, load}; +use super::keyring::pinned; +use super::packages::Record; +use super::source::source; +use crate::linux::install::limit::max_packages; +use crate::linux::install::place::unpack; +use crate::linux::install::Why; + +pub fn install(name: &str, pin: &[u8; 32]) -> Result<(), Why> { + let Some(src) = source() else { + return say(b"[LINUX] this image was built without a Debian mirror\n", Why::NoMirror); + }; + let Some(ring) = pinned() else { + return say(b"[LINUX] this image pins no Debian archive key\n", Why::NoKeyring); + }; + let Some(index) = load(&src, &ring) else { + return say(b"[LINUX] no verified Debian index\n", Why::Index); + }; + let _ = super::merged_usr::lay_out(); + let (max, mut done): (usize, Vec) = (max_packages(), Vec::new()); + let mut wanted: Vec> = vec![vec![String::from(name)]]; + while let Some(group) = wanted.pop() { + let Some(rec): Option<&Record> = group.iter().find_map(|n| find(&index, n)) else { + return say(b"[LINUX] nothing provides it\n", Why::NotProvided); + }; + if done.contains(&rec.name) { + continue; + } + if done.len() == max { + return say( + alloc::format!("[LINUX] refused: closure passes {max} packages\n").as_bytes(), + Why::TooLarge, + ); + } + let chosen = rec.name == name; + let Some(files) = fetch(&src, rec, chosen.then_some(pin)) else { + return Err(Why::Package); + }; + unpack(&files, chosen.then_some(name)); + done.push(rec.name.clone()); + wanted.extend(rec.depends.iter().cloned()); + } + Ok(()) +} + +/// Log a refusal and name it. +fn say(line: &[u8], why: Why) -> Result<(), Why> { + let _ = nonos_libc::mk_debug(line.as_ptr(), line.len()); + Err(why) +} diff --git a/userland/capsule_linux/src/linux/install/deb/source.rs b/userland/capsule_linux/src/linux/install/deb/source.rs new file mode 100644 index 0000000000..2ef7d8d9c4 --- /dev/null +++ b/userland/capsule_linux/src/linux/install/deb/source.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 . + +//! Where Debian packages come from, fixed when the image is built. An image +//! built without a mirror has none: no address or suite is guessed. +//! +//! NONOS_DEB_MIRROR is a.b.c.d:port, NONOS_DEB_HOST the Host line, +//! NONOS_DEB_ROOT the archive's path (`/kali`), NONOS_DEB_SUITE the suite +//! (`kali-rolling`), NONOS_DEB_COMPONENTS the components, comma-separated. + +use alloc::format; +use alloc::vec::Vec; + +use super::super::http::get_as; + +pub struct Source { + ip: &'static str, + port: u16, + host: &'static str, + root: &'static str, + pub suite: &'static str, +} + +pub fn source() -> Option { + let at = option_env!("NONOS_DEB_MIRROR")?; + let (ip, port) = at.rsplit_once(':').unwrap_or((at, "80")); + let host = option_env!("NONOS_DEB_HOST")?; + let (root, suite) = (option_env!("NONOS_DEB_ROOT")?, option_env!("NONOS_DEB_SUITE")?); + Some(Source { ip, port: port.parse().ok()?, host, root, suite }) +} + +pub fn components() -> impl Iterator { + option_env!("NONOS_DEB_COMPONENTS").unwrap_or("main").split(',').filter(|c| !c.is_empty()) +} + +impl Source { + /// A path under the archive root: `dists/...` or a pool file name. + pub fn get(&self, path: &str) -> Option> { + get_as(self.ip, self.port, self.host, &format!("{}/{path}", self.root)) + } +} 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/family.rs b/userland/capsule_linux/src/linux/install/family.rs new file mode 100644 index 0000000000..7ce463d5ab --- /dev/null +++ b/userland/capsule_linux/src/linux/install/family.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 . + +//! Which distribution an install is for. A family is named, never inferred +//! from the package: `pacman:` and `deb:` say so, and a bare name is Alpine's. +//! The name was split when the process started (`file::family::choose`). + +use crate::linux::file::family::{chosen, Family}; + +pub fn install(pkg: &str, pin: &[u8; 32]) -> Result<(), super::Why> { + match chosen() { + Family::Pacman => super::pacman::install(pkg, pin), + Family::Debian => super::deb::install(pkg, pin), + Family::Alpine => super::run::install(pkg, pin), + } +} 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/hex.rs b/userland/capsule_linux/src/linux/install/hex.rs new file mode 100644 index 0000000000..93ffa2d80a --- /dev/null +++ b/userland/capsule_linux/src/linux/install/hex.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 . + +//! A SHA-256 written as 64 hex digits, either case. + +pub fn hex32(s: &str) -> Option<[u8; 32]> { + let b = s.as_bytes(); + if b.len() != 64 { + return None; + } + let digit = |c: u8| (c as char).to_digit(16).map(|d| d as u8); + let mut out = [0u8; 32]; + for (i, o) in out.iter_mut().enumerate() { + *o = digit(b[2 * i])? << 4 | digit(b[2 * i + 1])?; + } + Some(out) +} diff --git a/userland/capsule_linux/src/linux/install/http.rs b/userland/capsule_linux/src/linux/install/http.rs index 795fccfe5d..0e609e0b02 100644 --- a/userland/capsule_linux/src/linux/install/http.rs +++ b/userland/capsule_linux/src/linux/install/http.rs @@ -16,46 +16,48 @@ //! A GET, over the socket service this capsule already uses. +use alloc::format; use alloc::vec::Vec; -use alloc::{format, string::String}; -use crate::linux::net::raw::{connect_host, open_stream}; -use crate::linux::net::raw_io::{close, recv_all, send_all}; +use super::http_reply::{body, complete}; +use super::mirror::HOST_LINE; +use crate::linux::net::raw::{connect_host, open_stream_to}; +use crate::linux::net::raw_io::{close, recv_until, send_all}; /// Enough for the largest package index; a reply beyond it is refused /// rather than truncated into a half-parsed index. const MAX_BODY: usize = 64 << 20; +/// How long a mirror may go silent: one fetching upstream before it answers +/// sends nothing for a while, and a slow link for longer under emulation. +const IDLE_MS: u64 = 120_000; -pub fn get(host: &str, port: u16, path: &str) -> Option> { - let handle = open_stream()?; - if connect_host(handle, host, port).is_none() { +/// A GET to Alpine's mirror. +pub fn get(ip: &str, port: u16, path: &str) -> Option> { + get_as(ip, port, HOST_LINE, path) +} + +/// A GET to `ip`, which must be a dotted IPv4 address: a name here would be +/// resolved by the socket service, in the clear. `host` is only the Host line. +pub fn get_as(ip: &str, port: u16, host: &str, path: &str) -> Option> { + if ip.split('.').filter(|o| o.parse::().is_ok()).count() != 4 { + return None; + } + let handle = open_stream_to(ip)?; + 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" ); - if send_all(handle, req.as_bytes()).is_none() { - close(handle); - return None; - } - let raw = recv_all(handle, MAX_BODY); + let sent = send_all(handle, req.as_bytes()); + let raw = sent.and_then(|()| recv_until(handle, MAX_BODY, &complete, IDLE_MS)); close(handle); - body(&raw?) -} - -/// The bytes after the header block. A reply whose status is not 200 is -/// nothing: an error page parsed as a package is the worst outcome here. -fn body(raw: &[u8]) -> Option> { - let head_end = find(raw, b"\r\n\r\n")? + 4; - let head = String::from_utf8_lossy(&raw[..head_end]); - let first = head.lines().next()?; - if !first.contains(" 200 ") { - return None; - } - Some(raw[head_end..].to_vec()) -} - -fn find(hay: &[u8], needle: &[u8]) -> Option { - hay.windows(needle.len()).position(|w| w == needle) + let got = raw.and_then(body); + let line = match &got { + Some(b) => format!("[LINUX] mirror {ip}: {path}, {} bytes\n", b.len()), + None => format!("[LINUX] mirror {ip}: GET {path} gave no whole 200 reply\n"), + }; + let _ = nonos_libc::mk_debug(line.as_ptr(), line.len()); + got } diff --git a/userland/capsule_linux/src/linux/install/http_reply.rs b/userland/capsule_linux/src/linux/install/http_reply.rs new file mode 100644 index 0000000000..f32f744f24 --- /dev/null +++ b/userland/capsule_linux/src/linux/install/http_reply.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 . + +//! An HTTP/1.1 reply's framing: where the header ends, how long the body +//! says it is, and whether all of it has arrived. A socket read that returns +//! nothing means "not yet", not "done", so the length is what ends a read. + +use alloc::vec::Vec; + +/// The header's end and the body length it states, once the header is in. +pub fn framing(reply: &[u8]) -> Option<(usize, Option)> { + let end = reply.windows(4).position(|w| w == b"\r\n\r\n")? + 4; + let length = reply[..end].split(|b| *b == b'\n').find_map(|line| { + let (name, value) = line.split_at(line.iter().position(|b| *b == b':')?); + let value = core::str::from_utf8(&value[1..]).ok()?.trim(); + name.eq_ignore_ascii_case(b"content-length").then(|| value.parse().ok())? + }); + Some((end, length)) +} + +/// Every byte the header promised has arrived. +pub fn complete(reply: &[u8]) -> bool { + match framing(reply) { + Some((end, Some(len))) => end.checked_add(len).is_some_and(|want| reply.len() >= want), + _ => false, + } +} + +/// The body of a 200 reply, cut to its stated length, in place. A reply that +/// is not a 200, or whose body stops short of its length, is nothing: an +/// error page or a truncated index parsed as a package is the worst outcome. +pub fn body(mut raw: Vec) -> Option> { + let (end, length) = framing(&raw)?; + // The code is the status line's second word, exactly: "HTTP/1.1 500 x + // 200" is a 500. + let status = raw[..end].split(|b| *b == b'\n').next()?; + if status.split(|b| *b == b' ' || *b == b'\r').nth(1) != Some(b"200") { + return None; + } + if let Some(len) = length { + let want = end.checked_add(len)?; + if raw.len() < want { + return None; + } + raw.truncate(want); + } + raw.drain(..end); + Some(raw) +} diff --git a/userland/capsule_linux/src/linux/install/index.rs b/userland/capsule_linux/src/linux/install/index.rs index 37118db325..7b91a592da 100644 --- a/userland/capsule_linux/src/linux/install/index.rs +++ b/userland/capsule_linux/src/linux/install/index.rs @@ -16,54 +16,60 @@ //! The distribution's package index, as this capsule needs it. +use super::pkg::bare; +pub use super::pkg::Pkg; use alloc::string::String; use alloc::vec::Vec; -pub struct Pkg { - pub name: String, - pub version: String, -} - 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 name, mut version) = (String::new(), String::new()); - 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() { - Some(b'P') => name = String::from(&line[2..]), - Some(b'V') => { - version = String::from(&line[2..]); - names - .push((name.clone(), Pkg { name: name.clone(), version: version.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)); - } - } - } + 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), + 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/index_load.rs b/userland/capsule_linux/src/linux/install/index_load.rs index 46bc16dd54..2bb03474c4 100644 --- a/userland/capsule_linux/src/linux/install/index_load.rs +++ b/userland/capsule_linux/src/linux/install/index_load.rs @@ -19,17 +19,28 @@ 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}; +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 plain = nonos_inflate::gunzip(&raw)?; + 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 + * 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 +49,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/limit.rs b/userland/capsule_linux/src/linux/install/limit.rs new file mode 100644 index 0000000000..b07b460da9 --- /dev/null +++ b/userland/capsule_linux/src/linux/install/limit.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 . + +//! How many packages one install may bring in. +//! +//! A bound, because a closure that names half a distribution is an index +//! gone wrong, not a choice. Large, because a tool group does pull hundreds: +//! a BlackArch group is several hundred packages. An image may set its own +//! in /etc/nonos-install-max, and never above the ceiling. + +use crate::linux::file::{key, store_read}; + +const DEFAULT: usize = 1024; +const CEILING: usize = 4096; +const FILE: &[u8] = b"/etc/nonos-install-max"; + +pub(super) fn max_packages() -> usize { + let Ok(raw) = store_read(&key(FILE), 16) else { + return DEFAULT; + }; + let text = raw.split(|b| b.is_ascii_whitespace()).next().unwrap_or(&[]); + let parsed = text.iter().try_fold(0usize, |v, b| match b { + b'0'..=b'9' => v.checked_mul(10)?.checked_add((b - b'0') as usize), + _ => None, + }); + match parsed { + Some(n) if n > 0 => n.min(CEILING), + _ => DEFAULT, + } +} 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 c92a6de470..8adb5c8cd1 100644 --- a/userland/capsule_linux/src/linux/install/mod.rs +++ b/userland/capsule_linux/src/linux/install/mod.rs @@ -16,16 +16,36 @@ //! Installing a Linux program from within the system. +mod auth; +mod deb; mod download; mod enrol; +mod family; +mod fetch; +mod hex; mod http; +mod http_reply; mod index; mod index_load; +mod limit; +mod mirror; +mod pacman; +mod pgp; +mod pkg; mod place; mod place_entry; -mod provenance; +mod place_links; +mod place_report; +mod program; mod run; mod tar; mod tar_field; +mod tar_kind; +mod tar_pax; +mod tar_path; +mod unpacked; +mod why; -pub use run::install; +pub use program::recorded; +pub use family::install; +pub use why::Why; diff --git a/userland/capsule_linux/src/linux/install/pacman/db.rs b/userland/capsule_linux/src/linux/install/pacman/db.rs new file mode 100644 index 0000000000..629e029b97 --- /dev/null +++ b/userland/capsule_linux/src/linux/install/pacman/db.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 . + +//! The repository databases: each fetched with its signature, verified, +//! then read for its `desc` records. + +use alloc::format; +use alloc::string::String; +use alloc::vec::Vec; + +use nonos_openpgp::Key; + +use super::desc::{records, Record}; +use crate::linux::install::pgp::signed; +use super::source::{repos, Source}; +use crate::linux::install::unpacked::unpacked; +use crate::linux::install::tar::entries; + +pub struct Db { + /// Each record, with the repository it came from. + pub records: Vec<(Record, &'static str)>, +} + +/// An unsigned database could steer a dependency to some other file the +/// same key signed, an older one say; so a database without a verifying +/// signature refuses the whole install. +pub fn load(src: &Source, ring: &[Key]) -> Option { + let mut db = Db { records: Vec::new() }; + for repo in repos() { + let raw = src.get(repo, &format!("{repo}.db"))?; + let Some(sig) = src.get(repo, &format!("{repo}.db.sig")) else { + say(b"[LINUX] refused: the pacman database is not signed\n"); + return None; + }; + if !signed(ring, &sig, &raw) { + return None; + } + for e in entries(&unpacked(&raw)?) { + if e.name.ends_with(b"/desc") { + let text = String::from_utf8_lossy(&e.body); + db.records.extend(records(&text).map(|r| (r, repo))); + } + } + } + (!db.records.is_empty()).then_some(db) +} + +impl Db { + /// A package by its own name first, then by a name it provides. + pub fn find(&self, want: &str) -> Option<&(Record, &'static str)> { + let named = self.records.iter().find(|(r, _)| r.name == want); + named.or_else(|| self.records.iter().find(|(r, _)| r.provides.iter().any(|p| p == want))) + } +} + +fn say(line: &[u8]) { + let _ = nonos_libc::mk_debug(line.as_ptr(), line.len()); +} diff --git a/userland/capsule_linux/src/linux/install/pacman/desc.rs b/userland/capsule_linux/src/linux/install/pacman/desc.rs new file mode 100644 index 0000000000..794c5c7a26 --- /dev/null +++ b/userland/capsule_linux/src/linux/install/pacman/desc.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 . + +//! A repository database's `desc` records: `%FIELD%` on a line, its values +//! on the lines after, a blank line ending it. + +use alloc::string::String; +use alloc::vec::Vec; + +use super::super::hex::hex32; + +#[derive(Default, Clone)] +pub struct Record { + pub name: String, + pub version: String, + pub filename: String, + pub sha256: Option<[u8; 32]>, + /// The detached signature, base64 as the database writes it. + pub pgpsig: String, + /// Names needed, version constraints dropped. + pub depends: Vec, + pub provides: Vec, +} + +/// One `desc` file. A record without a name, version, file or checksum is not +/// installable and is dropped rather than half-used. +pub fn records(text: &str) -> Option { + let mut r = Record::default(); + let mut field = ""; + for line in text.lines() { + if let Some(f) = line.strip_prefix('%').and_then(|l| l.strip_suffix('%')) { + field = f; + continue; + } + if line.is_empty() { + field = ""; + continue; + } + match field { + "NAME" => r.name = String::from(line), + "VERSION" => r.version = String::from(line), + "FILENAME" => r.filename = String::from(line), + "SHA256SUM" => r.sha256 = hex32(line), + "PGPSIG" => r.pgpsig.push_str(line), + "DEPENDS" => r.depends.push(bare(line)), + "PROVIDES" => r.provides.push(bare(line)), + _ => {} + } + } + let whole = !r.name.is_empty() && !r.version.is_empty() && !r.filename.is_empty(); + // A file name that could leave the repository directory is refused. + let safe = !r.filename.contains('/') && r.filename != ".." && r.filename != "."; + (whole && safe && r.sha256.is_some()).then_some(r) +} + +/// `glibc>=2.35` and `libfoo.so=1-64` name `glibc` and `libfoo.so`. +fn bare(dep: &str) -> String { + String::from(dep.split(['<', '>', '=']).next().unwrap_or(dep)) +} diff --git a/userland/capsule_linux/src/linux/install/pacman/fetch.rs b/userland/capsule_linux/src/linux/install/pacman/fetch.rs new file mode 100644 index 0000000000..2351b1a5a5 --- /dev/null +++ b/userland/capsule_linux/src/linux/install/pacman/fetch.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 . + +//! One pacman package: its bytes held to the market's pin when it is the +//! one chosen, to the database's SHA-256, and to its own signature. + +use alloc::format; +use alloc::vec::Vec; + +use nonos_openpgp::Key; + +use super::desc::Record; +use crate::linux::install::pgp::signed; +use super::source::Source; +use crate::linux::install::unpacked::unpacked; +use crate::linux::install::auth::{base64, Verified}; + +pub fn fetch( + src: &Source, + ring: &[Key], + repo: &str, + r: &Record, + pin: Option<&[u8; 32]>, +) -> Option { + let pkg = src.get(repo, &r.filename)?; + if pin.is_some_and(|want| blake3::hash(&pkg).as_bytes() != want) { + return refuse(b"[LINUX] package is not the one the market listed\n"); + } + if Some(nonos_hash::sha256(&pkg)) != r.sha256 { + return refuse(b"[LINUX] package does not match its database record\n"); + } + // The database may carry the signature; a mirror serves it beside the file. + let sig: Vec = match r.pgpsig.is_empty() { + false => base64::decode(r.pgpsig.as_bytes())?, + true => src.get(repo, &format!("{}.sig", r.filename))?, + }; + if !signed(ring, &sig, &pkg) { + return None; + } + Some(Verified::checked(unpacked(&pkg)?)) +} + +fn refuse(line: &[u8]) -> Option { + let _ = nonos_libc::mk_debug(line.as_ptr(), line.len()); + None +} diff --git a/userland/capsule_linux/src/linux/install/pacman/keyring.rs b/userland/capsule_linux/src/linux/install/pacman/keyring.rs new file mode 100644 index 0000000000..0d6bda396c --- /dev/null +++ b/userland/capsule_linux/src/linux/install/pacman/keyring.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 . + +//! The keys pacman packages must be signed by, pinned into this capsule when +//! it is built: the file NONOS_PACMAN_KEYRING names, as `gpg --export` wrote +//! it. The capsule is inside the measured image, so the keyring is too. + +use alloc::vec::Vec; + +use nonos_openpgp::{keys, Key}; + +const RING: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/pacman-keyring.gpg")); + +/// None when the image was built without one; nothing then verifies. +pub fn pinned() -> Option> { + keys(RING) +} diff --git a/userland/capsule_linux/src/linux/install/pacman/mod.rs b/userland/capsule_linux/src/linux/install/pacman/mod.rs new file mode 100644 index 0000000000..ca471dcefb --- /dev/null +++ b/userland/capsule_linux/src/linux/install/pacman/mod.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 . + +//! Arch's package format: a signed repository database, and packages each +//! held to its checksum there and to its own detached signature. + +mod db; +mod desc; +mod fetch; +mod keyring; +mod run; +mod source; + +pub use run::install; diff --git a/userland/capsule_linux/src/linux/install/pacman/run.rs b/userland/capsule_linux/src/linux/install/pacman/run.rs new file mode 100644 index 0000000000..623e780846 --- /dev/null +++ b/userland/capsule_linux/src/linux/install/pacman/run.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 . + +//! `install pacman:`: the package and everything it depends on. + +use alloc::string::String; +use alloc::vec; +use alloc::vec::Vec; + +use super::db::load; +use super::fetch::fetch; +use super::keyring::pinned; +use super::source::source; +use crate::linux::install::limit::max_packages; +use crate::linux::install::place::unpack; +use crate::linux::install::Why; + +pub fn install(name: &str, pin: &[u8; 32]) -> Result<(), Why> { + let Some(src) = source() else { + return say(b"[LINUX] this image was built without a pacman mirror\n", Why::NoMirror); + }; + let Some(ring) = pinned() else { + return say(b"[LINUX] this image pins no pacman keyring\n", Why::NoKeyring); + }; + let Some(db) = load(&src, &ring) else { + return say(b"[LINUX] no verified pacman database\n", Why::Index); + }; + let max = max_packages(); + let mut wanted: Vec = vec![String::from(name)]; + let mut done: Vec = Vec::new(); + while let Some(next) = wanted.pop() { + let Some((rec, repo)) = db.find(&next) else { + return say(b"[LINUX] nothing provides it\n", Why::NotProvided); + }; + if done.contains(&rec.name) { + continue; + } + if done.len() == max { + let line = alloc::format!("[LINUX] refused: closure passes {max} packages\n"); + return say(line.as_bytes(), Why::TooLarge); + } + let chosen = rec.name == name; + let Some(files) = fetch(&src, &ring, repo, rec, chosen.then_some(pin)) else { + return Err(Why::Package); + }; + unpack(&files, chosen.then_some(name)); + done.push(rec.name.clone()); + wanted.extend(rec.depends.iter().cloned()); + } + Ok(()) +} + +/// Log a refusal and name it. +fn say(line: &[u8], why: Why) -> Result<(), Why> { + let _ = nonos_libc::mk_debug(line.as_ptr(), line.len()); + Err(why) +} diff --git a/userland/capsule_linux/src/linux/install/pacman/source.rs b/userland/capsule_linux/src/linux/install/pacman/source.rs new file mode 100644 index 0000000000..7ae670acbe --- /dev/null +++ b/userland/capsule_linux/src/linux/install/pacman/source.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 . + +//! Where pacman packages come from, fixed when the image is built. An image +//! built without a mirror has none: no address or repository is guessed. +//! +//! NONOS_PACMAN_MIRROR is a.b.c.d:port, NONOS_PACMAN_HOST the Host line, +//! NONOS_PACMAN_PATH a path with `{repo}` in it, NONOS_PACMAN_REPOS the +//! repositories to search, comma-separated, in order. + +use alloc::format; +use alloc::vec::Vec; + +use super::super::http::get_as; + +pub struct Source { + ip: &'static str, + port: u16, + host: &'static str, + path: &'static str, +} + +pub fn source() -> Option { + let at = option_env!("NONOS_PACMAN_MIRROR")?; + let (ip, port) = at.rsplit_once(':').unwrap_or((at, "80")); + let host = option_env!("NONOS_PACMAN_HOST")?; + let path = option_env!("NONOS_PACMAN_PATH")?; + Some(Source { ip, port: port.parse().ok()?, host, path }) +} + +pub fn repos() -> impl Iterator { + option_env!("NONOS_PACMAN_REPOS").unwrap_or("").split(',').filter(|r| !r.is_empty()) +} + +impl Source { + pub fn get(&self, repo: &str, file: &str) -> Option> { + let dir = self.path.replace("{repo}", repo); + get_as(self.ip, self.port, self.host, &format!("{dir}/{file}")) + } +} diff --git a/userland/capsule_linux/src/linux/install/pgp/mod.rs b/userland/capsule_linux/src/linux/install/pgp/mod.rs new file mode 100644 index 0000000000..fc87e43da9 --- /dev/null +++ b/userland/capsule_linux/src/linux/install/pgp/mod.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 . + +//! OpenPGP signatures on this machine: the verifier the crypto service +//! backs, and a check that says what it found. pacman and Debian share it. + +mod request; +mod signed; +mod spki; +mod verifier; + +pub use signed::signed; diff --git a/userland/capsule_linux/src/linux/install/pgp/request.rs b/userland/capsule_linux/src/linux/install/pgp/request.rs new file mode 100644 index 0000000000..77e262bc60 --- /dev/null +++ b/userland/capsule_linux/src/linux/install/pgp/request.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 . + +//! An OpenPGP RSA check, put as the crypto service takes it. + +use alloc::vec::Vec; + +use super::spki::rsa_spki; + +/// What the crypto service is asked: its hash number, the key, and the +/// signature at the modulus's width (an MPI drops leading zeros). +pub struct Request { + pub hashid: u8, + pub spki: Vec, + pub sig: Vec, +} + +/// OpenPGP's SHA-256 and SHA-512 are the service's 0 and 2; nothing else. +pub fn request(n: &[u8], e: &[u8], sig: &[u8], hash: u8) -> Option { + let hashid = match hash { + 8 => 0, + 10 => 2, + _ => return None, + }; + let mut full = alloc::vec![0u8; n.len().checked_sub(sig.len())?]; + full.extend_from_slice(sig); + Some(Request { hashid, spki: rsa_spki(n, e)?, sig: full }) +} diff --git a/userland/capsule_linux/src/linux/install/pgp/signed.rs b/userland/capsule_linux/src/linux/install/pgp/signed.rs new file mode 100644 index 0000000000..40f19fe7bb --- /dev/null +++ b/userland/capsule_linux/src/linux/install/pgp/signed.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 . + +//! A detached signature, checked against the pinned keyring, and said. + +use alloc::format; +use alloc::string::String; + +use nonos_openpgp::{verify, Key}; + +use super::verifier::Machine; + +pub fn signed(ring: &[Key], sig: &[u8], data: &[u8]) -> bool { + let (ok, line) = match verify(&Machine, ring, sig, data) { + Ok(v) => (true, format!("[LINUX] signature Verified by {}\n", hex(&v.fingerprint))), + Err(r) => (false, format!("[LINUX] signature refused: {}\n", r.why())), + }; + let _ = nonos_libc::mk_debug(line.as_ptr(), line.len()); + ok +} + +fn hex(b: &[u8]) -> String { + b.iter().map(|x| format!("{x:02X}")).collect() +} diff --git a/userland/capsule_linux/src/linux/install/pgp/spki.rs b/userland/capsule_linux/src/linux/install/pgp/spki.rs new file mode 100644 index 0000000000..9131a06679 --- /dev/null +++ b/userland/capsule_linux/src/linux/install/pgp/spki.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 . + +//! An RSA public key as a SubjectPublicKeyInfo, the form the crypto service +//! takes: SEQUENCE { SEQUENCE { rsaEncryption, NULL }, BIT STRING { +//! SEQUENCE { INTEGER n, INTEGER e } } }. + +use alloc::vec::Vec; + +const RSA_ALGORITHM: [u8; 15] = + [0x30, 0x0D, 0x06, 0x09, 0x2A, 0x86, 0x48, 0x86, 0xF7, 0x0D, 0x01, 0x01, 0x01, 0x05, 0x00]; + +fn tlv(tag: u8, body: &[u8], out: &mut Vec) { + out.push(tag); + let len = body.len(); + match len { + 0..=0x7F => out.push(len as u8), + 0x80..=0xFF => out.extend([0x81, len as u8]), + _ => out.extend([0x82, (len >> 8) as u8, len as u8]), + } + out.extend_from_slice(body); +} + +/// A positive INTEGER: leading zeros dropped, one put back if the top bit is set. +fn integer(v: &[u8], out: &mut Vec) { + let v = &v[v.iter().position(|&b| b != 0).unwrap_or(v.len())..]; + let mut body = Vec::with_capacity(v.len() + 1); + if v.first().is_none_or(|&b| b & 0x80 != 0) { + body.push(0); + } + body.extend_from_slice(v); + tlv(0x02, &body, out); +} + +/// None for a key too large for two length bytes; no real one is. +pub fn rsa_spki(n: &[u8], e: &[u8]) -> Option> { + if n.len() > 0x2000 || e.len() > 0x100 { + return None; + } + let mut ints = Vec::new(); + integer(n, &mut ints); + integer(e, &mut ints); + let mut key = Vec::new(); + tlv(0x30, &ints, &mut key); + let mut bits = alloc::vec![0u8]; + bits.extend(key); + let mut body = RSA_ALGORITHM.to_vec(); + tlv(0x03, &bits, &mut body); + let mut out = Vec::new(); + tlv(0x30, &body, &mut out); + Some(out) +} diff --git a/userland/capsule_linux/src/linux/install/pgp/verifier.rs b/userland/capsule_linux/src/linux/install/pgp/verifier.rs new file mode 100644 index 0000000000..07e9ac0550 --- /dev/null +++ b/userland/capsule_linux/src/linux/install/pgp/verifier.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 . + +//! OpenPGP's arithmetic on this machine: RSA through the crypto service, and +//! Ed25519 in this capsule. + +use nonos_openpgp::Verifier; + +use super::request::request; + +/// The crypto service's PKCS#1 v1.5 scheme. +const PKCS1: u8 = 0; + +pub struct Machine; + +impl Verifier for Machine { + fn rsa(&self, n: &[u8], e: &[u8], sig: &[u8], hash: u8, digest: &[u8]) -> bool { + request(n, e, sig, hash) + .is_some_and(|r| nonos_tls::verify_rsa(PKCS1, r.hashid, &r.spki, &r.sig, digest)) + } + + fn ed25519(&self, key: &[u8; 32], sig: &[u8; 64], digest: &[u8]) -> bool { + nonos_ed25519::verify(key, digest, &nonos_ed25519::Signature::from_bytes(sig)) + } +} 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/place.rs b/userland/capsule_linux/src/linux/install/place.rs index 28012a2291..9daff7df4a 100644 --- a/userland/capsule_linux/src/linux/install/place.rs +++ b/userland/capsule_linux/src/linux/install/place.rs @@ -15,22 +15,45 @@ // 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::place_entry::one; -use super::provenance::Provenance; -use super::tar::entries; +use super::auth::Verified; +use alloc::vec::Vec; -/// 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() -} +use super::place_entry::{allowed, one}; +use super::program::record; +use super::tar::{walk, Kind}; +use crate::linux::file::visible; -fn say(line: &[u8]) { +/// Unpack a package's authenticated files into the store and report how +/// 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); + } + let archive = walk(files.files()); + let mut landed = 0usize; + let mut links: Vec<(Vec, Vec)> = Vec::new(); + for entry in &archive.entries { + match &entry.kind { + Kind::File => landed += usize::from(one(entry)), + Kind::Symlink(to) if allowed(&entry.name) => { + links.push((visible(b"/", &entry.name), to.clone())); + } + // A hard link names another member, so its target is absolute. + Kind::Hardlink(to) if allowed(&entry.name) => { + links.push((visible(b"/", &entry.name), visible(b"/", to))); + } + // Directories are implied by the paths under them. + _ => {} + } + } + let linked = super::place_links::record(&links); + super::place_report::say(landed, links.len(), linked, archive.dropped); + // 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 75aa0e08eb..68494dc459 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,12 +29,8 @@ 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 { - if entry.name.starts_with(b".") { - return false; - } - if REFUSED.iter().any(|s| entry.name.ends_with(s)) { - say(b"[LINUX] refused a package writing its own proof\n"); +pub(super) fn one(entry: &Entry) -> bool { + if !allowed(&entry.name) { return false; } let at = visible(b"/", &entry.name); @@ -43,18 +38,27 @@ 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; +/// Not a control file, and not a proof a package would be minting for itself. +/// A link is held to the same names as a file. +pub(super) fn allowed(name: &[u8]) -> bool { + if name.starts_with(b".") { + return false; + } + if REFUSED.iter().any(|s| name.ends_with(s)) { + say(b"[LINUX] refused a package writing its own proof\n"); + return false; } + true +} + +/// 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/place_links.rs b/userland/capsule_linux/src/linux/install/place_links.rs new file mode 100644 index 0000000000..9e3b68c6a3 --- /dev/null +++ b/userland/capsule_linux/src/linux/install/place_links.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 . +//! Putting a package's files into the store, under the Linux root. + +//! A package's links, written into the table the personality follows. +//! +//! The store holds files and nothing else, so a link is a `path target` line +//! in /etc/nonos-links, the table busybox's applets already resolve through. +//! A symbolic link keeps its target as written, relative or not; a hard link +//! names another member of the archive, so it becomes an absolute one. +//! Resolution passes every result through `visible`, so no link leaves the +//! guest's tree whatever it says. + +use alloc::vec::Vec; + +use crate::linux::file::{key, store_read, store_write}; + +const TABLE: &[u8] = b"/etc/nonos-links"; +const MAX_TABLE: u32 = 64 << 10; + +/// Add `links` (path, target) to the table, skipping paths it already has. +/// The count written, or None when the table could not be written. +pub(super) fn record(links: &[(Vec, Vec)]) -> Option { + if links.is_empty() { + return Some(0); + } + let mut table = store_read(&key(TABLE), MAX_TABLE).unwrap_or_default(); + let mut added = 0usize; + for (path, target) in links { + if has(&table, path) || path.contains(&b' ') || path.contains(&b'\n') { + continue; + } + if target.contains(&b'\n') { + continue; + } + if !table.is_empty() && table.last() != Some(&b'\n') { + table.push(b'\n'); + } + table.extend_from_slice(path); + table.push(b' '); + table.extend_from_slice(target); + table.push(b'\n'); + added += 1; + } + if table.len() > MAX_TABLE as usize { + return None; + } + store_write(&key(TABLE), &table).ok().map(|_| added) +} + +fn has(table: &[u8], path: &[u8]) -> bool { + table + .split(|b| *b == b'\n') + .any(|line| line.len() > path.len() && line.starts_with(path) && line[path.len()] == b' ') +} diff --git a/userland/capsule_linux/src/linux/install/place_report.rs b/userland/capsule_linux/src/linux/install/place_report.rs new file mode 100644 index 0000000000..42999f1fa1 --- /dev/null +++ b/userland/capsule_linux/src/linux/install/place_report.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 . +//! Putting a package's files into the store, under the Linux root. + +//! What an unpack did, as numbers in the log: files, links, and anything the +//! archive held that had nowhere to go. A dropped entry is never silent. + +use alloc::format; + +use nonos_libc::mk_debug; + +pub(super) fn say(files: usize, links: usize, linked: Option, dropped: u32) { + let written = match linked { + Some(n) => format!("{n}"), + None => "0 (table not written)".into(), + }; + let line = format!( + "[LINUX] unpacked files={files} links={links} linked={written} dropped={dropped}\n" + ); + let _ = mk_debug(line.as_ptr(), line.len()); +} 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..db12683980 --- /dev/null +++ b/userland/capsule_linux/src/linux/install/program.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 . + +//! 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 every family's tree, where no guest can rewrite what its package +// starts as. One directory per family, so two families' `jq` do not collide. +use crate::linux::file::family::records; + +/// 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", records()] { + 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(), b"/", 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 05b733a57f..0d189dd6f3 100644 --- a/userland/capsule_linux/src/linux/install/run.rs +++ b/userland/capsule_linux/src/linux/install/run.rs @@ -22,52 +22,49 @@ use alloc::vec::Vec; use nonos_libc::mk_debug; -use super::download::download; +use super::fetch::fetch; 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; 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; - -pub fn install(name: &str) -> bool { +/// Alpine's install; `family::install` sends the other families elsewhere. +pub fn install(name: &str, pin: &[u8; 32]) -> Result<(), super::Why> { let Some(index) = load_index() else { say(b"[LINUX] no package index\n"); - return false; + return Err(super::Why::Index); }; + let max = super::limit::max_packages(); 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; + return Err(super::Why::NotProvided); }; - let apk = download(&pkg.name, &pkg.version); - if apk.is_empty() { - say(b"[LINUX] package would not download\n"); - return false; + if done.contains(&pkg.name) { + continue; + } + if done.len() == max { + let line = alloc::format!("[LINUX] refused: closure passes {max} packages\n"); + say(line.as_bytes()); + return Err(super::Why::TooLarge); } - /* - * Nothing says these are the bytes the distribution - * published: see `provenance`. - */ - unpack(&apk, Provenance::Unauthenticated); - done.push(next); + let Some(files) = fetch(pkg, (pkg.name == name).then_some(pin)) else { + return Err(super::Why::Package); + }; + say(b"[LINUX] provenance Verified: index signature and checksums match\n"); + unpack(&files, (pkg.name == name).then_some(name)); + done.push(pkg.name.clone()); + wanted.extend(pkg.depends.iter().cloned()); } - true + Ok(()) } fn say(line: &[u8]) { diff --git a/userland/capsule_linux/src/linux/install/tar.rs b/userland/capsule_linux/src/linux/install/tar.rs index 2cecb045fd..7a11f8b2bf 100644 --- a/userland/capsule_linux/src/linux/install/tar.rs +++ b/userland/capsule_linux/src/linux/install/tar.rs @@ -15,32 +15,42 @@ // along with this program. If not, see . //! Walking a tar, which is what a package is once it is decompressed. +//! +//! Every typeflag a package carries is kept: files, symbolic and hard links, +//! directories, and the pax and GNU records that give a long path. Devices +//! and fifos are counted as dropped, so a lost entry is a number, not silence. use alloc::vec::Vec; -use super::tar_field::{name_of, octal}; +use super::tar_field::octal; +use super::tar_kind::{read, Read}; +use super::tar_pax::Overrides; + +pub use super::tar_kind::{Entry, Kind}; const BLOCK: usize = 512; const SIZE_AT: usize = 124; const SIZE_LEN: usize = 12; const TYPE_AT: usize = 156; -pub struct Entry { - pub name: Vec, - pub body: Vec, +/// Everything in the archive, and how many entries had nowhere to go. +pub struct Walk { + pub entries: Vec, + pub dropped: u32, } -/// Regular files only. A package's directories are implied by its paths -/// and its links are followed at install time, not recreated. +/// Regular files only, for the readers that want a named file's bytes. pub fn entries(data: &[u8]) -> Vec { - let mut out = Vec::new(); + walk(data).entries.into_iter().filter(|e| matches!(e.kind, Kind::File)).collect() +} + +pub fn walk(data: &[u8]) -> Walk { + let mut out = Walk { entries: Vec::new(), dropped: 0 }; + let mut next = Overrides::default(); let mut at = 0usize; while at + BLOCK <= data.len() { let head = &data[at..at + BLOCK]; - /* - * A zero block ends an archive, and an apk is several archives end to - * end: a signature, a control stream, then the data. - */ + // A zero block ends an archive, and an apk is several end to end. if head.iter().all(|b| *b == 0) { at += BLOCK; continue; @@ -49,13 +59,13 @@ pub fn entries(data: &[u8]) -> Vec { break; }; let body_at = at + BLOCK; - let end = body_at + size; - if end > data.len() { + let Some(end) = body_at.checked_add(size).filter(|e| *e <= data.len()) else { break; - } - if head[TYPE_AT] == b'0' || head[TYPE_AT] == 0 { - let name = name_of(head); - out.push(Entry { name, body: data[body_at..end].to_vec() }); + }; + match read(head[TYPE_AT], head, &data[body_at..end], &mut next) { + Read::Entry(e) => out.entries.push(e), + Read::Record => {} + Read::Dropped => out.dropped += 1, } at = body_at + size.div_ceil(BLOCK) * BLOCK; } diff --git a/userland/capsule_linux/src/linux/install/tar_field.rs b/userland/capsule_linux/src/linux/install/tar_field.rs index 588d901f2d..7a966c730d 100644 --- a/userland/capsule_linux/src/linux/install/tar_field.rs +++ b/userland/capsule_linux/src/linux/install/tar_field.rs @@ -14,11 +14,15 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -//! The two header fields this reader needs. +//! The header fields this reader needs. use alloc::vec::Vec; const NAME: usize = 100; +const LINK_AT: usize = 157; +const MAGIC_AT: usize = 257; +const PREFIX_AT: usize = 345; +const PREFIX: usize = 155; /// The size field is octal text, space or NUL padded. pub(super) fn octal(field: &[u8]) -> Option { @@ -33,8 +37,29 @@ pub(super) fn octal(field: &[u8]) -> Option { Some(value) } +/// The path, with ustar's prefix in front when the header has one: ustar +/// splits a long path there rather than truncating it. pub(super) fn name_of(head: &[u8]) -> Vec { - let raw = &head[..NAME]; - let end = raw.iter().position(|b| *b == 0).unwrap_or(NAME); - raw[..end].to_vec() + let name = cstr(&head[..NAME]); + if &head[MAGIC_AT..MAGIC_AT + 5] != b"ustar" { + return name.to_vec(); + } + let prefix = cstr(&head[PREFIX_AT..PREFIX_AT + PREFIX]); + if prefix.is_empty() { + return name.to_vec(); + } + let mut out = prefix.to_vec(); + out.push(b'/'); + out.extend_from_slice(name); + out +} + +/// What a link entry points at. +pub(super) fn link_of(head: &[u8]) -> Vec { + cstr(&head[LINK_AT..LINK_AT + NAME]).to_vec() +} + +/// A field up to its first NUL. +pub(super) fn cstr(raw: &[u8]) -> &[u8] { + &raw[..raw.iter().position(|b| *b == 0).unwrap_or(raw.len())] } diff --git a/userland/capsule_linux/src/linux/install/tar_kind.rs b/userland/capsule_linux/src/linux/install/tar_kind.rs new file mode 100644 index 0000000000..2056b93f01 --- /dev/null +++ b/userland/capsule_linux/src/linux/install/tar_kind.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 . + +//! What one header is: an entry, a record about the next entry, or dropped. + +use super::tar_field::{cstr, link_of, name_of}; +use super::tar_path::member; +use super::tar_pax::{read as read_pax, Overrides}; +use alloc::vec::Vec; + +pub enum Kind { + File, + Symlink(Vec), + Hardlink(Vec), + Dir, +} + +pub struct Entry { + pub name: Vec, + pub kind: Kind, + pub body: Vec, +} + +/// An entry; a pax, GNU long-name or global record; or a device, fifo or unknown. +pub(super) enum Read { + Entry(Entry), + Record, + Dropped, +} + +pub(super) fn read(flag: u8, head: &[u8], body: &[u8], next: &mut Overrides) -> Read { + let kind = match flag { + b'0' | 0 | b'7' => Kind::File, + b'1' => Kind::Hardlink(member(next.link.take().unwrap_or_else(|| link_of(head)))), + b'2' => Kind::Symlink(next.link.take().unwrap_or_else(|| link_of(head))), + b'5' => Kind::Dir, + b'x' => { + read_pax(body, next); + return Read::Record; + } + b'L' => { + next.path = Some(cstr(body).to_vec()); + return Read::Record; + } + b'K' => { + next.link = Some(cstr(body).to_vec()); + return Read::Record; + } + b'g' => return Read::Record, // global pax defaults: nothing reads them + _ => { + *next = Overrides::default(); + return Read::Dropped; + } + }; + let name = member(next.path.take().unwrap_or_else(|| name_of(head))); + let body = if matches!(kind, Kind::File) { body.to_vec() } else { Vec::new() }; + *next = Overrides::default(); + Read::Entry(Entry { name, kind, body }) +} diff --git a/userland/capsule_linux/src/linux/install/tar_path.rs b/userland/capsule_linux/src/linux/install/tar_path.rs new file mode 100644 index 0000000000..8aebecb6f0 --- /dev/null +++ b/userland/capsule_linux/src/linux/install/tar_path.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 . + +//! Member paths, as every archive family writes them. + +use alloc::vec::Vec; + +/// A member path as the archive's root sees it. dpkg writes `./usr/bin/x` +/// where apk writes `usr/bin/x`, and a directory ends in `/` on the wire. +/// A symlink's target is left as written: `./` there is meaningful. +pub(super) fn member(mut name: Vec) -> Vec { + while name.starts_with(b"./") { + name.drain(..2); + } + while name.len() > 1 && name.last() == Some(&b'/') { + name.pop(); + } + name +} diff --git a/userland/capsule_linux/src/linux/install/tar_pax.rs b/userland/capsule_linux/src/linux/install/tar_pax.rs new file mode 100644 index 0000000000..62e7098694 --- /dev/null +++ b/userland/capsule_linux/src/linux/install/tar_pax.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 . + +//! Pax extended headers: `length key=value\n` records that override the +//! next entry's fields. Only the two that change where a file lands are +//! taken; timestamps and checksums are recorded by nothing here. + +use alloc::vec::Vec; + +#[derive(Default)] +pub struct Overrides { + pub path: Option>, + pub link: Option>, +} + +pub fn read(body: &[u8], into: &mut Overrides) { + let mut at = 0usize; + while at < body.len() { + // The length counts the whole record, its own digits included. + let Some(space) = body[at..].iter().position(|b| *b == b' ') else { + return; + }; + let Some(len) = decimal(&body[at..at + space]) else { + return; + }; + let Some(end) = at.checked_add(len).filter(|e| *e <= body.len() && len > space + 1) else { + return; + }; + let record = &body[at + space + 1..end - 1]; + if let Some(eq) = record.iter().position(|b| *b == b'=') { + let value = record[eq + 1..].to_vec(); + match &record[..eq] { + b"path" => into.path = Some(value), + b"linkpath" => into.link = Some(value), + _ => {} + } + } + at = end; + } +} + +fn decimal(text: &[u8]) -> Option { + if text.is_empty() { + return None; + } + text.iter().try_fold(0usize, |v, b| match b { + b'0'..=b'9' => v.checked_mul(10)?.checked_add((b - b'0') as usize), + _ => None, + }) +} diff --git a/userland/capsule_linux/src/linux/install/unpacked.rs b/userland/capsule_linux/src/linux/install/unpacked.rs new file mode 100644 index 0000000000..d964fd368f --- /dev/null +++ b/userland/capsule_linux/src/linux/install/unpacked.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 . + +//! A database or package, decompressed by what its first bytes say it is. + +use alloc::vec::Vec; + +const GZIP: [u8; 2] = [0x1F, 0x8B]; +const ZSTD: [u8; 4] = [0x28, 0xB5, 0x2F, 0xFD]; +const XZ: [u8; 6] = [0xFD, 0x37, 0x7A, 0x58, 0x5A, 0x00]; + +/// The most a gzip stream may inflate to. Alpine's community index is 8 MB +/// inflated and Kali's main Packages 85 MB; the inflater's own 4 MiB default +/// refused the first. Still a bound: a small stream that expands without end +/// stops here, inside the install role's heap. +pub const MAX_INFLATED: usize = 128 << 20; + +/// A tar, decompressed if it is compressed. +pub fn unpacked(b: &[u8]) -> Option> { + // An uncompressed tar says so at offset 257. + let tar = b.get(257..262) == Some(b"ustar".as_slice()); + if tar { + return Some(b.to_vec()); + } + decompressed(b) +} + +/// Bytes compressed with zstd, gzip or xz; anything else is None. +pub fn decompressed(b: &[u8]) -> Option> { + if b.starts_with(&ZSTD) { + return nonos_zstd::decompress(b); + } + if b.starts_with(&GZIP) { + return nonos_inflate::gunzip_within(b, MAX_INFLATED); + } + b.starts_with(&XZ).then(|| nonos_xz::decompress(b))? +} diff --git a/userland/capsule_linux/src/linux/install/why.rs b/userland/capsule_linux/src/linux/install/why.rs new file mode 100644 index 0000000000..5de8ab40ee --- /dev/null +++ b/userland/capsule_linux/src/linux/install/why.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 . + +//! Why an install stopped, as the installer's exit code, so the system and +//! the store can say more than that it failed. The log line before the exit +//! says which package and which check. + +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum Why { + /// The index or database did not fetch, or its signature did not verify. + Index = 2, + /// A package, or something it depends on, is in no index. + NotProvided = 3, + /// The closure passes the configured package limit. + TooLarge = 4, + /// A package did not download, or did not match its pin, checksum or + /// signature. + Package = 5, + /// The image was built without a mirror for this family. + NoMirror = 8, + /// The image was built without a keyring for this family. + NoKeyring = 9, +} + +impl Why { + pub fn code(self) -> i32 { + self as i32 + } +} 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 cde2e2126d..305cba8465 100644 --- a/userland/capsule_linux/src/linux/mod.rs +++ b/userland/capsule_linux/src/linux/mod.rs @@ -22,16 +22,21 @@ mod attest; mod attest_local; mod attest_paths; mod attest_publisher; +mod boot_guest; +mod built_in; mod call; mod env; mod file; mod guest; +mod heap; mod image; mod install; +mod launch; mod net; mod origin; mod request; pub mod serve; +mod settle; mod source; mod start; mod start_guest; diff --git a/userland/capsule_linux/src/linux/net/connect.rs b/userland/capsule_linux/src/linux/net/connect.rs index ce5deac6d9..51f7279561 100644 --- a/userland/capsule_linux/src/linux/net/connect.rs +++ b/userland/capsule_linux/src/linux/net/connect.rs @@ -56,14 +56,9 @@ pub fn connect(guest: &mut Guest, fd: u64, at: u64, len: u64) -> u64 { } fn by_host(handle: u32, host: &[u8], port: u16) -> u64 { - if host.len() > u8::MAX as usize { + let Some(body) = super::host_body::host_body(handle, port, host) else { return errno::fail(errno::EINVAL); - } - let mut body = Vec::with_capacity(7 + host.len()); - body.extend_from_slice(&handle.to_le_bytes()); - body.extend_from_slice(&port.to_le_bytes()); - body.push(host.len() as u8); - body.extend_from_slice(host); + }; match call(OP_CONNECT_HOST, &body, 0) { Some((0, _)) => errno::ok(0), Some(_) => errno::fail(errno::ECONNREFUSED), diff --git a/userland/capsule_linux/src/linux/net/host_body.rs b/userland/capsule_linux/src/linux/net/host_body.rs new file mode 100644 index 0000000000..c90e693615 --- /dev/null +++ b/userland/capsule_linux/src/linux/net/host_body.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 . + +//! The body of a connect-by-host request, in the one layout net.sockets +//! parses (capsule_net_sockets connect/parse_host.rs): handle u32, port u16, +//! host length u16, then the host, all little-endian. + +use alloc::vec::Vec; + +/// The longest legal domain name, and net.sockets' own bound. +const MAX_HOST: usize = 253; + +pub fn host_body(handle: u32, port: u16, host: &[u8]) -> Option> { + if host.is_empty() || host.len() > MAX_HOST { + return None; + } + let mut body = Vec::with_capacity(8 + host.len()); + body.extend_from_slice(&handle.to_le_bytes()); + body.extend_from_slice(&port.to_le_bytes()); + body.extend_from_slice(&(host.len() as u16).to_le_bytes()); + body.extend_from_slice(host); + Some(body) +} diff --git a/userland/capsule_linux/src/linux/net/mod.rs b/userland/capsule_linux/src/linux/net/mod.rs index 0e91f4ee85..c0155af09c 100644 --- a/userland/capsule_linux/src/linux/net/mod.rs +++ b/userland/capsule_linux/src/linux/net/mod.rs @@ -21,6 +21,7 @@ mod call; mod connect; mod dgram; mod dgram_addr; +mod host_body; pub mod dns; mod ops; mod poll; @@ -28,14 +29,15 @@ mod poll_set; mod poll_socket; pub mod raw; pub mod raw_io; +pub mod route; mod select; mod socket; mod stream; pub use connect::connect; pub use dgram::{recvfrom, sendto}; -pub use poll::ready; +pub use poll::{ready, POLLERR, POLLHUP}; pub use poll_set::poll; -pub use select::select; +pub use select::{clear as select_clear, select}; pub use socket::socket; pub use stream::{close, recv, send}; diff --git a/userland/capsule_linux/src/linux/net/ops.rs b/userland/capsule_linux/src/linux/net/ops.rs index b5392771c1..3e0c98c4fb 100644 --- a/userland/capsule_linux/src/linux/net/ops.rs +++ b/userland/capsule_linux/src/linux/net/ops.rs @@ -26,6 +26,7 @@ pub const OP_POLL: u16 = 13; /// The socket kinds the server offers: 1 stream, 2 datagram, 3 mixnet. pub const KIND_MIXNET: u16 = 3; +pub const KIND_STREAM: u16 = 1; /// The address family the server takes. It is not AF_INET: the number /// is the server's own and the two only look alike. diff --git a/userland/capsule_linux/src/linux/net/poll.rs b/userland/capsule_linux/src/linux/net/poll.rs index 8d6e1130af..55b30b9a4d 100644 --- a/userland/capsule_linux/src/linux/net/poll.rs +++ b/userland/capsule_linux/src/linux/net/poll.rs @@ -23,6 +23,9 @@ use super::poll_socket::socket_bits; const POLLIN: u16 = 0x001; const POLLOUT: u16 = 0x004; const POLLNVAL: u16 = 0x020; +/// Reported whether asked for or not, by poll and by epoll alike. +pub const POLLERR: u16 = 0x008; +pub const POLLHUP: u16 = 0x010; /// What `fd` can do right now, in poll's bits. pub fn ready(guest: &Guest, fd: u64) -> u16 { @@ -32,21 +35,14 @@ pub fn ready(guest: &Guest, fd: u64) -> u16 { Some(handle) => socket_bits(handle), None => POLLNVAL, }, - Some(Kind::Timer) => timer_bits(guest, fd), + Some(Kind::Timer) => crate::linux::file::timer_bits(guest, fd), + Some(Kind::Pipe) => crate::linux::call::pipe_bits(guest, fd), + Some(Kind::Event) => crate::linux::file::event_bits(guest, fd), Some(Kind::Resolver) => resolver_bits(guest, fd), Some(_) => POLLIN | POLLOUT, } } -/// A timer is readable once it has fired and never writable. -fn timer_bits(guest: &Guest, fd: u64) -> u16 { - let now = nonos_libc::mk_uptime_ms().max(0) as u64; - match guest.fds.get(fd as usize) { - Some(e) if e.expiry != 0 && now >= e.expiry => POLLIN, - _ => 0, - } -} - /// Readable once an answer is waiting, and always writable: a query is taken /// whenever it is offered. fn resolver_bits(guest: &Guest, fd: u64) -> u16 { diff --git a/userland/capsule_linux/src/linux/net/poll_set.rs b/userland/capsule_linux/src/linux/net/poll_set.rs index dc7bc808d3..734ff8ad5d 100644 --- a/userland/capsule_linux/src/linux/net/poll_set.rs +++ b/userland/capsule_linux/src/linux/net/poll_set.rs @@ -20,7 +20,7 @@ use crate::linux::abi::errno; use crate::linux::guest::Guest; -use super::poll::ready; +use super::poll::{ready, POLLERR, POLLHUP}; /// fd, events, revents. const POLLFD_LEN: usize = 8; @@ -33,9 +33,14 @@ pub fn poll(guest: &mut Guest, at: u64, count: u64) -> u64 { let Some(raw) = guest.read(entry, POLLFD_LEN) else { return errno::fail(errno::EFAULT); }; - let fd = u32::from_le_bytes([raw[0], raw[1], raw[2], raw[3]]) as u64; + let fd = i32::from_le_bytes([raw[0], raw[1], raw[2], raw[3]]); let events = u16::from_le_bytes([raw[4], raw[5]]); - let revents = ready(guest, fd) & (events | POLLNVAL); + // A negative descriptor is an entry switched off: never ready. + // Hang-up, error and a closed descriptor are reported unasked. + let revents = match u64::try_from(fd) { + Ok(fd) => ready(guest, fd) & (events | POLLNVAL | POLLHUP | POLLERR), + Err(_) => 0, + }; if revents != 0 { hits += 1; } diff --git a/userland/capsule_linux/src/linux/net/raw.rs b/userland/capsule_linux/src/linux/net/raw.rs index 211bf78ba4..82b8385b1e 100644 --- a/userland/capsule_linux/src/linux/net/raw.rs +++ b/userland/capsule_linux/src/linux/net/raw.rs @@ -19,29 +19,48 @@ use alloc::vec::Vec; use super::call::call; -use super::ops::{DOMAIN, KIND_MIXNET, OP_CONNECT_HOST, OP_SOCKET}; +use super::ops::{DOMAIN, KIND_MIXNET, KIND_STREAM, OP_CONNECT_HOST, OP_SOCKET}; -/// Over the mixnet, like everything else. -pub fn open_stream() -> Option { +/// A stream to `ip`: over the mixnet, like everything else, unless `ip` is a +/// mirror on the local network (`route::is_local`), which is dialled directly +/// and said so. +pub fn open_stream_to(ip: &str) -> Option { + let kind = match super::route::is_local(ip) { + true => { + let line = + alloc::format!("[LINUX] mirror {ip} is on the local network: reached directly\n"); + let _ = nonos_libc::mk_debug(line.as_ptr(), line.len()); + KIND_STREAM + } + false => KIND_MIXNET, + }; let mut body = Vec::with_capacity(4); body.extend_from_slice(&DOMAIN.to_le_bytes()); - body.extend_from_slice(&KIND_MIXNET.to_le_bytes()); + body.extend_from_slice(&kind.to_le_bytes()); match call(OP_SOCKET, &body, 8) { Some((0, out)) if out.len() >= 4 => { Some(u32::from_le_bytes([out[0], out[1], out[2], out[3]])) } - _ => None, + got => failed("socket", ip, got.map(|g| g.0)), } } pub fn connect_host(handle: u32, host: &str, port: u16) -> Option<()> { - let mut body = Vec::with_capacity(7 + host.len()); - body.extend_from_slice(&handle.to_le_bytes()); - body.extend_from_slice(&port.to_le_bytes()); - body.push(host.len() as u8); - body.extend_from_slice(host.as_bytes()); + let body = super::host_body::host_body(handle, port, host.as_bytes())?; match call(OP_CONNECT_HOST, &body, 0) { Some((0, _)) => Some(()), - _ => None, + got => failed("connect", host, got.map(|g| g.0)), } } + +/// Which step a mirror fetch stopped at, and what net.sockets said: an +/// install that fails with only "no package index" cannot be told apart +/// from a mirror that is down. +fn failed(step: &str, to: &str, status: Option) -> Option { + let line = match status { + Some(code) => alloc::format!("[LINUX] mirror {to}: {step} refused, status {code}\n"), + None => alloc::format!("[LINUX] mirror {to}: {step} got no reply\n"), + }; + let _ = nonos_libc::mk_debug(line.as_ptr(), line.len()); + None +} diff --git a/userland/capsule_linux/src/linux/net/raw_io.rs b/userland/capsule_linux/src/linux/net/raw_io.rs index 268dc3a1f1..959fef2381 100644 --- a/userland/capsule_linux/src/linux/net/raw_io.rs +++ b/userland/capsule_linux/src/linux/net/raw_io.rs @@ -18,10 +18,12 @@ use alloc::vec::Vec; +use nonos_libc::{mk_yield, Deadline}; + use super::call::call; use super::ops::{OP_CLOSE, OP_RECV, OP_SEND}; -/// One transfer. The service caps a reply, so a body arrives in pieces. +/// One transfer; the service caps a reply, so a body arrives in pieces. const CHUNK: usize = 32 << 10; pub fn send_all(handle: u32, bytes: &[u8]) -> Option<()> { @@ -37,18 +39,32 @@ pub fn send_all(handle: u32, bytes: &[u8]) -> Option<()> { Some(()) } -/// Read until the peer closes, which is what Connection: close gives. -pub fn recv_all(handle: u32, limit: usize) -> Option> { +/// Read until `done` says the reply is whole, or nothing arrives for +/// `idle_ms`. An empty read is "nothing yet": net.core answers the same for +/// a quiet socket and a closed one, so it cannot mean the end. +type Done = dyn Fn(&[u8]) -> bool; + +pub fn recv_until(handle: u32, limit: usize, done: &Done, idle_ms: u64) -> Option> { let mut out: Vec = Vec::new(); + let mut quiet = Deadline::after_ms(idle_ms); loop { match call(OP_RECV, &handle.to_le_bytes(), CHUNK) { - Some((0, part)) if part.is_empty() => return Some(out), - Some((0, part)) => out.extend_from_slice(&part), - _ => return Some(out), + Some((0, part)) if !part.is_empty() => { + out.extend_from_slice(&part); + quiet = Deadline::after_ms(idle_ms); + } + _ if quiet.expired() => return Some(out), + _ => { + let _ = mk_yield(); + continue; + } } if out.len() > limit { return None; } + if done(&out) { + return Some(out); + } } } diff --git a/userland/capsule_linux/src/linux/net/route.rs b/userland/capsule_linux/src/linux/net/route.rs new file mode 100644 index 0000000000..b53d76c2f4 --- /dev/null +++ b/userland/capsule_linux/src/linux/net/route.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 . + +//! Which path a fetch takes. Everything goes over the mixnet, so what this +//! machine installs is not visible on the network it sits on, except a mirror +//! on a private or link-local address: a LAN or offline mirror, which a mixnet +//! exit could not reach. That exception is said in the log every time, and in +//! design/install-network.md. + +/// 10/8, 172.16/12, 192.168/16 and 169.254/16, as a dotted quad. +pub fn is_local(ip: &str) -> bool { + let mut q = [0u8; 4]; + let mut parts = ip.split('.'); + for slot in q.iter_mut() { + match parts.next().and_then(|p| p.parse().ok()) { + Some(v) => *slot = v, + None => return false, + } + } + if parts.next().is_some() { + return false; + } + matches!(q, [10, ..] | [192, 168, ..] | [169, 254, ..]) + || (q[0] == 172 && (16..32).contains(&q[1])) +} diff --git a/userland/capsule_linux/src/linux/net/select.rs b/userland/capsule_linux/src/linux/net/select.rs index 41e5139ed0..45e7878d6e 100644 --- a/userland/capsule_linux/src/linux/net/select.rs +++ b/userland/capsule_linux/src/linux/net/select.rs @@ -15,32 +15,57 @@ // along with this program. If not, see . //! `select`, answered from the same readiness the poll path reports. +use alloc::vec; +use alloc::vec::Vec; + use crate::linux::abi::errno; use crate::linux::guest::Guest; -use super::ready; +use super::{ready, POLLERR, POLLHUP}; -const POLLIN: u16 = 0x001; -const POLLOUT: u16 = 0x004; +/// What makes a descriptor count as ready in each set, as Linux's select +/// counts it: end of file is readable, and an error is both. +const POLLIN: u16 = 0x001 | POLLHUP | POLLERR; +const POLLOUT: u16 = 0x004 | POLLERR; /// Linux caps a descriptor set at 1024 bits and so does every libc that /// builds one, so a larger nfds is a caller error rather than a bigger set. const FD_SETSIZE: u64 = 1024; -const SET_BYTES: usize = (FD_SETSIZE / 8) as usize; -pub fn select(guest: &mut Guest, nfds: u64, readfds: u64, writefds: u64) -> u64 { +/// The bytes of a set that nfds covers, in whole longs, as Linux copies them. +fn set_bytes(nfds: u64) -> usize { + (nfds.div_ceil(64) * 8) as usize +} + +/// Count what is ready among `[readfds, writefds, exceptfds]`, narrowing +/// the sets to it. They are written back only when something is ready, +/// since a select that waits is tried again with the same sets; `clear` +/// empties them when its time runs out. Nothing here has an exceptional +/// condition, so that set always comes back empty. +pub fn select(guest: &mut Guest, nfds: u64, sets: [u64; 3]) -> u64 { if nfds > FD_SETSIZE { return errno::fail(errno::EINVAL); } + let bytes = set_bytes(nfds); + let mut narrowed: Vec<(u64, Vec)> = Vec::new(); let mut hits = 0u64; - for (at, want) in [(readfds, POLLIN), (writefds, POLLOUT)] { + for (at, want) in [(sets[0], POLLIN), (sets[1], POLLOUT)] { if at == 0 { continue; } - let Some(mut set) = guest.read(at, SET_BYTES) else { + let Some(mut set) = guest.read(at, bytes) else { return errno::fail(errno::EFAULT); }; hits += narrow(guest, &mut set, nfds, want); + narrowed.push((at, set)); + } + if hits == 0 { + return errno::ok(0); + } + if sets[2] != 0 { + narrowed.push((sets[2], vec![0; bytes])); + } + for (at, set) in narrowed { if guest.write(at, &set) < 0 { return errno::fail(errno::EFAULT); } @@ -48,6 +73,14 @@ pub fn select(guest: &mut Guest, nfds: u64, readfds: u64, writefds: u64) -> u64 errno::ok(hits) } +/// The sets as a select whose time ran out leaves them: empty. +pub fn clear(guest: &mut Guest, nfds: u64, sets: [u64; 3]) { + let empty = vec![0u8; set_bytes(nfds.min(FD_SETSIZE))]; + for at in sets.into_iter().filter(|&at| at != 0) { + let _ = guest.write(at, &empty); + } +} + /// Clear every bit whose descriptor is not ready for `want`, and report /// how many were left set. fn narrow(guest: &Guest, set: &mut [u8], nfds: u64, want: u16) -> u64 { diff --git a/userland/capsule_linux/src/linux/request.rs b/userland/capsule_linux/src/linux/request.rs index afaa2e9e8c..ac698fbf6c 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,40 @@ 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())?; + 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)) +} + +/// `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/serve/clone_tid.rs b/userland/capsule_linux/src/linux/serve/clone_tid.rs new file mode 100644 index 0000000000..a9d438fe8b --- /dev/null +++ b/userland/capsule_linux/src/linux/serve/clone_tid.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 tid `clone` writes where its caller asked (CLONE_PARENT_SETTID and +//! CLONE_CHILD_SETTID) is the number the guest sees, the same one clone +//! returns. musl keeps the parent's copy as the thread's own tid and hands it +//! to tkill, so the kernel's pid written there named no thread of the guest. + +use nonos_libc::ForeignFrame; + +use crate::linux::abi::nr; +use crate::linux::guest::Guest; + +const CLONE_PARENT_SETTID: u64 = 0x10_0000; +const CLONE_CHILD_SETTID: u64 = 0x100_0000; + +/// After a clone that made a thread, write its guest-side `tid` where the +/// flags ask. Linux ignores a word it cannot write. +pub(super) fn write(guest: &Guest, frame: &ForeignFrame, tid: u64) { + if frame.nr != nr::CLONE || tid as i64 <= 0 { + return; + } + let a = frame.args(); + let word = (tid as u32).to_le_bytes(); + if a[0] & CLONE_PARENT_SETTID != 0 { + let _ = guest.write(a[2], &word); + } + if a[0] & CLONE_CHILD_SETTID != 0 { + let _ = guest.write(a[3], &word); + } +} diff --git a/userland/capsule_linux/src/linux/serve/deliver.rs b/userland/capsule_linux/src/linux/serve/deliver.rs new file mode 100644 index 0000000000..5f2a86729a --- /dev/null +++ b/userland/capsule_linux/src/linux/serve/deliver.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 . + +//! Delivering a caught signal to the thread returning from a syscall. The +//! handler is entered with the interrupted syscall's return value already in +//! rax, so when it returns through rt_sigreturn the program sees that value. +//! Only the trapping thread is delivered to here; a signal raised against a +//! thread parked elsewhere waits in the queue until that thread next traps. + +use nonos_libc::{mk_foreign_context, mk_foreign_signal, ForeignRegs, SIGNAL_DELIVER}; + +use crate::linux::call::sigframe::build; +use crate::linux::guest::Guest; + +/// rax in the register word order. +const RAX: usize = 13; +/// `sa_flags`: enter the handler on the thread's alternate stack. +const SA_ONSTACK: u64 = 0x0800_0000; + +/// True when a handler was entered, so the caller must not also reply. +pub fn maybe_deliver(guest: &mut Guest, tid: u32, reply: u64) -> bool { + let Some((signum, act)) = guest.signals.take_caught(tid) else { + return false; + }; + let mut regs: ForeignRegs = [0; 18]; + let alt = guest.signals.stack(tid).map(|s| (s.sp, s.size)); + let onstack = act.flags & SA_ONSTACK != 0; + let built = (mk_foreign_context(tid, &mut regs) == 0).then(|| { + regs[RAX] = reply; + build(®s, act.handler, act.restorer, u32::from(signum), 0, alt, onstack) + }); + let Some(Some((_, buf, enter))) = built else { + // Could not read the thread or shape a frame: keep the signal pending. + guest.signals.raise(tid, signum); + return false; + }; + if guest.write(enter[15], &buf) < buf.len() as i64 { + guest.signals.raise(tid, signum); + return false; + } + if mk_foreign_signal(tid, &enter, SIGNAL_DELIVER) != 0 { + guest.signals.raise(tid, signum); + return false; + } + say(tid, signum, act.handler); + true +} + +fn say(tid: u32, signum: u8, handler: u64) { + let line = alloc::format!("[LINUX] signal {signum} to tid {tid}, handler {handler:#x}\n"); + let _ = nonos_libc::mk_debug(line.as_ptr(), line.len()); +} diff --git a/userland/capsule_linux/src/linux/serve/dispatch.rs b/userland/capsule_linux/src/linux/serve/dispatch.rs index 67226625ec..e292a5f27b 100644 --- a/userland/capsule_linux/src/linux/serve/dispatch.rs +++ b/userland/capsule_linux/src/linux/serve/dispatch.rs @@ -14,28 +14,60 @@ // 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; 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; pub fn answer(guest: &mut Guest, frame: &ForeignFrame) -> Answer { + super::tally::call(); + let got = route(guest, frame); + // An EAGAIN re-arms the descriptor's edge-triggered epoll entries. + if let Answer::Reply(value) = got { + crate::linux::file::rearm(guest, frame.nr, frame.args()[0], value); + } + got +} + +fn route(guest: &mut Guest, frame: &ForeignFrame) -> Answer { let a = frame.args(); match frame.nr { nr::CLONE => clone(guest, frame), - nr::FORK | nr::VFORK => crate::linux::call::fork(guest), + nr::FORK | nr::VFORK => crate::linux::call::fork(guest, frame.pid), 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)), - nr::FUTEX => futex(guest, frame.pid, a[0], a[1], a[2]), + nr::EXIT if frame.pid != guest.pid => 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::RT_SIGRETURN => crate::linux::call::rt_sigreturn(guest, frame.pid), + nr::FUTEX => futex(guest, frame.pid, a), + // The caller's own thread, which is not always the process. + nr::GETTID => Answer::value(u64::from(frame.pid)), + nr::SET_TID_ADDRESS => crate::linux::call::set_tid_address(guest, frame.pid, a[0]), + 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 | nr::WRITE if super::waits::may_wait(guest, frame.nr, a[0]) => { + super::waits::io(guest, frame.pid, frame.nr, a) + } + nr::EPOLL_PWAIT + | nr::EPOLL_PWAIT2 + | np::EPOLL_WAIT + | nr::POLL + | np::PPOLL + | np::SELECT + | np::PSELECT6 => super::waits::timed(guest, frame.pid, frame.nr, a), 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..c36d6771d3 --- /dev/null +++ b/userland/capsule_linux/src/linux/serve/family.rs @@ -0,0 +1,120 @@ +// 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 keeps its own descriptors, break and cwd. Pipe +//! buffers are the family's, lent to the guest being answered and taken back +//! (`family_lend`): a pipe opened before a fork has its ends in different +//! processes. + +use alloc::vec::Vec; +use core::mem; + +use nonos_libc::{mk_foreign_reply, ForeignFrame, FOREIGN_NR_DIED, FOREIGN_NR_INTERRUPTED}; + +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::{Event, Guest, Timer}; + +pub struct Family { + pub(super) guests: Vec, + pub(super) pipes: Vec>, + pub(super) events: Vec, + pub(super) timers: 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, root) = (mem::take(&mut first.pipes), first.pid); + let events = mem::take(&mut first.events); + let timers = mem::take(&mut first.timers); + let ns = PidNs::new(first.parent, root); + Family { guests: alloc::vec![first], pipes, events, timers, root, root_code: 0, ns } + } + + pub fn answer(&mut self, frame: &ForeignFrame) { + if frame.nr == FOREIGN_NR_DIED { + self.thread_died(frame.pid, frame.arg0 as i32); + return; + } + if frame.nr == FOREIGN_NR_INTERRUPTED { + self.interrupted(frame.pid); + return; + } + let Some(i) = self.guests.iter().position(|g| g.owns(frame.pid)) else { + return; + }; + let Some(frame) = frame_in(&self.ns, frame) else { + return; + }; + self.lend(i); + let got = answer(&mut self.guests[i], &frame); + self.take_back(i); + let g = &mut self.guests[i]; + let born = mem::take(&mut g.forked); + if let Answer::Reply(value) = got { + // A caught signal for this thread is delivered in place of the reply. + let out = value_out(&mut self.ns, frame.nr, value); + super::clone_tid::write(g, &frame, out); + if !super::deliver::maybe_deliver(g, frame.pid, out) { + let _ = mk_foreign_reply(frame.pid, out); + } + } + self.guests.extend(born); + } + + /// A guest thread ended on a signal. On Linux that ends the thread group, + /// so the guest exits; reap then kills its other threads and answers any + /// waiter. The status carries the signal in the shell's 128+signo form. + fn thread_died(&mut self, pid: u32, code: i32) { + let Some(g) = self.guests.iter_mut().find(|g| g.owns(pid)) else { + return; + }; + g.threads.retain(|t| *t != pid); + if g.exited.is_none() { + g.exited = Some(128 + signo_of(code)); + } + let line = + alloc::format!("[LINUX] guest thread {pid} ended on a signal; ending the process\n"); + crate::linux::start::say(line.as_bytes()); + } + + /// 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) + } +} + +/// The kernel names a fatal termination by a code that is not uniform across +/// its exception handlers. Map the ones a guest reaches to a signal number, +/// defaulting to SIGKILL for anything else, for the process's reported status. +fn signo_of(code: i32) -> i32 { + match code { + -11 | -12 => 11, // SIGSEGV: page fault, stack, bound, bad segment + -4 => 4, // SIGILL: bad opcode, FPU emulation, missing FPU + -8 => 8, // SIGFPE: divide, overflow, x87/SSE + -7 => 7, // SIGBUS: alignment, virtualisation + 5 => 5, // SIGTRAP: breakpoint, debug + c if c > 128 && c < 128 + 64 => c - 128, // terminate_current_with_signal + _ => 9, // SIGKILL, and the general-protection sentinel + } +} diff --git a/userland/capsule_linux/src/linux/serve/family_futex.rs b/userland/capsule_linux/src/linux/serve/family_futex.rs new file mode 100644 index 0000000000..489ee1f49a --- /dev/null +++ b/userland/capsule_linux/src/linux/serve/family_futex.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 . + +//! Ending futex waits whose timeout has passed. + +use alloc::vec::Vec; + +use nonos_libc::mk_foreign_reply; + +use super::family::Family; +use crate::linux::abi::errno; +use crate::linux::call::now_ms; + +const CLOCK_MONOTONIC: u64 = 1; + +impl Family { + /// Answer ETIMEDOUT to every futex waiter whose deadline has passed and + /// that nothing woke first. + pub fn settle_futex(&mut self) { + let Some(now) = now_ms(CLOCK_MONOTONIC) else { + return; + }; + for g in self.guests.iter_mut() { + let due: Vec = + g.futex_until.iter().filter(|&&(d, _)| d <= now).map(|&(_, t)| t).collect(); + g.futex_until.retain(|&(d, _)| d > now); + for tid in due { + let Some(at) = g.waits.iter().position(|&(w, _)| w == tid) else { + continue; + }; + g.waits.remove(at); + let value = errno::fail(errno::ETIMEDOUT); + // A caught signal for this thread is delivered in place of the reply. + if !super::deliver::maybe_deliver(g, tid, value) { + let _ = mk_foreign_reply(tid, value); + } + } + } + } +} diff --git a/userland/capsule_linux/src/linux/serve/family_interrupt.rs b/userland/capsule_linux/src/linux/serve/family_interrupt.rs new file mode 100644 index 0000000000..7c36a75c23 --- /dev/null +++ b/userland/capsule_linux/src/linux/serve/family_interrupt.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 . + +//! A running thread the kernel stopped at a tick because a signal was raised +//! for it: deliver it now, or let the thread run on exactly where it was. + +use nonos_libc::{mk_foreign_context, mk_foreign_reply, ForeignRegs}; + +use super::family::Family; + +/// rax in the register word order. +const RAX: usize = 13; + +impl Family { + pub(super) fn interrupted(&mut self, tid: u32) { + let mut regs: ForeignRegs = [0; 18]; + let rax = if mk_foreign_context(tid, &mut regs) == 0 { regs[RAX] } else { 0 }; + let Some(g) = self.guests.iter_mut().find(|g| g.owns(tid)) else { + let _ = mk_foreign_reply(tid, 0); + return; + }; + // No call is being answered: the handler returns to the thread's own rax. + if !super::deliver::maybe_deliver(g, tid, rax) { + let _ = mk_foreign_reply(tid, 0); + } + } +} diff --git a/userland/capsule_linux/src/linux/serve/family_lend.rs b/userland/capsule_linux/src/linux/serve/family_lend.rs new file mode 100644 index 0000000000..edd321d37f --- /dev/null +++ b/userland/capsule_linux/src/linux/serve/family_lend.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 . + +//! Lending the family's pipes, eventfd counters and timerfd timers to the +//! guest being answered. +//! +//! All three are the family's, since a fork leaves their descriptors in more +//! than one process. The guest being answered holds them for that one answer, +//! with a note of which ends are still open anywhere in the family: end of +//! file, a broken pipe and a hang-up are all decided by that note. + +use alloc::vec::Vec; +use core::mem; + +use super::family::Family; +use crate::linux::guest::Kind; + +impl Family { + pub(super) fn lend(&mut self, i: usize) { + let ends = self.pipe_ends(); + let g = &mut self.guests[i]; + g.pipe_ends = ends; + mem::swap(&mut self.pipes, &mut g.pipes); + mem::swap(&mut self.events, &mut g.events); + mem::swap(&mut self.timers, &mut g.timers); + } + + pub(super) fn take_back(&mut self, i: usize) { + let g = &mut self.guests[i]; + mem::swap(&mut self.pipes, &mut g.pipes); + mem::swap(&mut self.events, &mut g.events); + mem::swap(&mut self.timers, &mut g.timers); + g.pipe_ends = Vec::new(); + } + + /// For each pipe: whether a read end is open, whether a write end is. + fn pipe_ends(&self) -> Vec<(bool, bool)> { + let mut ends = alloc::vec![(false, false); self.pipes.len()]; + let open = self.guests.iter().flat_map(|g| g.fds.iter()); + for f in open.filter(|f| f.kind == Kind::Pipe) { + if let Some(end) = ends.get_mut(f.handle as usize) { + match f.writable { + true => end.1 = true, + false => end.0 = true, + } + } + } + ends + } +} 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..371e1c3c06 --- /dev/null +++ b/userland/capsule_linux/src/linux/serve/family_reap.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 . + +//! 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 rc = mk_kill(*tid as u64, SIGKILL); + if rc < 0 { + // Refused, it runs on after its process ended. + let line = alloc::format!( + "[LINUX] kill refused: pid {tid} outlives its process, errno {}\n", + -rc + ); + let _ = nonos_libc::mk_debug(line.as_ptr(), line.len()); + } + } + 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 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/family_sleep.rs b/userland/capsule_linux/src/linux/serve/family_sleep.rs new file mode 100644 index 0000000000..2f0e73985e --- /dev/null +++ b/userland/capsule_linux/src/linux/serve/family_sleep.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 . + +//! 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, futex timeout or parked wait + /// is due, if any. + pub fn next_wake_ms(&self) -> Option { + let now = now_ms(CLOCK_MONOTONIC)?; + let sleeper = self + .guests + .iter() + .flat_map(|g| g.sleepers.iter().chain(g.futex_until.iter())) + .map(|&(d, _)| d.saturating_sub(now)) + .min(); + [sleeper, self.next_wait_ms(now)].into_iter().flatten().min() + } +} diff --git a/userland/capsule_linux/src/linux/serve/family_waits.rs b/userland/capsule_linux/src/linux/serve/family_waits.rs new file mode 100644 index 0000000000..735f19a441 --- /dev/null +++ b/userland/capsule_linux/src/linux/serve/family_waits.rs @@ -0,0 +1,93 @@ +// 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 the calls parked in `waits`. + +use core::mem; + +use nonos_libc::mk_foreign_reply; + +use super::family::Family; +use super::waits::{attempt, expire}; +use super::waits_fds::watched; +use crate::linux::call::now_ms; +use crate::linux::guest::Kind; + +const CLOCK_MONOTONIC: u64 = 1; +/// How often a wait on a socket is looked at again: its readiness changes +/// with no call for the family to answer. A timer is looked at when it fires. +const TICK_MS: u64 = 10; + +impl Family { + /// Try every parked call again: answer the ones that can complete now, + /// answer the ones whose deadline has passed with nothing ready, and + /// leave the rest parked. + pub fn settle_waits(&mut self) { + let Some(now) = now_ms(CLOCK_MONOTONIC) else { + return; + }; + for i in 0..self.guests.len() { + if self.guests[i].blocked.is_empty() { + continue; + } + self.lend(i); + let g = &mut self.guests[i]; + for wait in mem::take(&mut g.blocked) { + let value = match attempt(g, &wait) { + Some(v) => v, + None if wait.deadline.is_some_and(|d| d <= now) => expire(g, &wait), + None => { + g.blocked.push(wait); + continue; + } + }; + // A caught signal for this thread is delivered in place of the reply. + if !super::deliver::maybe_deliver(g, wait.tid, value) { + let _ = mk_foreign_reply(wait.tid, value); + } + } + self.take_back(i); + } + } + + /// Milliseconds until a parked call is due to be looked at again: its + /// deadline, a timer it watches firing, or the next look at a socket. + pub(super) fn next_wait_ms(&self, now: u64) -> Option { + let mut soonest: Option = None; + let mut keep = |at: u64| soonest = Some(soonest.map_or(at, |s| s.min(at))); + for g in self.guests.iter() { + for wait in g.blocked.iter() { + if let Some(d) = wait.deadline { + keep(d.saturating_sub(now)); + } + for fd in watched(g, wait) { + match g.fds.get(fd as usize) { + Some(f) if f.kind == Kind::Socket => keep(TICK_MS), + Some(f) if f.kind == Kind::Timer => { + // One that has already fired was seen by the last look. + let due = self.timers.get(f.handle as usize).map_or(0, |t| t.due); + if due > now { + keep(due - now); + } + } + _ => {} + } + } + } + } + soonest + } +} diff --git a/userland/capsule_linux/src/linux/serve/loop_impl.rs b/userland/capsule_linux/src/linux/serve/loop_impl.rs index ef9e7bd449..a182d9f215 100644 --- a/userland/capsule_linux/src/linux/serve/loop_impl.rs +++ b/userland/capsule_linux/src/linux/serve/loop_impl.rs @@ -14,29 +14,33 @@ // 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); - } + // A sleeper or a parked wait 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); } - if let Some(code) = guest.exited { + family.settle_sleeps(); + family.settle_futex(); + // After reap, so a write end that left with its process reads as end of file. + family.reap(); + family.settle_waits(); + 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 a10fe22dad..91e9d726ff 100644 --- a/userland/capsule_linux/src/linux/serve/mod.rs +++ b/userland/capsule_linux/src/linux/serve/mod.rs @@ -17,14 +17,32 @@ //! Answering for a guest: the loop, and the table it answers from. mod answer; +mod clone_tid; +mod deliver; mod dispatch; +mod family; +mod family_futex; +mod family_interrupt; +mod family_lend; +mod family_reap; +mod family_sleep; +mod family_waits; mod loop_impl; +mod pid_map; +mod pid_ns; +mod refused; +mod pid_out; mod table; mod table_file; +mod table_link; mod table_mem; mod table_net; mod table_proc; +mod tally; mod unserved; +mod waits; +mod waits_fds; +mod waits_time; pub use answer::Answer; pub use loop_impl::serve; 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..894b1cb81f --- /dev/null +++ b/userland/capsule_linux/src/linux/serve/pid_map.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 . + +//! 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), + // The thread group, then the thread: both are numbers the guest was given. + np::TGKILL => (&[0, 1], errno::ESRCH), + nr::SCHED_SETPARAM + | nr::SCHED_GETPARAM + | nr::SCHED_SETSCHEDULER + | nr::SCHED_GETSCHEDULER + | nr::SCHED_SETAFFINITY + | nr::SCHED_GETAFFINITY => (&[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/refused.rs b/userland/capsule_linux/src/linux/serve/refused.rs new file mode 100644 index 0000000000..05e44228cc --- /dev/null +++ b/userland/capsule_linux/src/linux/serve/refused.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 . + +//! Calls refused on purpose, each with its reason. NONOS has its own +//! isolation model, and these would import Linux's: they are decisions, so +//! they are said as decisions and never read as forgotten. + +use crate::linux::abi::errno; + +const PERM: i64 = errno::EPERM; + +const REFUSED: &[(u64, i64, &str)] = &[ + (101, PERM, "ptrace: a guest does not inspect or steer another"), + (310, PERM, "process_vm_readv: no guest reads another's memory"), + (311, PERM, "process_vm_writev: no guest writes another's memory"), + (125, PERM, "capget: capabilities are the kernel's, not Linux's"), + (126, PERM, "capset: capabilities are the kernel's, not Linux's"), + (165, PERM, "mount: the tree is laid out by the personality"), + (166, PERM, "umount2: the tree is laid out by the personality"), + (161, PERM, "chroot: the family is already rooted at /linux"), + (272, PERM, "unshare: namespaces are the personality's"), + (308, PERM, "setns: namespaces are the personality's"), + (425, errno::ENOSYS, "io_uring_setup: a second call path around the gate"), + (426, errno::ENOSYS, "io_uring_enter: a second call path around the gate"), + (427, errno::ENOSYS, "io_uring_register: a second call path around the gate"), +]; + +/// The errno for a call refused on purpose, after saying why; None otherwise. +pub fn refused(number: u64) -> Option { + let (_, code, why) = REFUSED.iter().find(|(nr, _, _)| *nr == number)?; + let line = alloc::format!("[LINUX] refused {why}\n"); + let _ = nonos_libc::mk_debug(line.as_ptr(), line.len()); + Some(errno::fail(*code)) +} diff --git a/userland/capsule_linux/src/linux/serve/table.rs b/userland/capsule_linux/src/linux/serve/table.rs index 0f64f8f2c2..b5aaa26175 100644 --- a/userland/capsule_linux/src/linux/serve/table.rs +++ b/userland/capsule_linux/src/linux/serve/table.rs @@ -21,6 +21,7 @@ use crate::linux::call; use crate::linux::guest::Guest; use super::table_file::file_ops; +use super::table_link::link_ops; use super::table_mem::mem_ops; use super::table_net::net_ops; use super::table_proc::proc_ops; @@ -29,6 +30,9 @@ pub fn plain(guest: &mut Guest, tid: u32, nr: u64, a: [u64; 6]) -> u64 { if let Some(v) = file_ops(guest, tid, nr, a) { return v; } + if let Some(v) = link_ops(guest, nr, a) { + return v; + } if let Some(v) = net_ops(guest, tid, nr, a) { return v; } @@ -43,7 +47,7 @@ pub fn plain(guest: &mut Guest, tid: u32, nr: u64, a: [u64; 6]) -> u64 { fn rest(guest: &mut Guest, tid: u32, nr: u64, a: [u64; 6]) -> u64 { match nr { - nr::IOCTL => call::ioctl(guest, a[0], a[1]), + nr::IOCTL => call::ioctl(guest, a[0], a[1], a[2]), nr::FCNTL => call::fcntl(guest, a[0], a[1], a[2]), nr::UNAME => call::uname(guest, a[0]), np::GETRLIMIT => call::getrlimit(guest, a[0], a[1]), @@ -51,11 +55,16 @@ fn rest(guest: &mut Guest, tid: u32, nr: u64, a: [u64; 6]) -> u64 { np::PRLIMIT64 => call::prlimit64(guest, a[1], a[2], a[3]), nr::RT_SIGACTION => call::rt_sigaction(guest, a[0], a[1], a[2]), nr::RT_SIGPROCMASK => call::rt_sigprocmask(guest, a[2]), - nr::SIGALTSTACK => call::sigaltstack(guest, a[1]), + nr::SIGALTSTACK => call::sigaltstack(guest, tid, a[0], a[1]), nr::RSEQ | nr::SET_ROBUST_LIST => errno::ok(0), nr::ARCH_PRCTL => call::arch_prctl(guest, tid, a[0], a[1]), nr::GETRANDOM => call::getrandom(guest, a[0], a[1], a[2]), + nr::PRCTL => call::prctl(guest, tid, a[0], a[1]), + nr::SCHED_GETAFFINITY => call::sched_getaffinity(guest, a[1], a[2]), + nr::GETCPU => call::getcpu(guest, a[0], a[1]), + nr::MEMBARRIER => call::membarrier(a[0]), + nr::CLONE3 => call::clone3(), nr::EXIT | nr::EXIT_GROUP => call::exit(guest, a[0]), - other => super::unserved::unserved(other), + other => super::refused::refused(other).unwrap_or_else(|| super::unserved::unserved(other)), } } diff --git a/userland/capsule_linux/src/linux/serve/table_file.rs b/userland/capsule_linux/src/linux/serve/table_file.rs index 96fd7f03a8..d0da0fe5e9 100644 --- a/userland/capsule_linux/src/linux/serve/table_file.rs +++ b/userland/capsule_linux/src/linux/serve/table_file.rs @@ -14,10 +14,9 @@ // 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}; +use crate::linux::abi::{errno, nr, nr_path as np}; use crate::linux::call; use crate::linux::file; use crate::linux::file::flags; @@ -40,14 +39,19 @@ pub fn file_ops(guest: &mut Guest, tid: u32, nr: u64, a: [u64; 6]) -> Option file::newfstatat(guest, a[0], a[1], a[2]), nr::GETDENTS64 => file::getdents64(guest, a[0], a[1], a[2]), nr::EPOLL_CREATE1 => file::epoll_create(guest), + // The size is a hint Linux ignores past checking it is positive. + nr::EPOLL_CREATE if a[0] as u32 as i32 <= 0 => errno::fail(errno::EINVAL), + nr::EPOLL_CREATE => file::epoll_create(guest), + nr::EVENTFD2 => file::eventfd2(guest, a[0], a[1]), + nr::EVENTFD => file::eventfd2(guest, a[0], 0), nr::PIPE => call::pipe2(guest, a[0], 0), nr::PIPE2 => call::pipe2(guest, a[0], a[1]), nr::DUP => call::dup(guest, a[0]), nr::DUP2 | nr::DUP3 => call::dup2(guest, a[0], a[1]), nr::EPOLL_CTL => file::epoll_ctl(guest, a[0], a[1], a[2], a[3]), - nr::EPOLL_PWAIT => file::epoll_wait(guest, a[0], a[1], a[2]), - nr::TIMERFD_CREATE => file::timerfd_create(guest), - nr::TIMERFD_SETTIME => file::timerfd_settime(guest, a[0], a[2]), + nr::TIMERFD_CREATE => file::timerfd_create(guest, a[0], a[1]), + nr::TIMERFD_SETTIME => file::timerfd_settime(guest, a[0], a[1], a[2], a[3]), + nr::TIMERFD_GETTIME => file::timerfd_gettime(guest, a[0], a[1]), nr::PREAD64 => file::pread64(guest, a[0], a[1], a[2], a[3]), nr::GETCWD => call::getcwd(guest, a[0], a[1]), np::CHDIR => call::chdir(guest, a[0]), @@ -66,9 +70,8 @@ pub fn file_ops(guest: &mut Guest, tid: u32, nr: u64, a: [u64; 6]) -> Option file::faccessat(guest, a[0], a[1]), np::STATFS | np::FSTATFS => file::statfs(guest, a[1]), np::STATX => 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::readlinkat(guest, flags::AT_FDCWD, a[0], a[1], a[2]), _ => return None, }) } diff --git a/userland/capsule_linux/src/linux/serve/table_link.rs b/userland/capsule_linux/src/linux/serve/table_link.rs new file mode 100644 index 0000000000..f4a10b06a4 --- /dev/null +++ b/userland/capsule_linux/src/linux/serve/table_link.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 . + +//! Links, renames, owners, times and nodes. The plain calls are their *at +//! forms at AT_FDCWD, so each property is decided in one place. + +use crate::linux::abi::nr_path as np; +use crate::linux::file; +use crate::linux::file::flags::AT_FDCWD as CWD; +use crate::linux::guest::Guest; + +pub fn link_ops(guest: &mut Guest, nr: u64, a: [u64; 6]) -> Option { + Some(match nr { + np::SYMLINK => file::symlinkat(guest, a[0], CWD, a[1]), + np::SYMLINKAT => file::symlinkat(guest, a[0], a[1], a[2]), + np::LINK => file::linkat(guest, CWD, a[0], CWD, a[1]), + np::LINKAT => file::linkat(guest, a[0], a[1], a[2], a[3]), + np::READLINKAT => file::readlinkat(guest, a[0], a[1], a[2], a[3]), + np::RENAMEAT => file::renameat2(guest, a[0], a[1], a[2], a[3], 0), + np::RENAMEAT2 => file::renameat2(guest, a[0], a[1], a[2], a[3], a[4]), + np::CHOWN | np::LCHOWN => file::fchownat(guest, CWD, a[0], a[1], a[2]), + np::FCHOWNAT => file::fchownat(guest, a[0], a[1], a[2], a[3]), + np::FCHOWN => file::fchown_ids(a[1], a[2]), + np::UTIMENSAT => file::utimensat(guest, a[2]), + // A timeval cannot say "leave this time alone", so these always change one. + np::UTIME | np::UTIMES => file::utimensat(guest, 0), + np::MKNOD => file::mknodat(guest, CWD, a[0], a[1]), + np::MKNODAT => file::mknodat(guest, a[0], a[1], a[2]), + _ => return None, + }) +} diff --git a/userland/capsule_linux/src/linux/serve/table_mem.rs b/userland/capsule_linux/src/linux/serve/table_mem.rs index f2e562f75b..4ca19272bf 100644 --- a/userland/capsule_linux/src/linux/serve/table_mem.rs +++ b/userland/capsule_linux/src/linux/serve/table_mem.rs @@ -26,6 +26,7 @@ pub fn mem_ops(guest: &mut Guest, nr: u64, a: [u64; 6]) -> Option { nr::MMAP => call::mmap(guest, call::MapReq::from_args(a)), nr::MUNMAP => call::munmap(guest, a[0], a[1]), nr::MPROTECT => call::mprotect(guest, a[0], a[1], a[2]), + nr::MREMAP => call::mremap(guest, a[0], a[1], a[2], a[3]), // Advice, and this capsule takes none of it. nr::MADVISE => errno::ok(0), _ => return None, diff --git a/userland/capsule_linux/src/linux/serve/table_net.rs b/userland/capsule_linux/src/linux/serve/table_net.rs index 1d65692f78..cf5902775f 100644 --- a/userland/capsule_linux/src/linux/serve/table_net.rs +++ b/userland/capsule_linux/src/linux/serve/table_net.rs @@ -16,7 +16,7 @@ //! Calls that name a socket. -use crate::linux::abi::{nr, nr_path as np}; +use crate::linux::abi::nr; use crate::linux::call; use crate::linux::guest::Guest; use crate::linux::net; @@ -31,9 +31,6 @@ pub fn net_ops(guest: &mut Guest, tid: u32, nr: u64, a: [u64; 6]) -> Option nr::CONNECT => net::connect(guest, a[0], a[1], a[2]), nr::SENDTO => net::sendto(guest, a[0], a[1], a[2], a[4], a[5]), nr::RECVFROM => net::recvfrom(guest, a[0], a[1], a[2], a[4], a[5]), - nr::POLL => net::poll(guest, a[0], a[1]), - np::SELECT | np::PSELECT6 => net::select(guest, a[0], a[1], a[2]), - np::PPOLL => net::poll(guest, a[0], a[1]), nr::SHUTDOWN => call::close(guest, a[0]), nr::SENDMSG => unix::sendmsg(guest, a[0], a[1]), nr::RECVMSG => unix::recvmsg(guest, a[0], a[1]), diff --git a/userland/capsule_linux/src/linux/serve/table_proc.rs b/userland/capsule_linux/src/linux/serve/table_proc.rs index 2b0adad924..b98097cbfa 100644 --- a/userland/capsule_linux/src/linux/serve/table_proc.rs +++ b/userland/capsule_linux/src/linux/serve/table_proc.rs @@ -23,6 +23,7 @@ use crate::linux::guest::Guest; pub fn proc_ops(guest: &mut Guest, nr: u64, a: [u64; 6]) -> Option { Some(match nr { np::KILL | np::TKILL => call::kill(guest, a[0], a[1]), + np::TGKILL => call::kill(guest, a[1], a[2]), np::GETPPID => call::getppid(guest), np::SETPGID => call::setpgid(guest, a[0], a[1]), np::GETPGRP | np::GETPGID => call::getpgid(guest), @@ -31,13 +32,20 @@ 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(); errno::ok(0) } - nr::SET_TID_ADDRESS | nr::GETTID | nr::GETPID => errno::ok(guest.pid as u64), + nr::SCHED_GETSCHEDULER => call::sched_getscheduler(guest, a[0]), + nr::SCHED_SETSCHEDULER => call::sched_setscheduler(guest, a[0], a[1], a[2]), + nr::SCHED_GETPARAM => call::sched_getparam(guest, a[0], a[1]), + nr::SCHED_SETPARAM => call::sched_setparam(guest, a[0], a[1]), + nr::SCHED_GET_PRIORITY_MAX => call::priority_bound(a[0], true), + nr::SCHED_GET_PRIORITY_MIN => call::priority_bound(a[0], false), + nr::SCHED_SETAFFINITY => call::sched_setaffinity(guest, a[0], a[1], a[2]), + 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, 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 69174a1660..e60c1c6574 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}; @@ -22,9 +21,15 @@ 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 "; - 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 +39,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..] +} diff --git a/userland/capsule_linux/src/linux/serve/waits.rs b/userland/capsule_linux/src/linux/serve/waits.rs new file mode 100644 index 0000000000..6de3c6d7aa --- /dev/null +++ b/userland/capsule_linux/src/linux/serve/waits.rs @@ -0,0 +1,105 @@ +// 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 . + +//! Calls that wait for a descriptor: `epoll_wait`, `poll`, `ppoll`, +//! `select` and `pselect6` until their timeout, and a read or write that +//! would block on a pipe or an eventfd. +//! +//! Each is tried when it arrives. One that cannot complete is left parked +//! in its trap, and the family tries it again after every answer and at its +//! deadline (`family_waits`), so the other threads and processes it hosts +//! keep being served while it waits. + +use crate::linux::abi::{errno, nr, nr_path as np}; +use crate::linux::call::{self, now_ms}; +use crate::linux::file; +use crate::linux::guest::{Blocked, Guest, Kind}; +use crate::linux::net; + +use super::answer::Answer; +use super::waits_time::span; + +const CLOCK_MONOTONIC: u64 = 1; + +/// The epoll, poll and select calls: each waits until something it watches +/// is ready or its timeout passes, and a timeout of zero only looks. +pub fn timed(guest: &mut Guest, tid: u32, nr: u64, a: [u64; 6]) -> Answer { + let limit = match span(guest, nr, &a) { + Ok(limit) => limit, + Err(refused) => return Answer::value(refused), + }; + let now = now_ms(CLOCK_MONOTONIC).unwrap_or(0); + let deadline = limit.map(|ms| now.saturating_add(ms)); + let wait = Blocked { tid, nr, args: a, deadline }; + match attempt(guest, &wait) { + Some(v) => Answer::value(v), + None if deadline.is_some_and(|d| d <= now) => Answer::value(expire(guest, &wait)), + None => park(guest, wait), + } +} + +/// True for the reads and writes that can wait: a pipe or an eventfd, and +/// a read of a timer. +pub fn may_wait(guest: &Guest, nr: u64, fd: u64) -> bool { + match guest.fds.get(fd as usize).map(|f| f.kind) { + Some(Kind::Event | Kind::Pipe) => true, + Some(Kind::Timer) => nr == nr::READ, + _ => false, + } +} + +/// A read or write that answers EAGAIN waits instead, unless its descriptor +/// is non-blocking. +pub fn io(guest: &mut Guest, tid: u32, nr: u64, a: [u64; 6]) -> Answer { + let wait = Blocked { tid, nr, args: a, deadline: None }; + match attempt(guest, &wait) { + Some(v) => Answer::value(v), + None if guest.fds.get(a[0] as usize).is_some_and(|f| f.nonblock) => { + Answer::value(errno::fail(errno::EAGAIN)) + } + None => park(guest, wait), + } +} + +/// The call's answer if it can complete now, None if it would wait. +pub fn attempt(guest: &mut Guest, wait: &Blocked) -> Option { + let a = wait.args; + let again = errno::fail(errno::EAGAIN); + match wait.nr { + nr::READ => Some(call::read(guest, a[0], a[1], a[2])).filter(|&v| v != again), + nr::WRITE => Some(call::write(guest, a[0], a[1], a[2])).filter(|&v| v != again), + nr::POLL | np::PPOLL => Some(net::poll(guest, a[0], a[1])).filter(|&v| v != 0), + np::SELECT | np::PSELECT6 => { + Some(net::select(guest, a[0], [a[1], a[2], a[3]])).filter(|&v| v != 0) + } + _ => Some(file::epoll_wait(guest, a[0], a[1], a[2])).filter(|&v| v != 0), + } +} + +/// What a wait answers when its time runs out with nothing ready: zero, and +/// a select's sets emptied, as Linux leaves them. +pub fn expire(guest: &mut Guest, wait: &Blocked) -> u64 { + let a = wait.args; + if matches!(wait.nr, np::SELECT | np::PSELECT6) { + net::select_clear(guest, a[0], [a[1], a[2], a[3]]); + } + 0 +} + +fn park(guest: &mut Guest, wait: Blocked) -> Answer { + guest.blocked.push(wait); + Answer::Park +} diff --git a/userland/capsule_linux/src/linux/serve/waits_fds.rs b/userland/capsule_linux/src/linux/serve/waits_fds.rs new file mode 100644 index 0000000000..05f7bb06bd --- /dev/null +++ b/userland/capsule_linux/src/linux/serve/waits_fds.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 descriptors a parked wait watches, read from the call's own list. + +use alloc::vec::Vec; + +use crate::linux::abi::{nr, nr_path as np}; +use crate::linux::guest::{Blocked, Guest}; + +/// More than a guest can have open, so a longer list is read no further. +const MOST: u64 = 256; + +pub fn watched(guest: &Guest, wait: &Blocked) -> Vec { + let a = wait.args; + match wait.nr { + nr::READ | nr::WRITE => alloc::vec![a[0]], + nr::POLL | np::PPOLL => (0..a[1].min(MOST)) + .filter_map(|i| guest.read(a[0] + i * 8, 4)) + .map(|raw| u64::from(u32::from_le_bytes([raw[0], raw[1], raw[2], raw[3]]))) + .collect(), + np::SELECT | np::PSELECT6 => [a[1], a[2]] + .into_iter() + .filter(|&at| at != 0) + .filter_map(|at| guest.read(at, a[0].min(MOST).div_ceil(8) as usize)) + .flat_map(|set| { + (0..a[0].min(MOST)).filter(move |&fd| set[(fd / 8) as usize] & (1 << (fd % 8)) != 0) + }) + .collect(), + _ => guest + .fds + .get(a[0] as usize) + .map_or(Vec::new(), |l| l.watch.iter().map(|w| w.fd).collect()), + } +} diff --git a/userland/capsule_linux/src/linux/serve/waits_time.rs b/userland/capsule_linux/src/linux/serve/waits_time.rs new file mode 100644 index 0000000000..e306fb38d2 --- /dev/null +++ b/userland/capsule_linux/src/linux/serve/waits_time.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 . + +//! How long a waiting call may wait, read from each call's own form of +//! timeout. None has no limit; a timeout Linux refuses is EINVAL. + +use crate::linux::abi::{errno, nr, nr_path as np}; +use crate::linux::guest::Guest; + +/// Milliseconds, rounded up. +pub fn span(guest: &Guest, number: u64, a: &[u64; 6]) -> Result, u64> { + match number { + nr::POLL => Ok(int_ms(a[2])), + np::PPOLL => spec(guest, a[2], 1_000_000_000), + np::PSELECT6 => spec(guest, a[4], 1_000_000_000), + nr::EPOLL_PWAIT2 => spec(guest, a[3], 1_000_000_000), + // A timeval: seconds and microseconds. + np::SELECT => spec(guest, a[4], 1_000_000), + // epoll_wait and epoll_pwait. + _ => Ok(int_ms(a[3])), + } +} + +/// An int of milliseconds; a negative one has no limit. +fn int_ms(raw: u64) -> Option { + u64::try_from(raw as u32 as i32).ok() +} + +/// A timespec or timeval whose second word counts `whole` to the second. +/// A null pointer has no limit. +fn spec(guest: &Guest, at: u64, whole: i64) -> Result, u64> { + if at == 0 { + return Ok(None); + } + let Some(raw) = guest.read(at, 16) else { + return Err(errno::fail(errno::EFAULT)); + }; + let secs = i64::from_le_bytes(raw[..8].try_into().unwrap_or([0; 8])); + let part = i64::from_le_bytes(raw[8..16].try_into().unwrap_or([0; 8])); + if secs < 0 || !(0..whole).contains(&part) { + return Err(errno::fail(errno::EINVAL)); + } + let per_ms = (whole / 1000) as u64; + Ok(Some((secs as u64).saturating_mul(1000).saturating_add((part as u64).div_ceil(per_ms)))) +} diff --git a/userland/capsule_linux/src/linux/settle.rs b/userland/capsule_linux/src/linux/settle.rs new file mode 100644 index 0000000000..4c51e73701 --- /dev/null +++ b/userland/capsule_linux/src/linux/settle.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 . + +use nonos_app_skeleton::clients::vfs; +use nonos_libc::{mk_uptime_ms, mk_yield, Deadline}; + +use super::start::say; + +// The VFS always settles, loaded or given up; this bound only covers a dead one. +const READY_MS: u64 = 300_000; + +/* + * Wait until the VFS has finished loading the store, so a missing file is + * really missing. The time it took is logged as a number: the bound is a + * liveness backstop, and a slower settle must show up rather than hide under it. + */ +pub(super) fn wait_settled() -> bool { + let start = mk_uptime_ms(); + let until = Deadline::after_ms(READY_MS); + while !matches!(vfs::store_settled(), Ok(true)) { + if until.expired() { + say(b"[LINUX] boot guest unreadable: store never settled\n"); + return false; + } + let _ = mk_yield(); + } + const PREFIX: &[u8] = b"[LINUX] store settled after "; + // Room for the prefix, 20 digits and " ms\n". + let mut line = [0u8; 52]; + line[..PREFIX.len()].copy_from_slice(PREFIX); + let ms = mk_uptime_ms().saturating_sub(start).max(0) as u64; + let n = write_ms(&mut line[PREFIX.len()..], ms); + say(&line[..PREFIX.len() + n]); + true +} + +// " ms\n" into `out`, returning the bytes written. +fn write_ms(out: &mut [u8], mut ms: u64) -> usize { + let mut digits = [0u8; 20]; + let mut len = 0; + loop { + digits[len] = b'0' + (ms % 10) as u8; + len += 1; + ms /= 10; + if ms == 0 { + break; + } + } + for i in 0..len { + out[i] = digits[len - 1 - i]; + } + out[len..len + 4].copy_from_slice(b" ms\n"); + len + 4 +} diff --git a/userland/capsule_linux/src/linux/source.rs b/userland/capsule_linux/src/linux/source.rs index 1ee2ffb305..87e295bb1c 100644 --- a/userland/capsule_linux/src/linux/source.rs +++ b/userland/capsule_linux/src/linux/source.rs @@ -20,23 +20,32 @@ use alloc::vec::Vec; use nonos_libc::mk_args; +use crate::linux::file::family::choose; 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. -pub fn source() -> (Vec, Vec, Origin) { - match named() { - Some((path, bytes)) => (path, bytes, Origin::Store), - None => (b"/bin/busybox".to_vec(), BUILT_IN.to_vec(), Origin::BuiltIn), +/// 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 { + 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(choose(&name))?; + let bytes = store_read(&key(&path), MAX_IMAGE).ok()?; + 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(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 971da847d4..d7b1d9b380 100644 --- a/userland/capsule_linux/src/linux/start.rs +++ b/userland/capsule_linux/src/linux/start.rs @@ -16,23 +16,29 @@ //! Bring one Linux program up and stay with it until it ends. -use nonos_libc::{heap_init, mk_debug, mk_exit, mk_foreign_spawn}; +use nonos_libc::{mk_debug, mk_exit, mk_foreign_spawn}; -use super::guest::Guest; use super::serve::serve; use super::source::source; use super::start_guest::start; +use super::{file::family::choose, guest::Guest}; pub fn run() -> ! { - let _ = heap_init(); + super::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); - say(if ok { b"[LINUX] installed\n" } else { b"[LINUX] install failed\n" }); - mk_exit(if ok { 0 } else { 1 }) + let pkg = choose(&name); + super::file::allow_shared_writes(); + // The exit code names the reason, which the store shows. + let done = super::install::install(pkg, &pin); + say(if done.is_ok() { b"[LINUX] installed\n" } else { b"[LINUX] install failed\n" }); + mk_exit(done.map_or_else(|why| why.code(), |()| 0)) } - let (path, bytes, origin) = source(); + let Some(launch) = 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 "); @@ -41,17 +47,25 @@ 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); - let code = match start(&mut guest, &path, &bytes, origin) { + guest.links = alloc::rc::Rc::new(super::guest::Links::load()); + let code = match start(&mut guest, &launch) { Ok(()) => { say(b"[LINUX] guest running\n"); - serve(&mut guest) + 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) } 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(|_| ()), 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/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 } 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/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); + } } } 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, } } diff --git a/userland/capsule_linux/src/linux/wayland/shm.rs b/userland/capsule_linux/src/linux/wayland/shm.rs index 7f16458123..547092f1d5 100644 --- a/userland/capsule_linux/src/linux/wayland/shm.rs +++ b/userland/capsule_linux/src/linux/wayland/shm.rs @@ -35,9 +35,10 @@ pub fn formats(guest: &mut Guest, id: u32) { } /// The descriptor was passed in the control data of the sendmsg that -/// carried this request, so it is taken from the queue in order. +/// carried this request, so it is taken from the queue in order. An fd +/// argument has no word in the body: the body is the new id and the size. pub fn create_pool(guest: &mut Guest, args: &mut Args<'_>) { - let (Some(id), Some(_fd_slot), Some(size)) = (args.u32(), args.u32(), args.u32()) else { + let (Some(id), Some(size)) = (args.u32(), args.u32()) else { return; }; let Some(fd) = take_fd(guest) else { 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", } } diff --git a/userland/capsule_linux_proofs/Cargo.lock b/userland/capsule_linux_proofs/Cargo.lock index b5322f0561..d1c94a7156 100644 --- a/userland/capsule_linux_proofs/Cargo.lock +++ b/userland/capsule_linux_proofs/Cargo.lock @@ -2,6 +2,401 @@ # 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", + "nonos_openpgp", + "nonos_xz", + "nonos_zstd", + "rsa", + "sha1", +] + +[[package]] +name = "nonos_hash" +version = "0.1.0" + +[[package]] +name = "nonos_inflate" +version = "0.3.0" + +[[package]] +name = "nonos_openpgp" +version = "0.1.0" +dependencies = [ + "nonos_hash", + "sha1", +] + +[[package]] +name = "nonos_xz" +version = "0.1.0" +dependencies = [ + "nonos_hash", +] + +[[package]] +name = "nonos_zstd" +version = "0.1.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..cdf701f869 100644 --- a/userland/capsule_linux_proofs/Cargo.toml +++ b/userland/capsule_linux_proofs/Cargo.toml @@ -17,3 +17,18 @@ 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" } +nonos_xz = { path = "../xz" } +nonos_zstd = { path = "../zstd" } +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"] } +# Reads the GnuPG vectors the pacman request is checked with. +nonos_openpgp = { path = "../openpgp" } +sha1 = { version = "0.10", default-features = false, features = ["oid"] } diff --git a/userland/capsule_linux_proofs/src/host_doubles.rs b/userland/capsule_linux_proofs/src/host_doubles.rs new file mode 100644 index 0000000000..649d7897e9 --- /dev/null +++ b/userland/capsule_linux_proofs/src/host_doubles.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 . + +//! Host doubles for the two modules `root.rs` and `resolve.rs` reach that make +//! syscalls: the family's private prefixes (a random id from the kernel) and +//! the clamp log (a console write). The doubles keep the signatures and model +//! install mode, where every path maps to the shared tree, which is what the +//! key and resolve proofs are about. The clamp double counts, so a proof can +//! say a clamp was reported and not only that the path came out clamped. + +pub mod private { + pub fn shared_writes_allowed() -> bool { + true + } + pub fn is_private(_visible: &[u8]) -> bool { + false + } + pub fn root() -> Vec { + Vec::new() + } +} + +pub mod clamp { + use core::sync::atomic::{AtomicU32, Ordering}; + + pub static NOTED: AtomicU32 = AtomicU32::new(0); + + pub fn note(_path: &[u8]) { + NOTED.fetch_add(1, Ordering::Relaxed); + } +} 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/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/deb.rs b/userland/capsule_linux_proofs/src/install/deb.rs new file mode 100644 index 0000000000..e2a2540abc --- /dev/null +++ b/userland/capsule_linux_proofs/src/install/deb.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 . + +//! Debian's pure parts, included from the capsule. + +#[path = "../../../capsule_linux/src/linux/install/deb/ar.rs"] +pub mod ar; + +#[path = "../../../capsule_linux/src/linux/install/deb/fields.rs"] +pub mod fields; + +#[path = "../../../capsule_linux/src/linux/install/deb/packages.rs"] +pub mod packages; + +#[path = "../../../capsule_linux/src/linux/install/deb/release.rs"] +pub mod release; diff --git a/userland/capsule_linux_proofs/src/install/mod.rs b/userland/capsule_linux_proofs/src/install/mod.rs index 7985afda7e..b82f145195 100644 --- a/userland/capsule_linux_proofs/src/install/mod.rs +++ b/userland/capsule_linux_proofs/src/install/mod.rs @@ -14,14 +14,40 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . - //! The installer's pure parsers, included from the capsule. +pub mod auth; + +#[path = "../../../capsule_linux/src/linux/install/http_reply.rs"] +pub mod http_reply; + #[path = "../../../capsule_linux/src/linux/install/tar_field.rs"] pub mod tar_field; +#[path = "../../../capsule_linux/src/linux/install/tar_kind.rs"] +pub mod tar_kind; + +#[path = "../../../capsule_linux/src/linux/install/tar_pax.rs"] +pub mod tar_pax; + +#[path = "../../../capsule_linux/src/linux/install/tar_path.rs"] +pub mod tar_path; + #[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; + +#[path = "../../../capsule_linux/src/linux/install/hex.rs"] +pub mod hex; + +pub mod deb; +pub mod pacman; +pub mod pgp; + +#[path = "../../../capsule_linux/src/linux/install/unpacked.rs"] +pub mod unpacked; diff --git a/userland/capsule_linux_proofs/src/install/pacman.rs b/userland/capsule_linux_proofs/src/install/pacman.rs new file mode 100644 index 0000000000..c221516c66 --- /dev/null +++ b/userland/capsule_linux_proofs/src/install/pacman.rs @@ -0,0 +1,20 @@ +// 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 . + +//! pacman's pure parts, included from the capsule. + +#[path = "../../../capsule_linux/src/linux/install/pacman/desc.rs"] +pub mod desc; diff --git a/userland/capsule_linux_proofs/src/install/pgp.rs b/userland/capsule_linux_proofs/src/install/pgp.rs new file mode 100644 index 0000000000..c465f69b7d --- /dev/null +++ b/userland/capsule_linux_proofs/src/install/pgp.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 . + +//! The OpenPGP request the capsule sends the crypto service, included. + +#[path = "../../../capsule_linux/src/linux/install/pgp/spki.rs"] +pub mod spki; + +#[path = "../../../capsule_linux/src/linux/install/pgp/request.rs"] +pub mod request; diff --git a/userland/capsule_linux_proofs/src/lib.rs b/userland/capsule_linux_proofs/src/lib.rs index ab9fae3e52..caa4499bfe 100644 --- a/userland/capsule_linux_proofs/src/lib.rs +++ b/userland/capsule_linux_proofs/src/lib.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 shipped source, included and exercised. extern crate alloc; @@ -25,18 +24,49 @@ pub mod wire; #[path = "../../capsule_linux/src/linux/wayland/args.rs"] pub mod args; +mod host_doubles; +pub use host_doubles::{clamp, private}; + +#[path = "../../../src/userspace/capsule_linux/family.rs"] +pub mod listing_family; + +#[path = "../../capsule_linux/src/linux/file/family.rs"] +pub mod family; + +#[path = "../../capsule_linux/src/linux/file/root.rs"] +pub mod root; + #[path = "../../capsule_linux/src/linux/file/resolve.rs"] pub mod resolve; +#[path = "../../capsule_linux/src/linux/file/dir_children.rs"] +pub mod dir_children; + #[path = "../../capsule_linux/src/linux/file/dirent.rs"] pub mod dirent; #[path = "../../capsule_linux/src/linux/file/meta/statbuf.rs"] pub mod statbuf; +#[path = "../../capsule_linux/src/linux/net/host_body.rs"] +pub mod host_body; + +// net.sockets' own reader for a connect-by-host body, mounted at the crate +// paths it names, so the capsule's encoder is held to the real parser. +#[path = "../../capsule_net_sockets/src/protocol/errno.rs"] +pub mod protocol; +pub mod server; + +#[path = "../../capsule_linux/src/linux/net/route.rs"] +pub mod route; + +#[path = "../../capsule_linux/src/linux/call/sigframe.rs"] +pub mod sigframe; + #[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/server/handlers/mod.rs b/userland/capsule_linux_proofs/src/server/handlers/mod.rs new file mode 100644 index 0000000000..b36f085a52 --- /dev/null +++ b/userland/capsule_linux_proofs/src/server/handlers/mod.rs @@ -0,0 +1,21 @@ +// 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 . + +#[path = "../../../../capsule_net_sockets/src/server/handlers/io.rs"] +pub mod io; + +#[path = "../../../../capsule_net_sockets/src/server/handlers/connect/parse_host.rs"] +pub mod parse_host; diff --git a/userland/capsule_linux_proofs/src/server/mod.rs b/userland/capsule_linux_proofs/src/server/mod.rs new file mode 100644 index 0000000000..24b6b971d0 --- /dev/null +++ b/userland/capsule_linux_proofs/src/server/mod.rs @@ -0,0 +1,17 @@ +// 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 . + +pub mod handlers; diff --git a/userland/capsule_linux_proofs/src/tests.rs b/userland/capsule_linux_proofs/src/tests.rs index ae67ca5b14..a5860bdd6a 100644 --- a/userland/capsule_linux_proofs/src/tests.rs +++ b/userland/capsule_linux_proofs/src/tests.rs @@ -14,14 +14,36 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . - //! Every proof, by the thing it constrains. +mod alpine_index_tests; +mod auth_refusals; +mod auth_tests; +mod deb_chain_tests; +mod deb_file_tests; +mod deb_path_tests; +mod dir_children_tests; mod dirent_tests; mod elf_tests; mod exec_shebang_tests; +mod family_tests; +mod host_body_tests; +mod http_reply_tests; +mod inflate_bound_tests; +mod index_tests; +mod kali_anchor_tests; +mod key_tests; +mod listing_family_tests; +mod mutation; +mod mutation_tests; +mod pacman_desc_tests; +mod pacman_rsa_tests; mod resolve_tests; +mod route_tests; +mod sigframe_tests; +mod service; mod stack_words_tests; mod stat_tests; +mod tar_link_tests; mod tar_tests; mod wire_tests; diff --git a/userland/capsule_linux_proofs/src/tests/alpine_index_tests.rs b/userland/capsule_linux_proofs/src/tests/alpine_index_tests.rs new file mode 100644 index 0000000000..dc34fa058b --- /dev/null +++ b/userland/capsule_linux_proofs/src/tests/alpine_index_tests.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 . + +//! Alpine's own index, as the mirror served it on 2026-09-27 (v3.20 main +//! x86_64, signed 2026-07-16 by 6165ee59), through the capsule's signature +//! check with the key the capsule pins and a real RSA verifier. A failure +//! on the device that passes here is in the crypto service, not the parse. + +use rsa::pkcs8::DecodePublicKey; +use rsa::{Pkcs1v15Sign, RsaPublicKey}; + +use crate::install::auth::keys::spki; +use crate::install::auth::signature::signed_index; + +const INDEX: &[u8] = include_bytes!("../../vectors/alpine/APKINDEX-v3.20-main-x86_64.tar.gz"); + +fn rsa(key: &[u8], sig: &[u8], hashid: u8, digest: &[u8]) -> bool { + let Ok(k) = RsaPublicKey::from_public_key_der(key) else { + return false; + }; + match hashid { + 3 => k.verify(Pkcs1v15Sign::new::(), digest, sig).is_ok(), + 0 => k.verify(Pkcs1v15Sign::new::(), digest, sig).is_ok(), + _ => false, + } +} + +#[test] +fn the_real_index_verifies_under_the_pinned_key() { + assert!(spki(b"alpine-devel@lists.alpinelinux.org-6165ee59.rsa.pub").is_some()); + assert!(signed_index(INDEX, &rsa).is_some(), "the capsule's check refused a good index"); +} + +#[test] +fn one_flipped_byte_in_the_index_is_refused() { + let mut bad = INDEX.to_vec(); + let at = bad.len() - 100; + bad[at] ^= 1; + assert!(signed_index(&bad, &rsa).is_none()); +} 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/src/tests/deb_chain_tests.rs b/userland/capsule_linux_proofs/src/tests/deb_chain_tests.rs new file mode 100644 index 0000000000..0d2a66f26c --- /dev/null +++ b/userland/capsule_linux_proofs/src/tests/deb_chain_tests.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 . + +//! The chain an install walks, on an archive Debian's own tools made: +//! Release.gpg over Release, Release over Packages, Packages over each .deb. + +use nonos_openpgp::{dearmor, keys, verify}; + +use super::service::Service; +use crate::install::deb::packages::stanzas; +use crate::install::deb::release::sums; +use crate::install::unpacked::decompressed; + +pub const ARCHIVE: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/vectors/deb"); + +pub fn read(path: &str) -> Vec { + std::fs::read(format!("{ARCHIVE}/{path}")).expect(path) +} + +#[test] +fn the_release_verifies_under_the_archive_key_only() { + let ring = keys(&dearmor(&read("archive-key.asc")).expect("armor")).expect("key"); + let sig = dearmor(&read("dists/nonos/Release.gpg")).expect("armor"); + let release = read("dists/nonos/Release"); + assert!(verify(&Service, &ring, &sig, &release).is_ok()); + let mut changed = release.clone(); + changed[0] ^= 0x20; + assert!(verify(&Service, &ring, &sig, &changed).is_err(), "a changed Release"); +} + +#[test] +fn every_index_the_release_lists_matches_it() { + let listed = sums(&String::from_utf8(read("dists/nonos/Release")).expect("text")); + assert_eq!(listed.len(), 2); + for s in listed { + let bytes = read(&format!("dists/nonos/{}", s.path)); + assert_eq!((bytes.len(), nonos_hash::sha256(&bytes)), (s.size, s.sha256), "{}", s.path); + } +} + +#[test] +fn the_index_names_every_package_by_its_real_checksum() { + let packed = decompressed(&read("dists/nonos/main/binary-amd64/Packages.xz")).expect("xz"); + assert_eq!(packed, read("dists/nonos/main/binary-amd64/Packages")); + let records = stanzas(&String::from_utf8(packed).expect("text")); + assert_eq!(records.len(), 3); + for r in &records { + assert_eq!(Some(nonos_hash::sha256(&read(&r.filename))), r.sha256, "{}", r.name); + } + let hello = records.iter().find(|r| r.name == "nonos-hello").expect("nonos-hello"); + assert_eq!( + hello.depends, + [vec!["libnonos1", "libnonos-alt"], vec!["missing-alt", "libnonos-virtual"]] + ); + let lib = records.iter().find(|r| r.name == "libnonos1").expect("libnonos1"); + assert_eq!(lib.provides, ["libnonos-virtual"]); +} diff --git a/userland/capsule_linux_proofs/src/tests/deb_file_tests.rs b/userland/capsule_linux_proofs/src/tests/deb_file_tests.rs new file mode 100644 index 0000000000..fae5f4fb19 --- /dev/null +++ b/userland/capsule_linux_proofs/src/tests/deb_file_tests.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 . + +//! Each .deb opens to exactly the files dpkg-deb was given, whichever of +//! xz, zstd or gzip its data member is, with dpkg's `./` gone from names. + +use super::deb_chain_tests::read; +use crate::install::deb::ar::members; +use crate::install::tar::walk; +use crate::install::tar_kind::Kind; +use crate::install::unpacked::unpacked; + +fn opened(path: &str) -> crate::install::tar::Walk { + let deb = read(path); + let parts = members(&deb).expect("an ar archive"); + let names: Vec<&[u8]> = parts.iter().map(|(n, _)| *n).collect(); + assert_eq!(parts[0], (b"debian-binary".as_slice(), b"2.0\n".as_slice())); + assert!(names[1].starts_with(b"control.tar") && names[2].starts_with(b"data.tar"), "{path}"); + walk(&unpacked(parts[2].1).expect("the data member decompresses")) +} + +fn file<'a>(w: &'a crate::install::tar::Walk, name: &str) -> Option<&'a [u8]> { + w.entries + .iter() + .find(|e| e.name == name.as_bytes() && matches!(e.kind, Kind::File)) + .map(|e| e.body.as_slice()) +} + +#[test] +fn an_xz_data_member_opens() { + let w = opened("pool/main/n/nonos-hello/nonos-hello_1.0_amd64.deb"); + assert_eq!(file(&w, "usr/bin/nonos-hello"), Some(b"\x7fELF nonos hello\n".as_slice())); + assert_eq!(w.dropped, 0); +} + +#[test] +fn a_zstd_data_member_opens_with_its_link() { + let w = opened("pool/main/l/libnonos1/libnonos1_1.0_amd64.deb"); + assert_eq!(file(&w, "usr/lib/libnonos.so.1"), Some(b"\x7fELF libnonos\n".as_slice())); + let link = w.entries.iter().find(|e| e.name == b"usr/lib/libnonos.so").expect("the link"); + assert!(matches!(&link.kind, Kind::Symlink(to) if to == b"libnonos.so.1")); +} + +#[test] +fn a_gzip_data_member_opens() { + let w = opened("pool/main/n/nonos-gz/nonos-gz_1.0_amd64.deb"); + assert_eq!(file(&w, "usr/share/nonos/gz"), Some(b"gzip member\n".as_slice())); + assert!(w.entries.iter().all(|e| !e.name.starts_with(b"./")), "no ./ survives"); +} diff --git a/userland/capsule_linux_proofs/src/tests/deb_path_tests.rs b/userland/capsule_linux_proofs/src/tests/deb_path_tests.rs new file mode 100644 index 0000000000..494e1fc862 --- /dev/null +++ b/userland/capsule_linux_proofs/src/tests/deb_path_tests.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 pool path in a Packages stanza stays under the mirror's root. + +use crate::install::deb::packages::stanzas; + +#[test] +fn a_pool_path_that_leaves_the_root_is_dropped() { + let sum = "0".repeat(64); + for bad in ["../../etc/x.deb", "/abs/x.deb", "pool/./x.deb", "pool//x.deb", "pool/x.tar"] { + let text = format!("Package: x\nVersion: 1\nFilename: {bad}\nSHA256: {sum}\n"); + assert!(stanzas(&text).is_empty(), "{bad}"); + } +} diff --git a/userland/capsule_linux_proofs/src/tests/dir_children_tests.rs b/userland/capsule_linux_proofs/src/tests/dir_children_tests.rs new file mode 100644 index 0000000000..895233a9b0 --- /dev/null +++ b/userland/capsule_linux_proofs/src/tests/dir_children_tests.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 directory's children come from a listing that matches bytes, so a +//! sibling whose name only begins the same way must not leak in. + +use crate::dir_children::children; + +fn keys(all: &[&str]) -> Vec { + all.iter().map(|k| String::from(*k)).collect() +} + +#[test] +fn a_sibling_tree_is_not_a_child_of_the_root() { + let listed = keys(&[ + "/linux/usr/bin/jq", + "/linux/etc/os-release", + "/linux-deb/usr/bin/jq", + "/linux-pacman/usr/bin/nmap", + "/linux-private/7/tmp/x", + ]); + assert_eq!(children(b"/linux", listed), ["usr", "etc"]); +} + +#[test] +fn every_file_below_is_cut_to_its_first_name_and_seen_once() { + let listed = keys(&["/linux/usr/bin/a", "/linux/usr/bin/b", "/linux/usr/lib/c"]); + assert_eq!(children(b"/linux/usr", listed), ["bin", "lib"]); +} + +#[test] +fn a_file_named_like_the_directory_plus_a_suffix_is_not_inside_it() { + let listed = keys(&["/linux/usr/bin/jq", "/linux/usr/bin/jq-extra/x"]); + assert_eq!(children(b"/linux/usr/bin/jq", listed), Vec::::new()); +} 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/family_tests.rs b/userland/capsule_linux_proofs/src/tests/family_tests.rs new file mode 100644 index 0000000000..e6047384fc --- /dev/null +++ b/userland/capsule_linux_proofs/src/tests/family_tests.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 family is named by the system, never guessed from the package, and +//! each one lives in a tree of its own. + +use crate::family::{places, split, Family}; + +#[test] +fn a_prefix_names_the_family_and_is_not_part_of_the_package() { + assert_eq!(split("deb:jq"), (Family::Debian, "jq")); + assert_eq!(split("pacman:nmap"), (Family::Pacman, "nmap")); + assert_eq!(split("jq"), (Family::Alpine, "jq")); +} + +#[test] +fn a_bare_name_is_alpine_even_when_it_looks_like_another_family() { + for name in ["debjq", "deb-jq", "pacman-contrib", "Deb:jq", " deb:jq"] { + assert_eq!(split(name), (Family::Alpine, name), "{name}"); + } +} + +#[test] +fn alpine_keeps_the_tree_it_always_had() { + assert_eq!(places(Family::Alpine), (&b"/linux"[..], &b"/nonos/linux/apps"[..])); +} + +#[test] +fn no_two_families_share_a_tree_or_a_record() { + let all = [Family::Alpine, Family::Debian, Family::Pacman].map(places); + for (i, a) in all.iter().enumerate() { + for b in &all[i + 1..] { + assert_ne!(a.0, b.0); + assert_ne!(a.1, b.1); + // Neither tree is inside another, so no guest path reaches across. + assert!(!b.0.starts_with(&[a.0, b"/"].concat()) && !a.0.starts_with(b.0)); + } + } +} + +#[test] +fn records_sit_outside_every_tree() { + for family in [Family::Alpine, Family::Debian, Family::Pacman] { + let rec = places(family).1; + for tree in [Family::Alpine, Family::Debian, Family::Pacman].map(|f| places(f).0) { + assert!(!rec.starts_with(tree), "{:?}", family); + } + } +} diff --git a/userland/capsule_linux_proofs/src/tests/host_body_tests.rs b/userland/capsule_linux_proofs/src/tests/host_body_tests.rs new file mode 100644 index 0000000000..2a124e4cdd --- /dev/null +++ b/userland/capsule_linux_proofs/src/tests/host_body_tests.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 . + +//! What the capsule sends to connect by host is what net.sockets reads. +//! The capsule once sent the host length as one byte, which the server read +//! as two, so every connect was refused as a bad length; the two ends are +//! now tested together. + +use crate::host_body::host_body; +use crate::server::handlers::parse_host::parse; + +#[test] +fn the_server_reads_back_what_the_capsule_sent() { + for (handle, port, host) in [ + (7u32, 8080u16, &b"10.0.2.2"[..]), + (0xDEAD_BEEF, 443, b"kali.download"), + (1, 80, b"a"), + (2, 1, &[b'x'; 253][..]), + ] { + let body = host_body(handle, port, host).expect("encodes"); + assert_eq!(parse(&body), Some((handle, port, host))); + } +} + +#[test] +fn a_host_the_server_would_refuse_is_never_sent() { + assert_eq!(host_body(1, 80, b""), None); + assert_eq!(host_body(1, 80, &[b'x'; 254]), None); +} + +#[test] +fn the_old_one_byte_length_is_what_the_server_refused() { + let mut old = vec![7, 0, 0, 0, 0x90, 0x1F, 8]; + old.extend_from_slice(b"10.0.2.2"); + assert_eq!(parse(&old), None); +} diff --git a/userland/capsule_linux_proofs/src/tests/http_reply_tests.rs b/userland/capsule_linux_proofs/src/tests/http_reply_tests.rs new file mode 100644 index 0000000000..e4469f7997 --- /dev/null +++ b/userland/capsule_linux_proofs/src/tests/http_reply_tests.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 . + +//! A reply ends where its Content-Length says, not at the first empty read: +//! a mirror still fetching upstream sends nothing for a while, and reading +//! that as the end refused Kali's Release on a live boot. + +use crate::install::http_reply::{body, complete, framing}; +const OK: &[u8] = b"HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nContent-Length: 5\r\n\r\nhello"; + +#[test] +fn a_reply_is_complete_only_once_every_promised_byte_is_in() { + for cut in 0..OK.len() { + assert!(!complete(&OK[..cut]), "complete at {cut} of {}", OK.len()); + } + assert!(complete(OK)); + assert_eq!(body(OK.to_vec()).as_deref(), Some(&b"hello"[..])); +} + +#[test] +fn the_length_header_is_read_whatever_its_case() { + let r = b"HTTP/1.1 200 OK\r\ncontent-LENGTH: 3 \r\n\r\nabc"; + assert_eq!(framing(r), Some((r.len() - 3, Some(3)))); + assert!(complete(r)); +} + +#[test] +fn a_body_short_of_its_length_is_refused() { + let short = b"HTTP/1.1 200 OK\r\nContent-Length: 10\r\n\r\nabc"; + assert!(!complete(short)); + assert_eq!(body(short.to_vec()), None); +} + +#[test] +fn bytes_past_the_length_are_not_part_of_the_body() { + let long = b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nabXYZ"; + assert_eq!(body(long.to_vec()).as_deref(), Some(&b"ab"[..])); +} + +#[test] +fn anything_but_a_200_is_nothing() { + for r in [ + &b"HTTP/1.1 404 Not Found\r\nContent-Length: 3\r\n\r\nnop"[..], + b"HTTP/1.1 302 Found\r\nLocation: x\r\n\r\n", + b"HTTP/1.1 500 x 200 y\r\n\r\n", + ] { + assert_eq!(body(r.to_vec()), None); + } +} + +#[test] +fn without_a_length_the_whole_rest_is_the_body_and_never_complete() { + let r = b"HTTP/1.1 200 OK\r\nConnection: close\r\n\r\nall of it"; + assert!(!complete(r)); + assert_eq!(body(r.to_vec()).as_deref(), Some(&b"all of it"[..])); +} + +#[test] +fn a_status_line_without_a_reason_is_still_read() { + let bare = b"HTTP/1.1 200\r\nContent-Length: 1\r\n\r\nx"; + assert_eq!(body(bare.to_vec()).as_deref(), Some(&b"x"[..])); +} 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/capsule_linux_proofs/src/tests/inflate_bound_tests.rs b/userland/capsule_linux_proofs/src/tests/inflate_bound_tests.rs new file mode 100644 index 0000000000..30bf6669b9 --- /dev/null +++ b/userland/capsule_linux_proofs/src/tests/inflate_bound_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 . + +//! An index may inflate past the inflater's 4 MiB default: Alpine's +//! community index is 8 MB and Kali's Packages 85 MB, and on a live boot the +//! default refused both after they had downloaded and verified. The +//! installer's bound admits them and still stops a stream that runs past it. + +use crate::install::unpacked::{decompressed, MAX_INFLATED}; + +const LARGE: &[u8] = include_bytes!("../../vectors/inflate/large.gz"); +const SIX_MIB: usize = 6 << 20; + +#[test] +fn the_default_bound_is_what_refused_a_real_index() { + assert_eq!(nonos_inflate::gunzip(LARGE), None); + assert_eq!(nonos_inflate::members(LARGE).map(|m| m.len()), None); +} + +#[test] +fn the_installer_bound_opens_it_whole() { + assert_eq!(decompressed(LARGE).map(|b| b.len()), Some(SIX_MIB)); + let parts = nonos_inflate::members_within(LARGE, MAX_INFLATED).expect("two members"); + assert_eq!(parts.iter().map(|m| m.body.len()).sum::(), SIX_MIB); + assert_eq!((parts.len(), parts[1].end), (2, LARGE.len())); +} + +#[test] +fn a_bound_below_the_output_still_refuses() { + assert_eq!(nonos_inflate::gunzip_within(LARGE, SIX_MIB - 1), None); + assert_eq!(nonos_inflate::members_within(LARGE, SIX_MIB - 1).map(|m| m.len()), None); + assert!(nonos_inflate::gunzip_within(LARGE, SIX_MIB).is_some()); +} diff --git a/userland/capsule_linux_proofs/src/tests/kali_anchor_tests.rs b/userland/capsule_linux_proofs/src/tests/kali_anchor_tests.rs new file mode 100644 index 0000000000..9ffb4175a0 --- /dev/null +++ b/userland/capsule_linux_proofs/src/tests/kali_anchor_tests.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 . + +//! The Kali anchor this image pins, checked against Kali's own files: the key +//! file holds exactly the 2025 archive key, and the kali-rolling Release +//! fetched on 2026-09-27 verifies under it, the way an install checks one. + +use nonos_openpgp::{dearmor, keys, verify}; + +use super::service::Service; +use crate::install::deb::release::sums; + +const KEY: &[u8] = include_bytes!("../../../capsule_linux/keys/kali/archive-key-2025.asc"); +const RELEASE: &[u8] = include_bytes!("../../vectors/kali/Release"); +const RELEASE_GPG: &[u8] = include_bytes!("../../vectors/kali/Release.gpg"); + +fn hex(b: &[u8]) -> String { + b.iter().map(|x| format!("{x:02X}")).collect() +} + +#[test] +fn the_pinned_file_is_exactly_the_2025_archive_key() { + let ring = keys(&dearmor(KEY).expect("armored")).expect("a key"); + let primaries: Vec = ring.iter().map(|k| hex(&k.fingerprint)).collect(); + assert_eq!(primaries[0], "827C8569F2518CC677FECA1AED65462EC8D5E4C5"); + // The key Kali lost access to is not in the anchor. + assert!(primaries.iter().all(|f| !f.ends_with("ED444FF07D8D0BF6"))); +} + +#[test] +fn kali_rolling_release_verifies_under_the_pin() { + let ring = keys(&dearmor(KEY).expect("armored")).expect("a key"); + let sig = dearmor(RELEASE_GPG).unwrap_or_else(|| RELEASE_GPG.to_vec()); + let ok = verify(&Service, &ring, &sig, RELEASE).expect("Kali's Release verifies"); + assert_eq!(hex(&ok.fingerprint), "827C8569F2518CC677FECA1AED65462EC8D5E4C5"); + let mut changed = RELEASE.to_vec(); + changed[10] ^= 1; + assert!(verify(&Service, &ring, &sig, &changed).is_err(), "a changed Release"); +} + +#[test] +fn the_release_lists_main_packages_for_amd64() { + let listed = sums(core::str::from_utf8(RELEASE).expect("text")); + let paths: Vec<&str> = listed.iter().map(|s| s.path.as_str()).collect(); + assert!(paths.contains(&"main/binary-amd64/Packages.gz"), "{} entries", paths.len()); +} 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/listing_family_tests.rs b/userland/capsule_linux_proofs/src/tests/listing_family_tests.rs new file mode 100644 index 0000000000..6dfbe52267 --- /dev/null +++ b/userland/capsule_linux_proofs/src/tests/listing_family_tests.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 kernel turns a listing's tail into the name the personality reads, +//! and the personality splits that name back into family and package. The +//! two ends are tested together, so they cannot drift apart. + +use crate::family::{split, Family}; +use crate::listing_family::package_arg; + +#[test] +fn each_namespace_reaches_its_family_with_the_package_intact() { + for (tail, family, pkg) in [ + ("jq", Family::Alpine, "jq"), + ("kali.jq", Family::Debian, "jq"), + ("kali.libc6", Family::Debian, "libc6"), + ("blackarch.nmap", Family::Pacman, "nmap"), + ("g++", Family::Alpine, "g++"), + ] { + let arg = package_arg(tail).expect(tail); + assert_eq!(split(&arg), (family, pkg), "{tail}"); + } +} + +#[test] +fn a_namespace_with_no_package_is_refused() { + for tail in ["", ".", ".jq", "kali.", "kali..jq", "blackarch.", "blackarch..x"] { + assert_eq!(package_arg(tail), None, "{tail:?}"); + } +} + +#[test] +fn a_listing_cannot_smuggle_a_family_prefix_of_its_own() { + // The personality would read `deb:jq` as Debian, so the kernel never + // passes a tail with a colon in it, under any namespace. + for tail in ["deb:jq", "pacman:nmap", "kali.deb:jq", "blackarch.deb:x", "jq:"] { + assert_eq!(package_arg(tail), None, "{tail}"); + } +} diff --git a/userland/capsule_linux_proofs/src/tests/mutation.rs b/userland/capsule_linux_proofs/src/tests/mutation.rs new file mode 100644 index 0000000000..642aab9044 --- /dev/null +++ b/userland/capsule_linux_proofs/src/tests/mutation.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 . + +//! Damage the way hostile bytes arrive: a flipped bit, an overwritten byte, +//! a truncation, or a separator inserted where a parser splits. + +pub fn damage(s: &mut u64, v: &mut Vec) { + let mut next = || { + *s ^= *s << 13; + *s ^= *s >> 7; + *s ^= *s << 17; + *s + }; + let at = (next() % v.len().max(1) as u64) as usize; + match next() % 4 { + 0 if at < v.len() => v[at] ^= 1 << (next() % 8), + 1 if at < v.len() => v[at] = next() as u8, + 2 => v.truncate(at), + _ => v.insert(at.min(v.len()), b"\n:%/ ."[(next() % 6) as usize]), + } +} diff --git a/userland/capsule_linux_proofs/src/tests/mutation_tests.rs b/userland/capsule_linux_proofs/src/tests/mutation_tests.rs new file mode 100644 index 0000000000..c165c1399f --- /dev/null +++ b/userland/capsule_linux_proofs/src/tests/mutation_tests.rs @@ -0,0 +1,82 @@ +// 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 . + +//! Seeded mutation of every reader the Debian and pacman installs added, +//! not coverage-guided (no libFuzzer is vendored). Damaged .debs, indexes, +//! Releases and desc records must return, in a debug build, without a panic. + +use super::deb_chain_tests::read; +use crate::install::deb::ar::members; +use crate::install::deb::packages::stanzas; +use crate::install::deb::release::sums; +use crate::install::pacman::desc::records; +use crate::install::tar::walk; +use crate::install::unpacked::unpacked; + +use super::mutation::damage; + +const ROUNDS: usize = 1500; + +#[test] +fn damaged_debs_never_panic() { + let mut s = 0xDEB5_EEDu64; + for deb in [ + "pool/main/n/nonos-hello/nonos-hello_1.0_amd64.deb", + "pool/main/l/libnonos1/libnonos1_1.0_amd64.deb", + "pool/main/n/nonos-gz/nonos-gz_1.0_amd64.deb", + ] { + let clean = read(deb); + for _ in 0..ROUNDS { + let mut v = clean.clone(); + damage(&mut s, &mut v); + for (_, body) in members(&v).unwrap_or_default() { + let _ = unpacked(body).map(|t| walk(&t)); + } + } + } +} + +#[test] +fn damaged_indexes_never_panic() { + let mut s = 0x1DE7_5EEDu64; + let desc = + b"%FILENAME%\nx.pkg.tar.zst\n\n%NAME%\nx\n\n%VERSION%\n1-1\n\n%DEPENDS%\na>=1\n".to_vec(); + for clean in [read("dists/nonos/main/binary-amd64/Packages"), read("dists/nonos/Release"), desc] + { + for _ in 0..ROUNDS { + let mut v = clean.clone(); + damage(&mut s, &mut v); + let text = String::from_utf8_lossy(&v); + let _ = (stanzas(&text), sums(&text), records(&text)); + } + } +} + +#[test] +fn a_damaged_signal_frame_never_panics_returning() { + use crate::sigframe::{build, returned}; + let mut s = 0x516E_A100u64; + let mut base = [0u64; 18]; + base[15] = 0x7fff_ff00_0000; + let (_, buf, _) = build(&base, 0x4000, 0x4008, 11, 0, None, false).expect("frame"); + for _ in 0..ROUNDS { + let mut v = buf.clone(); + damage(&mut s, &mut v); + // rt_sigreturn reads the ucontext at the guest's rsp: any bytes there. + let _ = returned(&v); + let _ = v.first().map(|_| returned(&v[v.len().min(8)..])); + } +} diff --git a/userland/capsule_linux_proofs/src/tests/pacman_desc_tests.rs b/userland/capsule_linux_proofs/src/tests/pacman_desc_tests.rs new file mode 100644 index 0000000000..433c0673b6 --- /dev/null +++ b/userland/capsule_linux_proofs/src/tests/pacman_desc_tests.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 . + +//! A repository `desc` record: every field an install uses, and every +//! record that could not be installed faithfully refused whole. + +use crate::install::pacman::desc::records; + +const SUM: &str = "5d41402abc4b2a76b9719d911017c592aaaabbbbccccddddeeeeffff00001111"; + +fn desc(filename: &str, sum: &str) -> String { + format!( + "%FILENAME%\n{filename}\n\n%NAME%\nnmap\n\n%VERSION%\n7.95-2\n\n%SHA256SUM%\n{sum}\n\n\ + %PGPSIG%\niQEzBAABCAAd\nFiEE\n\n%DEPENDS%\nglibc>=2.35\nlibpcap\nlua54=5.4.6\n\n\ + %PROVIDES%\nnmap-bin=7.95\n\n" + ) +} + +#[test] +fn every_field_an_install_uses_is_read() { + let r = records(&desc("nmap-7.95-2-x86_64.pkg.tar.zst", SUM)).expect("a whole record"); + assert_eq!((r.name.as_str(), r.version.as_str()), ("nmap", "7.95-2")); + assert_eq!(r.filename, "nmap-7.95-2-x86_64.pkg.tar.zst"); + assert_eq!(r.sha256.map(|s| s[..2].to_vec()), Some(vec![0x5d, 0x41])); + assert_eq!(r.pgpsig, "iQEzBAABCAAdFiEE", "a wrapped signature is joined"); + assert_eq!(r.depends, ["glibc", "libpcap", "lua54"], "constraints are dropped"); + assert_eq!(r.provides, ["nmap-bin"]); +} + +#[test] +fn a_file_name_that_leaves_the_repository_is_refused() { + for bad in ["../../etc/shadow", "a/b.pkg.tar.zst", "..", "."] { + assert!(records(&desc(bad, SUM)).is_none(), "{bad}"); + } +} + +#[test] +fn a_record_without_a_usable_checksum_is_refused() { + for bad in ["", "abc", &SUM[1..], &SUM.replace('5', "g")] { + assert!(records(&desc("x.pkg.tar.zst", bad)).is_none(), "{bad:?}"); + } +} diff --git a/userland/capsule_linux_proofs/src/tests/pacman_rsa_tests.rs b/userland/capsule_linux_proofs/src/tests/pacman_rsa_tests.rs new file mode 100644 index 0000000000..db8b8c22d6 --- /dev/null +++ b/userland/capsule_linux_proofs/src/tests/pacman_rsa_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 . + +//! The RSA request the capsule sends for an OpenPGP signature, checked the +//! way the crypto service checks it, against GnuPG's own signatures. + +use nonos_openpgp::{keys, verify, Material}; +use rsa::pkcs8::DecodePublicKey; +use rsa::traits::PublicKeyParts; +use rsa::{BigUint, RsaPublicKey}; + +use super::service::Service; + +use crate::install::pgp::request::request; + +const VECTORS: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/../openpgp/tests/vectors"); + +fn read(name: &str) -> Vec { + std::fs::read(format!("{VECTORS}/{name}")).expect(name) +} + +#[test] +fn the_spki_carries_exactly_the_key() { + let ring = keys(&read("rsa.pub")).expect("rsa.pub"); + let Material::Rsa { n, e } = &ring[0].material else { panic!("not RSA") }; + let r = request(n, e, &[1], 8).expect("a request"); + let key = RsaPublicKey::from_public_key_der(&r.spki).expect("DER the service parses"); + assert_eq!(key.n(), &BigUint::from_bytes_be(n)); + assert_eq!(key.e(), &BigUint::from_bytes_be(e)); + assert_eq!(r.sig.len(), n.len(), "padded to the modulus width"); +} + +#[test] +fn gnupg_rsa_signatures_verify_through_the_request() { + let data: Vec = (0..70000u32).map(|i| ((i * 131 + 17) % 251) as u8).collect(); + let ring = keys(&read("rsa.pub")).expect("rsa.pub"); + for sig in ["rsa-sha256.sig", "rsa-sha512.sig"] { + assert!(verify(&Service, &ring, &read(sig), &data).is_ok(), "{sig}"); + } + let mut changed = data.clone(); + changed[0] ^= 1; + assert!(verify(&Service, &ring, &read("rsa-sha256.sig"), &changed).is_err()); +} 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] diff --git a/userland/capsule_linux_proofs/src/tests/route_tests.rs b/userland/capsule_linux_proofs/src/tests/route_tests.rs new file mode 100644 index 0000000000..9aa994a0ab --- /dev/null +++ b/userland/capsule_linux_proofs/src/tests/route_tests.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 . + +//! Which fetches leave the mixnet: only mirrors on private or link-local +//! addresses. Everything else, including what only looks close, stays on it. + +use crate::route::is_local; + +#[test] +fn private_and_link_local_mirrors_go_direct() { + for ip in + ["10.0.2.2", "10.255.255.255", "172.16.0.1", "172.31.9.9", "192.168.1.10", "169.254.3.4"] + { + assert!(is_local(ip), "{ip}"); + } +} + +#[test] +fn public_addresses_stay_on_the_mixnet() { + for ip in [ + "151.101.66.132", + "172.15.0.1", + "172.32.0.1", + "192.169.0.1", + "169.255.0.1", + "11.0.0.1", + "8.8.8.8", + ] { + assert!(!is_local(ip), "{ip}"); + } +} + +#[test] +fn anything_not_a_plain_dotted_quad_stays_on_the_mixnet() { + for ip in + ["", "10", "10.0.2", "10.0.2.2.5", "10.0.2.256", "10.0.2.x", "kali.download", " 10.0.2.2"] + { + assert!(!is_local(ip), "{ip:?}"); + } +} diff --git a/userland/capsule_linux_proofs/src/tests/service.rs b/userland/capsule_linux_proofs/src/tests/service.rs new file mode 100644 index 0000000000..f20b83f3e5 --- /dev/null +++ b/userland/capsule_linux_proofs/src/tests/service.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 crypto service's RSA checks, standing in for the IPC call, fed the +//! exact request the capsule builds. + +use nonos_openpgp::Verifier; +use rsa::pkcs8::DecodePublicKey; +use rsa::sha2::{Sha256, Sha512}; +use rsa::{Pkcs1v15Sign, RsaPublicKey}; + +use crate::install::pgp::request::request; + +/// capsule_crypto's rsa_scheme for scheme 0, standing in for the IPC call. +pub struct Service; + +impl Verifier for Service { + fn rsa(&self, n: &[u8], e: &[u8], sig: &[u8], hash: u8, digest: &[u8]) -> bool { + let Some(r) = request(n, e, sig, hash) else { return false }; + let Ok(key) = RsaPublicKey::from_public_key_der(&r.spki) else { return false }; + match r.hashid { + 0 => key.verify(Pkcs1v15Sign::new::(), digest, &r.sig).is_ok(), + 2 => key.verify(Pkcs1v15Sign::new::(), digest, &r.sig).is_ok(), + _ => false, + } + } + + fn ed25519(&self, _: &[u8; 32], _: &[u8; 64], _: &[u8]) -> bool { + false + } +} diff --git a/userland/capsule_linux_proofs/src/tests/sigframe_tests.rs b/userland/capsule_linux_proofs/src/tests/sigframe_tests.rs new file mode 100644 index 0000000000..902b9d192e --- /dev/null +++ b/userland/capsule_linux_proofs/src/tests/sigframe_tests.rs @@ -0,0 +1,143 @@ +// 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 signal frame a handler enters through, and the frame it returns from, +//! are the same layout read two ways: build one, then read the sigcontext +//! back the way rt_sigreturn does, and the registers must be identical. This +//! is what lets a program's handler run and return to where it was. + +use crate::sigframe::{build, returned, SIGCONTEXT_OFF, WORDS}; + +const RSP: usize = 15; +const RAX: usize = 13; + +fn regs() -> [u64; WORDS] { + let mut r = [0u64; WORDS]; + for (i, w) in r.iter_mut().enumerate() { + *w = 0x1111_0000 + i as u64; // a distinct value per register + } + r[RSP] = 0x7fff_ffe0_0000; // a plausible stack pointer, page aligned + r +} + +#[test] +fn a_returning_frame_restores_the_registers_the_handler_was_entered_over() { + let saved = regs(); + let (frame, buf, enter) = + build(&saved, 0xdead_beef, 0xca11, 11, 0x1234, None, false).expect("frame"); + // The handler is entered at the frame, below the old stack, 16-byte down 8. + assert!(frame < saved[RSP] - 128); + assert_eq!(enter[RSP], frame); + assert_eq!(enter[16], 0xdead_beef); // rip = handler + assert_eq!(enter[8], 11); // rdi = signum + assert_eq!(enter[12], frame + 8); // rdx = &ucontext + // rt_sigreturn reads the ucontext the guest's rsp points at: frame + 8. + let uc = &buf[8..]; + assert_eq!(returned(uc).unwrap(), saved); +} + +#[test] +fn the_syscall_return_value_rides_in_the_saved_rax() { + let mut saved = regs(); + saved[RAX] = 0; // as the thread trapped, before we set the reply + saved[RAX] = 42; // the value the interrupted syscall returns + let (_, buf, _) = build(&saved, 1, 2, 3, 0, None, false).expect("frame"); + assert_eq!(returned(&buf[8..]).unwrap()[RAX], 42); +} + +#[test] +fn a_stack_too_low_to_hold_a_frame_is_refused() { + let mut low = regs(); + low[RSP] = 64; // below the red zone plus a frame + assert!(build(&low, 1, 2, 3, 0, None, false).is_none()); +} + +#[test] +fn the_sigcontext_sits_where_the_ucontext_says() { + // uc_mcontext is at SIGCONTEXT_OFF within the ucontext, which is at frame+8. + let saved = regs(); + let (_, buf, _) = build(&saved, 1, 2, 3, 0, None, false).expect("frame"); + let at = 8 + SIGCONTEXT_OFF; + let r8 = u64::from_le_bytes(buf[at..at + 8].try_into().unwrap()); + assert_eq!(r8, saved[0]); +} + +#[test] +fn the_registers_and_mask_sit_where_linux_programs_read_them() { + // Offsets within the ucontext, from musl's and glibc's own + // offsetof(ucontext_t, ...) on x86-64: uc_mcontext.gregs[REG_R8] at 40, + // REG_RSP at 160, REG_RIP at 168, uc_sigmask at 296. The ucontext is at + // frame + 8, after the return address. + let saved = regs(); + let (_, buf, _) = build(&saved, 1, 2, 3, 0x0000_8000_0000_0001, None, false).expect("frame"); + let word = |at: usize| u64::from_le_bytes(buf[8 + at..8 + at + 8].try_into().unwrap()); + assert_eq!(word(40), saved[0]); // r8 + assert_eq!(word(160), saved[RSP]); + assert_eq!(word(168), saved[16]); // rip + assert_eq!(word(296), 0x0000_8000_0000_0001); // the mask +} + +/// A 32 KiB alternate stack well away from the thread's own stack. +const ALT: (u64, u64) = (0x7000_0000_0000, 0x8000); + +fn uc_stack(buf: &[u8]) -> (u64, u64, u64) { + let word = |at: usize| u64::from_le_bytes(buf[8 + at..8 + at + 8].try_into().unwrap()); + (word(16), word(24) & 0xffff_ffff, word(32)) // ss_sp, ss_flags, ss_size +} + +#[test] +fn an_onstack_handler_is_entered_at_the_top_of_the_alternate_stack() { + let saved = regs(); + let (frame, buf, enter) = build(&saved, 1, 2, 3, 0, Some(ALT), true).expect("frame"); + let (sp, size) = ALT; + assert!(frame > sp && frame < sp + size, "frame {frame:#x} is on the alternate stack"); + assert!(sp + size - frame < 512, "and at its top"); + assert_eq!(enter[RSP], frame); + // The thread's own rsp is what rt_sigreturn gives back. + assert_eq!(returned(&buf[8..]).unwrap(), saved); + // uc_stack names the alternate stack; the thread was not on it. + assert_eq!(uc_stack(&buf), (sp, 0, size)); +} + +#[test] +fn a_handler_without_sa_onstack_stays_on_the_thread_stack() { + let saved = regs(); + let (frame, buf, _) = build(&saved, 1, 2, 3, 0, Some(ALT), false).expect("frame"); + assert!(frame < saved[RSP] - 128 && frame > saved[RSP] - 1024); + assert_eq!(uc_stack(&buf), (ALT.0, 0, ALT.1)); +} + +#[test] +fn a_signal_on_the_alternate_stack_nests_below_it_there() { + let mut saved = regs(); + saved[RSP] = ALT.0 + 0x6000; // already running a handler there + let (frame, buf, _) = build(&saved, 1, 2, 3, 0, Some(ALT), true).expect("frame"); + assert!(frame < saved[RSP] - 128 && frame > ALT.0); + assert_eq!(uc_stack(&buf), (ALT.0, 1, ALT.1)); // SS_ONSTACK +} + +#[test] +fn a_frame_that_would_run_off_the_alternate_stack_is_refused() { + let mut saved = regs(); + saved[RSP] = ALT.0 + 0x200; // too near the base for another frame + assert!(build(&saved, 1, 2, 3, 0, Some(ALT), true).is_none()); +} + +#[test] +fn no_alternate_stack_reads_back_disabled() { + let (_, buf, _) = build(®s(), 1, 2, 3, 0, None, true).expect("frame"); + assert_eq!(uc_stack(&buf), (0, 2, 0)); // SS_DISABLE +} diff --git a/userland/capsule_linux_proofs/src/tests/stat_tests.rs b/userland/capsule_linux_proofs/src/tests/stat_tests.rs index 80055e6307..a4dc00eb46 100644 --- a/userland/capsule_linux_proofs/src/tests/stat_tests.rs +++ b/userland/capsule_linux_proofs/src/tests/stat_tests.rs @@ -31,7 +31,7 @@ fn u64_at(b: &[u8], at: usize) -> u64 { /// at 56, blocks at 64. #[test] fn a_regular_file_lands_in_the_right_fields() { - let s = build(4096, false); + let s = build(4096, false, 7); assert_eq!(s.len(), STAT_LEN); assert_eq!(u64_at(&s, 16), 1); assert_eq!(u32_at(&s, 24), S_IFREG | 0o644); @@ -42,22 +42,31 @@ fn a_regular_file_lands_in_the_right_fields() { #[test] fn a_directory_says_so_in_the_mode() { - let s = build(0, true); + let s = build(0, true, 7); assert_eq!(u32_at(&s, 24), S_IFDIR | 0o755); assert_eq!(u64_at(&s, 48), 0); } #[test] fn block_count_rounds_up_to_the_next_five_hundred_and_twelve() { - assert_eq!(u64_at(&build(1, false), 64), 1); - assert_eq!(u64_at(&build(512, false), 64), 1); - assert_eq!(u64_at(&build(513, false), 64), 2); + assert_eq!(u64_at(&build(1, false, 7), 64), 1); + assert_eq!(u64_at(&build(512, false, 7), 64), 1); + assert_eq!(u64_at(&build(513, false, 7), 64), 2); } #[test] fn everything_unknown_is_left_at_zero() { - let s = build(10, false); - for at in [0, 8, 40, 72, 88, 104] { + let s = build(10, false, 7); + // st_ino at 8 is known now: `the_inode_given_is_the_inode_reported`. + for at in [0, 40, 72, 88, 104] { assert_eq!(u64_at(&s, at), 0, "offset {at} should be untouched"); } } + +#[test] +fn the_inode_given_is_the_inode_reported() { + // st_ino sits after st_dev, at byte 8. Every file used to report 0, and + // musl's loader took two libraries with one inode for the same file. + assert_eq!(u64_at(&build(10, false, 0xdead_beef), 8), 0xdead_beef); + assert_ne!(u64_at(&build(10, false, 1), 8), u64_at(&build(10, false, 2), 8)); +} diff --git a/userland/capsule_linux_proofs/src/tests/tar_link_tests.rs b/userland/capsule_linux_proofs/src/tests/tar_link_tests.rs new file mode 100644 index 0000000000..4def16479a --- /dev/null +++ b/userland/capsule_linux_proofs/src/tests/tar_link_tests.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 . + + +//! Every typeflag a package carries survives the walk, and what is dropped is +//! counted. `links.tar` is three archives end to end, as an apk is, written by +//! Python's tarfile in ustar, pax and GNU form. + +use crate::install::tar::{walk, Kind}; + +const LIBFFI: &[u8] = include_bytes!("../../vectors/libffi.tar"); +const LINKS: &[u8] = include_bytes!("../../vectors/links.tar"); + +fn link_of(name: &[u8], data: &[u8]) -> Option> { + walk(data).entries.into_iter().find(|e| e.name == name).and_then(|e| match e.kind { + Kind::Symlink(t) | Kind::Hardlink(t) => Some(t), + _ => None, + }) +} + +#[test] +fn the_soname_link_in_a_real_package_is_kept() { + let to = link_of(b"usr/lib/libffi.so.8", LIBFFI).expect("the soname link"); + assert_eq!(to, b"libffi.so.8.1.4"); + assert_eq!(walk(LIBFFI).dropped, 0, "a real package loses nothing"); +} + +#[test] +fn symbolic_and_hard_links_keep_their_targets() { + assert_eq!(link_of(b"usr/lib/libz.so.1", LINKS).as_deref(), Some(&b"libz.so.1.3"[..])); + let hard = walk(LINKS).entries.into_iter().find(|e| e.name == b"usr/lib/libz-copy.so"); + assert!(matches!(hard.map(|e| e.kind), Some(Kind::Hardlink(t)) if t == b"usr/lib/libz.so.1.3")); +} + +#[test] +fn long_paths_arrive_whole_in_every_form() { + let names: Vec> = walk(LINKS).entries.into_iter().map(|e| e.name).collect(); + for want in [ + format!("usr/share/{}/deep.txt", "p".repeat(120)), + format!("usr/share/{}/deep.txt", "g".repeat(120)), + format!("usr/lib/{}/libq.so.1.0", "q".repeat(95)), + ] { + assert!(names.contains(&want.clone().into_bytes()), "missing {}", &want[..30]); + } + let pax = link_of(b"usr/bin/short", LINKS).expect("a pax linkpath"); + assert_eq!(pax, format!("/usr/share/{}", "t".repeat(120)).into_bytes()); +} + +#[test] +fn devices_and_fifos_are_counted_not_silently_lost() { + let w = walk(LINKS); + assert_eq!(w.dropped, 2, "one fifo and one character device"); + assert!(w.entries.iter().all(|e| !e.name.starts_with(b"dev/"))); +} + +#[test] +fn file_bodies_are_the_bytes_written() { + let w = walk(LINKS); + let body = |n: &[u8]| w.entries.iter().find(|e| e.name == n).map(|e| e.body.clone()); + assert_eq!(body(b"usr/lib/libz.so.1.3").as_deref(), Some(&b"\x7fELF"[..])); + assert!(matches!(w.entries.iter().find(|e| e.name == b"usr/lib").map(|e| &e.kind), Some(Kind::Dir))); +} diff --git a/userland/capsule_linux_proofs/src/tests/tar_tests.rs b/userland/capsule_linux_proofs/src/tests/tar_tests.rs index e2f7b3d0b7..79e835472a 100644 --- a/userland/capsule_linux_proofs/src/tests/tar_tests.rs +++ b/userland/capsule_linux_proofs/src/tests/tar_tests.rs @@ -19,8 +19,9 @@ use crate::install::tar::entries; -/// libffi-3.4.6-r0.apk, decompressed. An independent reader sees one -/// regular file in it: usr/lib/libffi.so.8.1.4 at 38960 bytes. +/// libffi-3.4.6-r0.apk, decompressed. Python's tarfile sees two control +/// files, two directories, the library at 38960 bytes, and its soname +/// link usr/lib/libffi.so.8 -> libffi.so.8.1.4, each with a pax header. const PKG: &[u8] = include_bytes!("../../vectors/libffi.tar"); #[test] diff --git a/userland/capsule_linux_proofs/vectors/alpine/APKINDEX-v3.20-main-x86_64.tar.gz b/userland/capsule_linux_proofs/vectors/alpine/APKINDEX-v3.20-main-x86_64.tar.gz new file mode 100644 index 0000000000..4b387cf77a Binary files /dev/null and b/userland/capsule_linux_proofs/vectors/alpine/APKINDEX-v3.20-main-x86_64.tar.gz differ 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 0000000000..6d82d2a655 Binary files /dev/null and b/userland/capsule_linux_proofs/vectors/auth/APKINDEX.tar.gz differ diff --git a/userland/capsule_linux_proofs/vectors/auth/hello.apk b/userland/capsule_linux_proofs/vectors/auth/hello.apk new file mode 100644 index 0000000000..9318072344 Binary files /dev/null and b/userland/capsule_linux_proofs/vectors/auth/hello.apk differ 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 0000000000..5d9168f703 Binary files /dev/null and b/userland/capsule_linux_proofs/vectors/auth/swapped.apk differ 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 0000000000..f70a424459 Binary files /dev/null and b/userland/capsule_linux_proofs/vectors/auth/test_key.spki differ 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 0000000000..680312397b Binary files /dev/null and b/userland/capsule_linux_proofs/vectors/auth/untrusted.tar.gz differ diff --git a/userland/capsule_linux_proofs/vectors/deb/archive-key.asc b/userland/capsule_linux_proofs/vectors/deb/archive-key.asc new file mode 100644 index 0000000000..e9112e0029 --- /dev/null +++ b/userland/capsule_linux_proofs/vectors/deb/archive-key.asc @@ -0,0 +1,23 @@ +-----BEGIN PGP PUBLIC KEY BLOCK----- + +mQGNBGq5U+oBDADMOe+fasqr7w4ukpZUsXNn92Kci/gXFMVKkYu+0LFn/28J/CKb +wGTkyW0aR7qSkamhe2yTmyl0QvDILH+25ALtWnj5IwOD5AARpt5in10ZxtTEM0Kx +tBmvFTXScg6VpuYUUdBvBlbnyXDjoO9o07M+w3PRf7vUg9mD3iaLB4rINSVRrRfL +KPGHxdaoILXpbTDCBC0Ac54ZiNDGJvIurHeAZIe7r7W81ww7Ikbznfv3+mVoGnL5 +jyWt7bWPJWZLKSQQeUJxqhd4bs6YryuqZ1Xmno4YX6UgAN7DzOwM6H6yyTIqi+IP +H4he3kpH6meZTVL+GtxEZr15Z0T+SzkTQRS7RHpt9eanV16Wl2/vqeQiOPQqnsQQ +gqvSoW4O4m0MVAQvNghwCtxEoU9JmApioH8YLw+wK4bPkLaP73y+Iprl5ItxA4rp +/yXoG3ni/9ynszzqoSFSJLCmgxvYYNFu1ficFlgxQJxjtS9KeXdSCfJ5fZYTfqwY +j4PAs7d8FjcUFiEAEQEAAbQqTk9OT1MgdGVzdCBhcmNoaXZlIDxhcmNoaXZlQG5v +bm9zLmludmFsaWQ+iQHRBBMBCgA7FiEELMfcYGkqoZn2eDNYT+nm/afskQ4FAmq5 +U+oCGwMFCwkIBwICIgIGFQoJCAsCBBYCAwECHgcCF4AACgkQT+nm/afskQ4G1gwA +rysnPlJFnhZUDXr/0/u+xDmTynXxbd94UUJJVuszC+Uay7/Iv1yWPIxZP1y8wXCM +TzswG/Mkf4oHDf1OcRBl9mnEOJ1Rx9pnLqsONxybS2aWS5Wx5poU0c1HvR5DGJPL +k8+fls4sDRA6nlTqo3HO1n9fCbK91M8NwOnopTcOLYwcvqYFAxHAgOl/ypdlsgNo +QMyZQvXrv7KBuwzxjxDwodC93ebPUUu3cQXFwVRefazhYjStg61b+jKvbj8sc4C4 +61ndNhqxN1r/e6y4NLdYVpWGgdGwy6hF2WgsdSg2RIlHIqAzfNlVRp4djfXN+ZxQ +jLCwjtC1cUUmj9Mq7ElU8IXVNddlWyyedoJp2E1euZrCwtKCSw9SRoYrFoacts5z +ZMF8NjatHZjJyHgTUoB7+hPlOrwVCC8k/XIOgbyMW+snIXoLkdlxhBJMGG/4aK49 +5O09RQzRCymM78nBD9GPNbtZFwMCVcovdDf/1GphCfdSMH4BXGP27Ct3lWJLQWM4 +=G0dx +-----END PGP PUBLIC KEY BLOCK----- diff --git a/userland/capsule_linux_proofs/vectors/deb/dists/nonos/Release b/userland/capsule_linux_proofs/vectors/deb/dists/nonos/Release new file mode 100644 index 0000000000..8d85d5364a --- /dev/null +++ b/userland/capsule_linux_proofs/vectors/deb/dists/nonos/Release @@ -0,0 +1,8 @@ +Origin: NONOS test +Suite: nonos +Codename: nonos +Architectures: amd64 +Components: main +SHA256: + 2988735bb409b93f3e27af06c1924bb77201af1fdd8b5244b511c38ed7d03f8f 1116 main/binary-amd64/Packages + b95796d87e52e1edb953c316c0202b284b574f8480c4b9543fa245bfe633504c 620 main/binary-amd64/Packages.xz diff --git a/userland/capsule_linux_proofs/vectors/deb/dists/nonos/Release.gpg b/userland/capsule_linux_proofs/vectors/deb/dists/nonos/Release.gpg new file mode 100644 index 0000000000..0e0ca90a60 --- /dev/null +++ b/userland/capsule_linux_proofs/vectors/deb/dists/nonos/Release.gpg @@ -0,0 +1,14 @@ +-----BEGIN PGP SIGNATURE----- + +iQGzBAABCgAdFiEELMfcYGkqoZn2eDNYT+nm/afskQ4FAmq5U+sACgkQT+nm/afs +kQ4lTAv7BuDYOOmrpzWEpW4oXpvKnIOj3bCPKE/6KUdW2DNk7/rUb4N413PEt329 +FxTfWdDNnRjX//UNVPwHKCf4Ph03XYFHqim5lkb5Q+nf7tGagN5/Z6d08op4SWzd +r8C+OfsgQNku7Yr16t64QdbC7yMoDDV1Q1Z/LelMPZ83tTy5Live5eRi4GaybEt7 +lbZDZWffC/GHehFs4psb82ckMiTwz1DjGcIi0emwvC+/VrmNS7bCnYhCuKnf7zPO +EE/5kjey+2t6CF982EmqxEAvQz4w/rXpCMS85qY0DVpjkOPrtQ8abn49NZDa2Rv5 +kAWK0zhlzxo0qbOSbFUs5GJdtHTGfZ4vbo+v4fNGQHvpz3xIEs/nau8aCgWW8j88 ++WRGu28cXF+paHTSlE454Dihwy8oxHIyjijy9qFkRhkMZ4Zdi2Yc5i6q+VtEz8O1 +ZV/vDN+onWEJXGR0sD35Ulj3wh23tqRd6nDMNcIcs8KEd5Km+zvBKd831WwS184f +fcaGfbNq +=3fNk +-----END PGP SIGNATURE----- diff --git a/userland/capsule_linux_proofs/vectors/deb/dists/nonos/main/binary-amd64/Packages b/userland/capsule_linux_proofs/vectors/deb/dists/nonos/main/binary-amd64/Packages new file mode 100644 index 0000000000..a045885e79 --- /dev/null +++ b/userland/capsule_linux_proofs/vectors/deb/dists/nonos/main/binary-amd64/Packages @@ -0,0 +1,35 @@ +Package: libnonos1 +Version: 1.0 +Architecture: amd64 +Maintainer: NONOS +Provides: libnonos-virtual +Filename: pool/main/l/libnonos1/libnonos1_1.0_amd64.deb +Size: 566 +MD5sum: 716daeecd2860394351e0b99acb4425a +SHA1: c51fdf61c834495aef6d4788ead573c2e0f5a85c +SHA256: 6e00c0707b664b543ec4c70812a38775b8073a4f53410f80e3c27add62fde391 +Description: test + +Package: nonos-gz +Version: 1.0 +Architecture: amd64 +Maintainer: NONOS +Filename: pool/main/n/nonos-gz/nonos-gz_1.0_amd64.deb +Size: 604 +MD5sum: 16530b71603ea349a96fd86fe1bd213b +SHA1: d5525d0b8f35c1231186288250fb1d36d89488dc +SHA256: b2dbc6882208c99855837fcf02f64892c635be6114287cfbba1cfeb3a8e47eca +Description: test + +Package: nonos-hello +Version: 1.0 +Architecture: amd64 +Maintainer: NONOS +Depends: libnonos1 (>= 1.0) | libnonos-alt, missing-alt | libnonos-virtual +Filename: pool/main/n/nonos-hello/nonos-hello_1.0_amd64.deb +Size: 772 +MD5sum: cfa80aa0c77be4a0dbba73fd8bee6628 +SHA1: 8af5a117ead7cccb621f5882ba996e3fb558449d +SHA256: 264ced869ab274df80d3784e27caace16c6093394e7356e33ff9929507565eb5 +Description: test + diff --git a/userland/capsule_linux_proofs/vectors/deb/dists/nonos/main/binary-amd64/Packages.xz b/userland/capsule_linux_proofs/vectors/deb/dists/nonos/main/binary-amd64/Packages.xz new file mode 100644 index 0000000000..752ffd8e5c Binary files /dev/null and b/userland/capsule_linux_proofs/vectors/deb/dists/nonos/main/binary-amd64/Packages.xz differ diff --git a/userland/capsule_linux_proofs/vectors/deb/pool/main/l/libnonos1/libnonos1_1.0_amd64.deb b/userland/capsule_linux_proofs/vectors/deb/pool/main/l/libnonos1/libnonos1_1.0_amd64.deb new file mode 100644 index 0000000000..54ff4d0c13 Binary files /dev/null and b/userland/capsule_linux_proofs/vectors/deb/pool/main/l/libnonos1/libnonos1_1.0_amd64.deb differ diff --git a/userland/capsule_linux_proofs/vectors/deb/pool/main/n/nonos-gz/nonos-gz_1.0_amd64.deb b/userland/capsule_linux_proofs/vectors/deb/pool/main/n/nonos-gz/nonos-gz_1.0_amd64.deb new file mode 100644 index 0000000000..abe587b3a3 Binary files /dev/null and b/userland/capsule_linux_proofs/vectors/deb/pool/main/n/nonos-gz/nonos-gz_1.0_amd64.deb differ diff --git a/userland/capsule_linux_proofs/vectors/deb/pool/main/n/nonos-hello/nonos-hello_1.0_amd64.deb b/userland/capsule_linux_proofs/vectors/deb/pool/main/n/nonos-hello/nonos-hello_1.0_amd64.deb new file mode 100644 index 0000000000..293a42b292 Binary files /dev/null and b/userland/capsule_linux_proofs/vectors/deb/pool/main/n/nonos-hello/nonos-hello_1.0_amd64.deb differ diff --git a/userland/capsule_linux_proofs/vectors/inflate/large.gz b/userland/capsule_linux_proofs/vectors/inflate/large.gz new file mode 100644 index 0000000000..4f4c84f6a0 Binary files /dev/null and b/userland/capsule_linux_proofs/vectors/inflate/large.gz differ diff --git a/userland/capsule_linux_proofs/vectors/inflate/make_large.py b/userland/capsule_linux_proofs/vectors/inflate/make_large.py new file mode 100644 index 0000000000..e7c25ae5da --- /dev/null +++ b/userland/capsule_linux_proofs/vectors/inflate/make_large.py @@ -0,0 +1,39 @@ +#!/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 large.gz: two gzip members that inflate to 6 MiB together, past the +inflater's 4 MiB default and inside the installer's bound, the shape of an +index larger than the default allowed (Alpine community, Kali Packages).""" + +import argparse +import gzip +from pathlib import Path + + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("--out", type=Path, default=Path(__file__).with_name("large.gz")) + args = ap.parse_args() + line = b"Package: nonos-test\nVersion: 1\nFilename: pool/x.deb\n\n" + half = (line * (3 * 1024 * 1024 // len(line) + 1))[: 3 * 1024 * 1024] + one = gzip.compress(half, mtime=0) + args.out.write_bytes(one + gzip.compress(half, mtime=0)) + print(f"{args.out}: {args.out.stat().st_size} bytes, {2 * len(half)} inflated") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/userland/capsule_linux_proofs/vectors/kali/Release b/userland/capsule_linux_proofs/vectors/kali/Release new file mode 100644 index 0000000000..a4d290d93a --- /dev/null +++ b/userland/capsule_linux_proofs/vectors/kali/Release @@ -0,0 +1,382 @@ +Origin: Kali +Suite: kali-rolling +Codename: kali-rolling +Date: Fri, 25 Sep 2026 12:06:29 UTC +Architectures: i386 amd64 armhf arm64 +Components: main contrib non-free non-free-firmware +Description: Kali's official continuously updated distribution +MD5Sum: + 32ad98d77af57667cca458edbd501a8a 82236962 main/binary-i386/Packages + d52238fd309b3e6077d2336893a3db3c 20950425 main/binary-i386/Packages.gz + e13c11a4521dc08c19cc9e3c1cced023 133 main/binary-i386/Release + 8fb17266a4e6ae945081b055ea80ce67 243133 main/debian-installer/binary-i386/Packages + f42f49047241b95e91afd63f0c0d0ca1 72672 main/debian-installer/binary-i386/Packages.gz + 1730c6c9219075d57cd07aecb14c8080 85109129 main/binary-amd64/Packages + 7cb5458684b5c6aae702497d1b86e2c3 21631391 main/binary-amd64/Packages.gz + 31b97dacc3772e0d11f1f9850f6257d8 134 main/binary-amd64/Release + 3e647756eaf368243225df719064dbfb 288898 main/debian-installer/binary-amd64/Packages + 06c621ba7c4e2ee75a6fe8e48fde7b81 81771 main/debian-installer/binary-amd64/Packages.gz + 3e60da77f685f426e8548616c880875c 80320048 main/binary-armhf/Packages + 4eeb8429c04048f6ae897a5b367499ee 20649104 main/binary-armhf/Packages.gz + 47a50c28a87b11be6692b73c73b85c5d 134 main/binary-armhf/Release + e5f63715016d30bd05fcf14b5773f0f8 283262 main/debian-installer/binary-armhf/Packages + d3066671a9c601a7cb20e784a330c0d5 80800 main/debian-installer/binary-armhf/Packages.gz + 7e0ff293f4dad98230d2a3ac5e2e1fb0 84429031 main/binary-arm64/Packages + 5db1ff51756405984f6fa82878127b2c 21487692 main/binary-arm64/Packages.gz + 9383f0d6baff82b7ca0b2a3e64561947 134 main/binary-arm64/Release + 49bb289cdc5f1a96104b092e096ec3c9 280634 main/debian-installer/binary-arm64/Packages + 7a07a2d91e033c3de62a4ddaa580b452 80235 main/debian-installer/binary-arm64/Packages.gz + 5fcc043f02d5a8493d0b613c6cf8f5fe 71843143 main/source/Sources + 9674ad611e9e41b1f9fa9c8d7e702f1d 18340253 main/source/Sources.gz + fcfea3dd584b0b94ac7589471dd6ecbc 135 main/source/Release + bf34d10153a4b5f7254689552f7c97cc 327278 contrib/binary-i386/Packages + 283b1e907a9c393d424305ac7f748604 97835 contrib/binary-i386/Packages.gz + d1d85c9cc18a22214c0c9e31b67fa742 136 contrib/binary-i386/Release + 4f4b2b76a7ea7d0a8173d2e6b9684cdb 584 contrib/debian-installer/binary-i386/Packages + 7d9138b41b4cf369e95b44b3f39e75ce 404 contrib/debian-installer/binary-i386/Packages.gz + 22eb3dc4d5ac86adb03bafe3600c07fb 395749 contrib/binary-amd64/Packages + db3df994cf25e2e5000d63f6c7e1427a 115774 contrib/binary-amd64/Packages.gz + 43c61011a73aba65d952345fe3dd525a 137 contrib/binary-amd64/Release + 48f06624cc2e228c1eceb9abea3217a8 586 contrib/debian-installer/binary-amd64/Packages + 45b5063ea504b858e9938cbd0a2f2224 401 contrib/debian-installer/binary-amd64/Packages.gz + 92e1b5a831fac5c75767401e57a0d10a 303681 contrib/binary-armhf/Packages + 48e51b6a0cc211bdde80333049d9d691 91427 contrib/binary-armhf/Packages.gz + 8ab53104d0489e3bec8a2796638a865e 137 contrib/binary-armhf/Release + cbb8dcdea108b38fbad050c53a67a55e 586 contrib/debian-installer/binary-armhf/Packages + fda4dad1afd56399a3e8998194e31a65 404 contrib/debian-installer/binary-armhf/Packages.gz + db9e4406091c891e03920bbd6cb71e90 336647 contrib/binary-arm64/Packages + 9e59e4893f28d3230f01cf9275b326bf 101260 contrib/binary-arm64/Packages.gz + 23f5fc1cceead0b7024075102dd0a5dc 137 contrib/binary-arm64/Release + 959d53fe0b4fe1e6ebd5a6116f495e28 586 contrib/debian-installer/binary-arm64/Packages + ee48ef954a35356f3e4c5d29092667f5 403 contrib/debian-installer/binary-arm64/Packages.gz + 928b452980a7fb7b9c367b0a1df468c5 278476 contrib/source/Sources + ab564145ff57e229098c1057b2982592 80721 contrib/source/Sources.gz + 3ece05988a69901d4b7f3b3c9ee5b0e2 138 contrib/source/Release + 379a38ce39b025a9db9ad7fb900ea119 608001 non-free/binary-i386/Packages + 76557c4b450d1f337a224f0bc104a8ce 139918 non-free/binary-i386/Packages.gz + fe0146344acff5b99863b2c38f0b7915 137 non-free/binary-i386/Release + 27c7fa8af87d9b27344c66e33b963b58 858 non-free/debian-installer/binary-i386/Packages + 2f55a5d99d0638e0de1646f2771b5715 552 non-free/debian-installer/binary-i386/Packages.gz + 4b26728e03b2a75040e0e2e7ae0eaac8 890733 non-free/binary-amd64/Packages + f6a052987d79e80b17e91771743f1426 182629 non-free/binary-amd64/Packages.gz + 52bbf7c80917ffc1dbc7e93708e95fef 138 non-free/binary-amd64/Release + dad6c39c3a24558664efa000977b4a1d 860 non-free/debian-installer/binary-amd64/Packages + 2f1ad9e668e350ff265c29bea2537c1f 552 non-free/debian-installer/binary-amd64/Packages.gz + 42205e5de89b53b7607c1e1052950d37 465538 non-free/binary-armhf/Packages + 7f2a6f55c361358d08b72ad19fb0a7da 115642 non-free/binary-armhf/Packages.gz + 1543a3c9dd1cefe008d36afa0c214f22 138 non-free/binary-armhf/Release + 83be53e9570d33d62da25608b75088a7 860 non-free/debian-installer/binary-armhf/Packages + de0cc8ae4871f6509c03dfcf7b134c8e 549 non-free/debian-installer/binary-armhf/Packages.gz + 7c4d08324ec07abddbf5ece04c100d2a 589148 non-free/binary-arm64/Packages + 39bb24453e6a43f94bd4226df19dd938 142513 non-free/binary-arm64/Packages.gz + fffe0394fe868816b648f147c3029b4c 138 non-free/binary-arm64/Release + 9c9914f2c18bf5965fb00cf117f00f6c 860 non-free/debian-installer/binary-arm64/Packages + d62659ea272ece9817c399721e74a9e3 551 non-free/debian-installer/binary-arm64/Packages.gz + ce72fc3fde0f9d5b19eb95e4fb268b0d 454492 non-free/source/Sources + 9704ab5420ca3cdc018aee920229b42e 119046 non-free/source/Sources.gz + 15d3a809138f5dbd4b01b70f3120a7b8 139 non-free/source/Release + 1eabc0a1d42e28284637c63233266fee 72073 non-free-firmware/binary-i386/Packages + a99ef7458510cb7ff9be7bac21837443 15654 non-free-firmware/binary-i386/Packages.gz + bdf17ed315da1e10204e521e6cb07a92 146 non-free-firmware/binary-i386/Release + d41d8cd98f00b204e9800998ecf8427e 0 non-free-firmware/debian-installer/binary-i386/Packages + 7029066c27ac6f5ef18d660d5741979a 20 non-free-firmware/debian-installer/binary-i386/Packages.gz + a0fd95ea4709f957b66da33b3ecdad25 73045 non-free-firmware/binary-amd64/Packages + 79b5cc172835f27bfd79f0ace0b5eff4 15983 non-free-firmware/binary-amd64/Packages.gz + 88f406569f9c80152c93ecb073792866 147 non-free-firmware/binary-amd64/Release + d41d8cd98f00b204e9800998ecf8427e 0 non-free-firmware/debian-installer/binary-amd64/Packages + 7029066c27ac6f5ef18d660d5741979a 20 non-free-firmware/debian-installer/binary-amd64/Packages.gz + c801221e3ec6a6e7ff38ffc37f0e40b9 69903 non-free-firmware/binary-armhf/Packages + f53bf490bab40cac8d47c3ba03c25f44 14791 non-free-firmware/binary-armhf/Packages.gz + 0e17d5ca4a3f6a65e2db76859c1bc7d6 147 non-free-firmware/binary-armhf/Release + d41d8cd98f00b204e9800998ecf8427e 0 non-free-firmware/debian-installer/binary-armhf/Packages + 7029066c27ac6f5ef18d660d5741979a 20 non-free-firmware/debian-installer/binary-armhf/Packages.gz + e0b96911ba2a3d12bbcf21ef3281a8d1 70871 non-free-firmware/binary-arm64/Packages + 510c7347ae171d364cc5f927e51321cb 15116 non-free-firmware/binary-arm64/Packages.gz + 522ab3f56a04c59b100741ba5dc80777 147 non-free-firmware/binary-arm64/Release + d41d8cd98f00b204e9800998ecf8427e 0 non-free-firmware/debian-installer/binary-arm64/Packages + 7029066c27ac6f5ef18d660d5741979a 20 non-free-firmware/debian-installer/binary-arm64/Packages.gz + 7f4b00be565aba84db303d1c90545dad 41329 non-free-firmware/source/Sources + 39580891873d89327afbdf991ad337a3 10386 non-free-firmware/source/Sources.gz + 2d7886186228d1b8f38584b9ee305670 148 non-free-firmware/source/Release + d7016f69456606e58c10e5ea6ae2c23b 758722605 main/Contents-i386 + d196b4e56a4842b1ed5712f6905f9bc7 48719781 main/Contents-i386.gz + 411ad360afafc2ba0f2748994ce1ee71 2652154 contrib/Contents-i386 + 1ee6b3359228385b4c17262a8b681c4c 180850 contrib/Contents-i386.gz + 16773acc6addf46fc7e3bd9658c83b08 15826486 non-free/Contents-i386 + 74f47af9eb834c95b1503c2747a3c1e8 882627 non-free/Contents-i386.gz + 5da8ca07f9311e79d9009306ff2e34ff 845714 non-free-firmware/Contents-i386 + 1fdf60b7ba23046583e2e9508c23753c 41874 non-free-firmware/Contents-i386.gz + 8af3b5f84696a58d7e62aa77066ae3d0 877089125 main/Contents-amd64 + 112739172565e427ffeb1822628853b4 54082810 main/Contents-amd64.gz + 774655aacb1b71f6bc36d51cfd2db3f3 4265469 contrib/Contents-amd64 + cca41761909c8204a8c1bf04c0a6b85a 269687 contrib/Contents-amd64.gz + ca63e7e1211c4348474d38a95c922442 16269585 non-free/Contents-amd64 + f08e0409d843f1de5556d036f221d5a3 915112 non-free/Contents-amd64.gz + bab7949cbbd4591b989e3d6a3e9f693a 842380 non-free-firmware/Contents-amd64 + c4e557594b409dc7bae934ceb92d0535 41764 non-free-firmware/Contents-amd64.gz + ba8ebbab2449b9d3bb357534a67862df 739291835 main/Contents-armhf + ab09e0e6e191fae3eb68e4b3f11b0990 47778227 main/Contents-armhf.gz + c88f307042cb482636d9c79aca5b551c 2640597 contrib/Contents-armhf + d6aa9f8ea315d5057f6e666f4fdb349a 179018 contrib/Contents-armhf.gz + cab4bd59fe1d417baf864364bb1837af 15517956 non-free/Contents-armhf + fdd76457cdbf1b59afeed1ed1ff5c164 861261 non-free/Contents-armhf.gz + 87b8a61a77c11988e1138d761f977658 827561 non-free-firmware/Contents-armhf + af77d1a99a7f601f3c1ab8a3d52895a2 40748 non-free-firmware/Contents-armhf.gz + 4e5de6cadd325cc4863d9f2641da00ef 790195791 main/Contents-arm64 + fcac84a43aea413df6c622560f81b8dd 50608160 main/Contents-arm64.gz + dbd6a7777fec51aee807d92fcb50c518 2674846 contrib/Contents-arm64 + 31a2f49309046d02fe9837bfa6b12c9d 182575 contrib/Contents-arm64.gz + e8e08bc29f2a69109f5c0526b956b9b4 15812364 non-free/Contents-arm64 + fc275e8a848e2240e786c39b32fa22e0 881847 non-free/Contents-arm64.gz + 275e1b4bee6dab374d1bef8d0ccb3f37 828327 non-free-firmware/Contents-arm64 + 341328666464f33c6b21aa551c6b54df 40818 non-free-firmware/Contents-arm64.gz +SHA1: + d196cde753177270ac012a803955a8b920c6d874 82236962 main/binary-i386/Packages + baa7a1d1533dc002fb4ab000177ba7dc1e0b7adb 20950425 main/binary-i386/Packages.gz + 904ca4f9f4c574b32577d0a3357434b08eb6cb30 133 main/binary-i386/Release + baef89095623685fa22f2cc000402a9b3b1fd841 243133 main/debian-installer/binary-i386/Packages + a6a1dcc565438a5d5c892614edf7c3c91724eb3f 72672 main/debian-installer/binary-i386/Packages.gz + 1d769cd05aecc861f51d1976ee957dd8bd98531c 85109129 main/binary-amd64/Packages + 6d91d1202bd6fc1e911843f71a41fcafa8ace2bf 21631391 main/binary-amd64/Packages.gz + fe9ae488c5d131bcb464820db5d296016ef2a781 134 main/binary-amd64/Release + b10e3bb06fcce2cbd0ddd29da3b4a82c7edab4e7 288898 main/debian-installer/binary-amd64/Packages + 94d4cb8b006bffe096852bcb6c133604d73bcb05 81771 main/debian-installer/binary-amd64/Packages.gz + 19d67d1ca1d87d578378c7858f7dd6a545e17bd6 80320048 main/binary-armhf/Packages + 265e8164f32f2841ed12f12da653b383fa4a9060 20649104 main/binary-armhf/Packages.gz + 0c405f9aaa0d48d6eca496291f0323f223fdc83a 134 main/binary-armhf/Release + 23bd5f5e31f128a796f57bc2bdd95a9b51990930 283262 main/debian-installer/binary-armhf/Packages + b3fe874ffef251e0a29e8a2efe507eca5ba683c8 80800 main/debian-installer/binary-armhf/Packages.gz + 86de56d75225ed033a2904e2e0ea0c77eee27721 84429031 main/binary-arm64/Packages + 55232de22b9733d590a1d0e5609efeb3b7b2adde 21487692 main/binary-arm64/Packages.gz + c79163b14b59a18407cdb1e5a04ae7b6055d0739 134 main/binary-arm64/Release + 6249f4f82dfa8c2e66ea3cf7eb8d4f0afe4a1d74 280634 main/debian-installer/binary-arm64/Packages + 5319038d1dc204464ce9eb634b71d697440a9b4e 80235 main/debian-installer/binary-arm64/Packages.gz + d4939bb8e758bb974db68b0a996f8d96148b2553 71843143 main/source/Sources + ddd1dd8356e462f4f9e44f65625d539b9dd2ad63 18340253 main/source/Sources.gz + d12878aeae081e0747b4bb3be0a4f0db981d2f65 135 main/source/Release + f0bd98c30f88987fb0208bf96dbc302706467706 327278 contrib/binary-i386/Packages + 8e741d09d2dd8dbd978c67517ecd045531b908ca 97835 contrib/binary-i386/Packages.gz + ceeccb021ea94d1a1e34f25030ceeb075345f779 136 contrib/binary-i386/Release + 70918211972996f80dfa044aa3be2e7003cbaf71 584 contrib/debian-installer/binary-i386/Packages + c6740138f7c6fc9268f4a829809ea5c17e46afa9 404 contrib/debian-installer/binary-i386/Packages.gz + 2d6e9811f451a879c858cc504d23ff28e377d308 395749 contrib/binary-amd64/Packages + a2e8e086b42f7b33fa1f1529d6bbd56a9c517006 115774 contrib/binary-amd64/Packages.gz + 0693dc6fdad7e27fa258f6daabfbcb2f7890aa80 137 contrib/binary-amd64/Release + 5603a4afd17879c51f9e02e4c41b27ff6a840eae 586 contrib/debian-installer/binary-amd64/Packages + 8a1ec1cb8164b4b7fcd3884f9a6417cc50fc3838 401 contrib/debian-installer/binary-amd64/Packages.gz + be1efbf5c1d575f4e05eadef8233d783d31222f1 303681 contrib/binary-armhf/Packages + f044b684bb7a93d33b2c4574efc49113b5bfddbe 91427 contrib/binary-armhf/Packages.gz + 66c7d7f2648754bc567540d1611d4ab486962d4e 137 contrib/binary-armhf/Release + bceaf2a5a1e0db4fee82ced680c80481d05730e8 586 contrib/debian-installer/binary-armhf/Packages + bb98ff35d8a93daed265ba5cf7ddf76a2d2a3f05 404 contrib/debian-installer/binary-armhf/Packages.gz + a0052b0d300b2cd879c5be5b4d6697199617b70f 336647 contrib/binary-arm64/Packages + d3caa30aa843cddad7bf255c6a9d1b0fd1673e83 101260 contrib/binary-arm64/Packages.gz + 9fb62eb23ed0b1d98e999295d6c652ae2879ca3a 137 contrib/binary-arm64/Release + 1fa350b6a5089623dd853c5618d45af94cdac849 586 contrib/debian-installer/binary-arm64/Packages + c943d4c7a8743bd6610d69e35c0e745a9123734f 403 contrib/debian-installer/binary-arm64/Packages.gz + 20e39a48f3c6789599c167ea79ff4ac36e2aabfa 278476 contrib/source/Sources + d6bd910d1e2933661da20b6cd280550b9c024e8b 80721 contrib/source/Sources.gz + 95164b0aae1ff106713f5a5bf648ac3d0498961e 138 contrib/source/Release + 563338242cbfd11e70d43c413d8b27a0a0ccec22 608001 non-free/binary-i386/Packages + 3558cd0ca0ff21535e59b4ec9fa57afa00967081 139918 non-free/binary-i386/Packages.gz + b044e9a60f24a92c4eb0b78f2d3245cd4095e8ab 137 non-free/binary-i386/Release + 5f64e79517cbe1f6a8ec4c14295eff6b4bcb73df 858 non-free/debian-installer/binary-i386/Packages + bf6a9bb786f2ad1e396eefaf0a1202be2d44abb0 552 non-free/debian-installer/binary-i386/Packages.gz + 041b2cf393d431b58b836b6f5e957debd51f2db4 890733 non-free/binary-amd64/Packages + f275c5d9c5e5bb07268249a55fe0539bca156873 182629 non-free/binary-amd64/Packages.gz + 34838f11a8e6bad86b9211d89b5a1c926b32fa88 138 non-free/binary-amd64/Release + 17cd046a593f353850eac90e9067cb84888b1dbc 860 non-free/debian-installer/binary-amd64/Packages + 531e70fbad7ccbcaaddaef461de66c09557e636a 552 non-free/debian-installer/binary-amd64/Packages.gz + 951fc4bc8b9a377e43631f69d0e1b86a959a5e46 465538 non-free/binary-armhf/Packages + 9389e7f172376554e2a4d789de80fa036ea29220 115642 non-free/binary-armhf/Packages.gz + 5180006b9ee085b6ce195e7b187ab50dedf045b9 138 non-free/binary-armhf/Release + 3bccb9a9e42e719f14d4c6233faa425a92dbaf72 860 non-free/debian-installer/binary-armhf/Packages + 5cff8546a75c9a6966919e3f33c2552076e8a5f2 549 non-free/debian-installer/binary-armhf/Packages.gz + 79a6abdfc8685f2e546dc2d459eab30e003f4b63 589148 non-free/binary-arm64/Packages + 09759cf73e031135028408860ffcbf7f3f4d5b9d 142513 non-free/binary-arm64/Packages.gz + bbf43c9b6341172d156d58fd8a54680ede3a2cf0 138 non-free/binary-arm64/Release + 0a9f98e3de9d53d9ee3b37a7df4ad683a1f0f7fb 860 non-free/debian-installer/binary-arm64/Packages + 44d858e9cfb64fe172faa1d023496ca1bc2945a7 551 non-free/debian-installer/binary-arm64/Packages.gz + 77e8ee6f7714a32e634c6bc790ab953f82679a75 454492 non-free/source/Sources + 5ad309d476c4183d99c43617788733c108ab84a7 119046 non-free/source/Sources.gz + 7f606b9b6da367ee7d1b8d57faf74649a7eb5487 139 non-free/source/Release + af7757ab856f5bbbaaa89c00d5cb125258fd1829 72073 non-free-firmware/binary-i386/Packages + 3babad0ff96f859d04b1a5102748d17ffe8447e4 15654 non-free-firmware/binary-i386/Packages.gz + 26204176390a2daffa8576ce62ccbd89651815fc 146 non-free-firmware/binary-i386/Release + da39a3ee5e6b4b0d3255bfef95601890afd80709 0 non-free-firmware/debian-installer/binary-i386/Packages + 46c6643f07aa7f6bfe7118de926b86defc5087c4 20 non-free-firmware/debian-installer/binary-i386/Packages.gz + 63a5e90ae816c17b68fa9720756d84ebaf5c820a 73045 non-free-firmware/binary-amd64/Packages + 1fa81d781790eee68a1eb819170354a1b0767b50 15983 non-free-firmware/binary-amd64/Packages.gz + 85838a62653afb40ff7bf19f92364bf0f7451588 147 non-free-firmware/binary-amd64/Release + da39a3ee5e6b4b0d3255bfef95601890afd80709 0 non-free-firmware/debian-installer/binary-amd64/Packages + 46c6643f07aa7f6bfe7118de926b86defc5087c4 20 non-free-firmware/debian-installer/binary-amd64/Packages.gz + 1d7851664b0500e86e94e06ae8c16abde2fa045c 69903 non-free-firmware/binary-armhf/Packages + 552edb00b8a215999a94fe55a367c90789994aa6 14791 non-free-firmware/binary-armhf/Packages.gz + 161c0c67627521d3bd133de758646f3947f89de0 147 non-free-firmware/binary-armhf/Release + da39a3ee5e6b4b0d3255bfef95601890afd80709 0 non-free-firmware/debian-installer/binary-armhf/Packages + 46c6643f07aa7f6bfe7118de926b86defc5087c4 20 non-free-firmware/debian-installer/binary-armhf/Packages.gz + 0a833470f4ee4179f7a20272d285625464422107 70871 non-free-firmware/binary-arm64/Packages + 7dd79fd7bc62b4bc20ea009d6b1b7be7149518bc 15116 non-free-firmware/binary-arm64/Packages.gz + 3a204c49797dfcbf4741ba8d4be56db6ed544b8c 147 non-free-firmware/binary-arm64/Release + da39a3ee5e6b4b0d3255bfef95601890afd80709 0 non-free-firmware/debian-installer/binary-arm64/Packages + 46c6643f07aa7f6bfe7118de926b86defc5087c4 20 non-free-firmware/debian-installer/binary-arm64/Packages.gz + c851fa1ae1bc256a8fca1efc01fcf2e590a5e0c7 41329 non-free-firmware/source/Sources + 60d022ebad4044fe0bae8c0a18e60421584988f3 10386 non-free-firmware/source/Sources.gz + 3cc269458e304029a1086f3d42aca0b6e406d588 148 non-free-firmware/source/Release + f6e3aed7d528e73a28dd46d3a84c7c56c17ad49f 758722605 main/Contents-i386 + 6fbf26ffccb490bb1c062924032dbd86061a1cf6 48719781 main/Contents-i386.gz + 34b30c4624c4589d6799b36165be12f32d77c8f2 2652154 contrib/Contents-i386 + 2b5b7ada7fe20bb352b76a918b0da0ed15b96e40 180850 contrib/Contents-i386.gz + 8b105151ede02fd5b0b2e7fb9f6bb49ec264601e 15826486 non-free/Contents-i386 + 6066e87b967681d2a538637ad8929b3a15bae2a1 882627 non-free/Contents-i386.gz + 476d0d33a188cde486d8985d14f5ee6ec94950bd 845714 non-free-firmware/Contents-i386 + ce1bdb8b6100f7299d25d977ca054f1ff79216a1 41874 non-free-firmware/Contents-i386.gz + 179262b4be8165fcf607c32efbdb0542a8d51ac7 877089125 main/Contents-amd64 + f34e4d80db9e43862d55e95cea1994a7c9631a30 54082810 main/Contents-amd64.gz + 403268f296c53f4bef17f29374dc0963620e02c8 4265469 contrib/Contents-amd64 + 64e2d7b444773d2883d09a2343acf0337ac0d624 269687 contrib/Contents-amd64.gz + c60091fb9292b31eeecb2044019f6b0211f937c4 16269585 non-free/Contents-amd64 + 1f173b6a7a805d46c7ff5db3d7686703b295601c 915112 non-free/Contents-amd64.gz + 6dbcaacae3d4a4f2a8d0185ac789a4f51a0593b0 842380 non-free-firmware/Contents-amd64 + 645f6c27689b7213e7fc8cd4d1af37fbfcdec4bd 41764 non-free-firmware/Contents-amd64.gz + 5347b7696b0d131864cdb7f1a1ba6698a6795fdc 739291835 main/Contents-armhf + d780cf277b9f0ff6b94074613b969a6c539d1eba 47778227 main/Contents-armhf.gz + 32feb24c6645a0c23f17233fda966a911a9ed10f 2640597 contrib/Contents-armhf + 9602e2c7999a891e7cb554f590697e815449935e 179018 contrib/Contents-armhf.gz + d0b030b338c1bc2e8663c20556575e042b4a45a9 15517956 non-free/Contents-armhf + 01cb496e4ff5dec8ff40d838e27360cb49714bda 861261 non-free/Contents-armhf.gz + 4fe69d6b1ad09e05961ef34423887a61b7a24ba8 827561 non-free-firmware/Contents-armhf + 270885a35231e2ddf68ea61095f8bbfcb0bb06e0 40748 non-free-firmware/Contents-armhf.gz + d6ff41e4436af6cd4adc8307200fb26539cb6222 790195791 main/Contents-arm64 + 582042cf18b60a58bc6ba8803e4f1a4f1c49c1bd 50608160 main/Contents-arm64.gz + e7160da2d3dd77467ffd1b4f83d25fbc7fb71992 2674846 contrib/Contents-arm64 + 171c7038807f11ca0d1750d3d0b0e8bece2ebcb5 182575 contrib/Contents-arm64.gz + ced5d1488df803284bd23bfa70978cb90f999eb6 15812364 non-free/Contents-arm64 + 69d63ea99c47c041ad5e7e8ff89963717ffe10a8 881847 non-free/Contents-arm64.gz + 0c1714146efa73ca6e68a78bf3fff4d466afda47 828327 non-free-firmware/Contents-arm64 + ad4fb09e351f7e93918a7f1d5763350e7c761e65 40818 non-free-firmware/Contents-arm64.gz +SHA256: + 3bfd13a559847ada5bf9cfa1346e596389622e28f1037894e40f0b9fe40cfe64 82236962 main/binary-i386/Packages + 6c8d26bc7d4ba4384ce8630b6673165fda7d9ff53dfaffc84f0f8a3df4e03cda 20950425 main/binary-i386/Packages.gz + bb611d99e888fb73026be0503004bf9278a9dacc25612aa6b5dc7f5bba0a133c 133 main/binary-i386/Release + 9917ce92deaed5f42dc37c92de63c1a747be32f29af891be2b0093b3419e6f3b 243133 main/debian-installer/binary-i386/Packages + 88e248ce6ed97f1e5dad510dc5158988d7c0d2770981258c4101bf4e642d2990 72672 main/debian-installer/binary-i386/Packages.gz + 858892768295a628afc6981be65edb73600607621265f2b7e83239c8ed871e01 85109129 main/binary-amd64/Packages + 28766aa9230ffb44921e09bd66556bffa57dcb825604bb0d0482d19b16006412 21631391 main/binary-amd64/Packages.gz + 95c16e4db9eaec0f30153afb508e0bc4e59946cbb0b177e1965e919786eac33a 134 main/binary-amd64/Release + cff2f96c1775ad3ff071bde3f8f6fb4093dace78e723033ccd39b5a39c162c7a 288898 main/debian-installer/binary-amd64/Packages + 0b94c2cd9995b7147d7109b6be80de2353aec2a8d42700ef32aadab10812877b 81771 main/debian-installer/binary-amd64/Packages.gz + 69e783a466d9de0294d5316b02fcc82d6a7e0f02d1b9fa11142b7f88c1f9ebcd 80320048 main/binary-armhf/Packages + 9de70d2da979824562d90da627e74620029f1cdd1f862f188b52884d06bb7864 20649104 main/binary-armhf/Packages.gz + 6b180523930a235045664bddcf366a9fbea517f02298571d491960b75335d47b 134 main/binary-armhf/Release + a08f332eed1a0146aafb2d5a2e15f1ba8a8cecbecfce7632258b2c16d3691abf 283262 main/debian-installer/binary-armhf/Packages + 8d02e4075f001d3a47db8313e66d0176be3e4e0d24a17c51aba77baa1ede108d 80800 main/debian-installer/binary-armhf/Packages.gz + 571453152c7220f90309f1a9499a70370b1f40471781833ae893d605c30f9f40 84429031 main/binary-arm64/Packages + 09b9788d0c4939e3ba3aea9242336b2ae63dc1665ed99cc240eb5c427082236c 21487692 main/binary-arm64/Packages.gz + da0eec89cd044291a0b01444c6843e7dafc6e47304a414fcd6faabb115a92d91 134 main/binary-arm64/Release + baaff2ee78e74fa6f4e4b3ef0f8b40474adb46b7612e8dd36739d7b8867f7b95 280634 main/debian-installer/binary-arm64/Packages + 76f74bf4c3549bfa24afbc4bcdce4d73ca03c2c23ddb766680ba76f6eac1b4e9 80235 main/debian-installer/binary-arm64/Packages.gz + 1b6b09f5fb001133067a2ac5509fbcb062c612561c4bd73a1c636b0e012a5799 71843143 main/source/Sources + ac6bb79902b11faa36e0cc90c7395f9d3ecad3f06d352fa56e944546af0bbe49 18340253 main/source/Sources.gz + b312772354c87a0f340638557e6255698b3940f326bd777fd7a7c1700b0a4200 135 main/source/Release + 141c8b42977a403a98e49e06aa93c674f0c069037d8e5af9ce046a7888d9497a 327278 contrib/binary-i386/Packages + 8ddc4258dbe778146392963a843043267bf01a1c56a69cae56b02ef74f44858d 97835 contrib/binary-i386/Packages.gz + a8e0d8b1f81a2c59c0fe0fadd9d14c895a2037f1c8be8e5873493bd3dc85e233 136 contrib/binary-i386/Release + e955823b002616619fbe6faebb31ae7f42b865c170dd5b258701adb0ba866f17 584 contrib/debian-installer/binary-i386/Packages + 013ba090d72d62dddc4e2d76a0f1dd555aa17ed8a39d21cf05bf5ee4b92ecc29 404 contrib/debian-installer/binary-i386/Packages.gz + 3d61c3485b48cdc1b94686b166deb81af7a3068f4f18e45b391254a6d34e23db 395749 contrib/binary-amd64/Packages + 7fdd81f21790721ac9ba61663259f0c127c7a7b8a90d21679ec5eaec1d95fd54 115774 contrib/binary-amd64/Packages.gz + 6864073f4d408cd944e07e6ae93cdc0825180089a7d120d8013172c5c0e0f60b 137 contrib/binary-amd64/Release + 1456ce64e8d0d2218a8680044e2401e3e6142e4559c7a4abf40f6574446dbc8b 586 contrib/debian-installer/binary-amd64/Packages + 1294daccf039b8ab1dec70d23aca545db115f957077eeb70ef2a17a70062937a 401 contrib/debian-installer/binary-amd64/Packages.gz + fe66e36c4cf5ed164c2138710acce27e21207b00e77b4c7506383ed79e6b24b1 303681 contrib/binary-armhf/Packages + f4c988763e19ab825b63e819aee6c646b8828b67f8ed7c1d5cf9c69620bc7bd3 91427 contrib/binary-armhf/Packages.gz + 9e0d266ec0801b296dbd8cd4eb85cdec69cfc3d5ef9579af85cd6e3afac5401c 137 contrib/binary-armhf/Release + 6f8f7ab980277ee29cb526809bebff1e59b8e0b6f0ee0d2b744b2bb08c1ba0b3 586 contrib/debian-installer/binary-armhf/Packages + a455807c2f2928334f4ab83dd668cd1505fe8fa072b6fcee322ac18e6c4afab1 404 contrib/debian-installer/binary-armhf/Packages.gz + 64eed718c1ea5d51d6b4414de0a02398fc04071e5d1cdbcd1f39c0c14fe38a9c 336647 contrib/binary-arm64/Packages + 478dcec14a8c9bbd4c9b6c2272ce58e6c4f6b92a72d2386ada15d5832717d0ab 101260 contrib/binary-arm64/Packages.gz + 17e0f36e771c80540126e98117374333e28123808ed9a221e24777b712df4720 137 contrib/binary-arm64/Release + 6c984bc1495505d8b2fa1510894580dba90747c5d806c16c9955280f084549c1 586 contrib/debian-installer/binary-arm64/Packages + 0214ea73a4c5d0fc805abaac0fbe59610cde0c3ec7dacb53aacf00f4a006ac6a 403 contrib/debian-installer/binary-arm64/Packages.gz + cfbd60d5b8633e90be405b7da21204140016b74fbdef54b262670a0b4c559e4c 278476 contrib/source/Sources + 257974af5924fe7055abc0ce46134039bbb389acc687d0da9e369f6c59dd6110 80721 contrib/source/Sources.gz + 1539af5cb0546f526241651102ea4fe63fd3cb24cb45eeba71a97c3da07759eb 138 contrib/source/Release + 947e1850c72c175892e0204448feeb7c6de75e389ed04348da5618583c6c5427 608001 non-free/binary-i386/Packages + afa485a39b13d46b347a59273ad22148c9a50a1ca45776f16e95270b99f35791 139918 non-free/binary-i386/Packages.gz + ae7735a4e2f8b3b75a0fc443ad5acad68b21e80abbabb500ca2f816391029a0c 137 non-free/binary-i386/Release + 2b2f5bc42d499f30196e08dcd19ac9719cf82b65a4f59daad2c9b1cac2bac5fc 858 non-free/debian-installer/binary-i386/Packages + 4ab748887d8fe68408bc9899847a0b13ac0e56e80b882d916ec72010c91c239a 552 non-free/debian-installer/binary-i386/Packages.gz + ca78bd3d305851f88112bf1454243e0069ba6a0818e8c114551c898a39f5afe0 890733 non-free/binary-amd64/Packages + 1c5195a0db6e4a2cbf50aa4b1fc5d6a5573656fa9b416d0a864a00b414df1432 182629 non-free/binary-amd64/Packages.gz + 2a0c93c545d06be17c15858451388025ce59fa5e33035bec62e62b9e3dbc0af3 138 non-free/binary-amd64/Release + e4f206ed56c9e0fc38dcba5d5285f4329d24581b9947ff7125f5fbc0a931185e 860 non-free/debian-installer/binary-amd64/Packages + 6bebb9faae7bc46c94ca006c9d3a4265e07fb1e7af6b07989328ef52fd184ef3 552 non-free/debian-installer/binary-amd64/Packages.gz + 7af1fe4f98b9eee3479d23bde48a4e4e293b38141c8ba556b3ec4db4db3716d9 465538 non-free/binary-armhf/Packages + 1aa7bc3ed0759589576f7d44c1b04422518fb4095b1bc7bf2f3b2e9d6ba65667 115642 non-free/binary-armhf/Packages.gz + a0c7bc4ac0d5056f65befc2e96201e411a32ff45c4072f751075a67682ce681a 138 non-free/binary-armhf/Release + 95136a9476486ee64bc3cd828db5ac128e5fd98c33121701aacbcb8c8777b54c 860 non-free/debian-installer/binary-armhf/Packages + 0d268325932f379c70efb9d0e0b87e03ddc9cb29a79a7f07daeb7e4b76c3e6a7 549 non-free/debian-installer/binary-armhf/Packages.gz + a3be79a6c65f631526ff8c8a001380e1f415da38c52c8db9638af933a9377ff3 589148 non-free/binary-arm64/Packages + b7f81a759794059be6c7896bddea2d502faf54275171abcdc64cd6cf3e22380c 142513 non-free/binary-arm64/Packages.gz + 171215d3bd310ce5503f22cff3fcdb06dda90149c3e0d640143ae46a7c66ca95 138 non-free/binary-arm64/Release + 655341010ec8d48a8e21e7d0dbb1e890fad106de901c63a7476c619b999b0286 860 non-free/debian-installer/binary-arm64/Packages + 7dd111875c121ba8c2421e8eeaf28bacf536db7bc9621e54af4ba2db56ed51fb 551 non-free/debian-installer/binary-arm64/Packages.gz + c341824582859268de521334a45b85f1cd78b1176ddb3abba28def0aebcb9176 454492 non-free/source/Sources + a829bc0a8514e7f8486b2eef1f1705a6c9c9af2d89424285427f7afbd693eb23 119046 non-free/source/Sources.gz + bca50443a7a839a1886fbef6799c13d034125cdd7ce8b22257097a17dd8eb0b1 139 non-free/source/Release + 32d5bf5ecf7116591aa8b3e6a0ffc528addde189e5fb1164e58f0a04f3cb76ca 72073 non-free-firmware/binary-i386/Packages + 73550237e8201210330c7d2e846dc577a8394e4d661be89b5f6a0b4df5b9cff5 15654 non-free-firmware/binary-i386/Packages.gz + 343ae58b311d6209bbe2cd24d9d32a12b41ec5ad4c29d665585d5b91840cdd80 146 non-free-firmware/binary-i386/Release + e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 0 non-free-firmware/debian-installer/binary-i386/Packages + 59869db34853933b239f1e2219cf7d431da006aa919635478511fabbfc8849d2 20 non-free-firmware/debian-installer/binary-i386/Packages.gz + 66b8a9c6d4424a7ca11bfee8a8ad603f03140ac9ba411160cbed27be0ec767d0 73045 non-free-firmware/binary-amd64/Packages + 5658e0ea5565df182398d8f5ac9a4582476b8a099d915088f9435c3ff85c4abe 15983 non-free-firmware/binary-amd64/Packages.gz + 2c96f3e069812cef8912ec03ee793a9f884b4f3153405bd489d4d19e727c8652 147 non-free-firmware/binary-amd64/Release + e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 0 non-free-firmware/debian-installer/binary-amd64/Packages + 59869db34853933b239f1e2219cf7d431da006aa919635478511fabbfc8849d2 20 non-free-firmware/debian-installer/binary-amd64/Packages.gz + 33294b6f0ba9d545d6d516b6cf2d22855611e72f34efa604fbfc7dd38fd19ccc 69903 non-free-firmware/binary-armhf/Packages + 1ef0501b59e4bbe3a3e3f004e0ab6f70a259a0d30df3a2c711deaaa17199506a 14791 non-free-firmware/binary-armhf/Packages.gz + 4f472c8c0658e046c70309357902e411bf87244a1f50623739f8db3e663dbded 147 non-free-firmware/binary-armhf/Release + e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 0 non-free-firmware/debian-installer/binary-armhf/Packages + 59869db34853933b239f1e2219cf7d431da006aa919635478511fabbfc8849d2 20 non-free-firmware/debian-installer/binary-armhf/Packages.gz + 735a6b4efdd929bef7ab3b12b0afb94ca836878f0ba936d819aff3c70d51ce2e 70871 non-free-firmware/binary-arm64/Packages + 890ae3ed65d36c89b1f82245308345bda9981ea405cc51b63af24071951b254c 15116 non-free-firmware/binary-arm64/Packages.gz + e782c5ba217289af946c8fce940d86ebdce8f6da6191560ac067c6e0c62605c7 147 non-free-firmware/binary-arm64/Release + e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 0 non-free-firmware/debian-installer/binary-arm64/Packages + 59869db34853933b239f1e2219cf7d431da006aa919635478511fabbfc8849d2 20 non-free-firmware/debian-installer/binary-arm64/Packages.gz + 1a80e3d1f1478626bbcd569087d496cfa1ca31f4181c0f1672bdbe25df3bc579 41329 non-free-firmware/source/Sources + 26ce7fecaceb1d52f9ce00a2afe187a9366379d4467c6c330bd8239dfa36547b 10386 non-free-firmware/source/Sources.gz + b26828ee25c73b0a7a8d13d1f5072281202b363d71c43566c3a90abe33bba0d3 148 non-free-firmware/source/Release + 423e9e24afe9a0c41b10f7b433757242c2d091d1f93014eb5f14ac40977b70ca 758722605 main/Contents-i386 + 905fe81a972627da47ba7b2ddcf6e24c00a06d43a9249fb8b72ed5a93d2a3539 48719781 main/Contents-i386.gz + 41f03656dc40a052a4a963eb1683166d851ecd8aa95ad0015d94424c84057096 2652154 contrib/Contents-i386 + c49e054becdfa5739ad8008c3ac1a308ee343b67fe0977171c29bc1237ad7be1 180850 contrib/Contents-i386.gz + 2f58de5be4129ab01d2fc05ed69d6fe4c17e819a8e8e094ffc0c220e6b93cd03 15826486 non-free/Contents-i386 + 7326b9a4d2ee0fbc2c397377fdcb76ddd21de31ea5283a43ec31832adbe56e0d 882627 non-free/Contents-i386.gz + c304e06627f843e9440d58ff1eb3e0c34ce9cf8f18f017f4b85023ff590c61d9 845714 non-free-firmware/Contents-i386 + c645342f2f4ca52a5bcf8c5a6344e00a169263f6d34eebd2eeaf3c937958aa8b 41874 non-free-firmware/Contents-i386.gz + a5237f62182c8b23a1d5e328aa9d3b1371cf1f95e682058855646df925660f4c 877089125 main/Contents-amd64 + ed72b52a580c2c72da7c2df5aaddd738dc8f35df14b72a246ed1567947c1609f 54082810 main/Contents-amd64.gz + 03dc392e045d05b62ed7fbba479172a60a54ba3f4ee2791459a5780948bd64fc 4265469 contrib/Contents-amd64 + 60bebfce23df921cd81fa2588078be9b36acbcfe59ac4e082468ac3059133137 269687 contrib/Contents-amd64.gz + 44ea082d3f3ea8d2f87eee950847409b1f2795679996f14c0e37fff1c611ce12 16269585 non-free/Contents-amd64 + 87da36aaa98f7cd539e5c7b98ca12b2cfbf1a219dcd9fbf82836e741e78a0374 915112 non-free/Contents-amd64.gz + 2f624e55ba2f6e37eeedca1fd5c1927cb7c2e703bdfdf8a339e520ae48baac51 842380 non-free-firmware/Contents-amd64 + 44f54e992ea6ed521842893015fadfd14faa49b9fd8a6f7c5656de413c9898b5 41764 non-free-firmware/Contents-amd64.gz + d29c436fa6fd819d3a2c4494774c4ea934750a03ebb6a4d4ac63767323a2dab5 739291835 main/Contents-armhf + 4ddf7044d50a2085394597c4fa50d92255215da83f4cb7463575f113e5da1796 47778227 main/Contents-armhf.gz + 712e5f2cdea97edb5e337a354e11bdc60ecacba89494b766d1ea23cebe30f4dc 2640597 contrib/Contents-armhf + b46e369b284364b043ae09766b4b5688fc17a7f91b0f6ce9bce2eac4c7b6a92d 179018 contrib/Contents-armhf.gz + 4c4ba12ead4b6796e77e06bf96e60cb69bc3485a2dce4366dae9966dfb9182b9 15517956 non-free/Contents-armhf + 0f7b5fe0ae7f3a67eab109d1b2610cdbca284c744f651b1d2dc4d76603f60a5a 861261 non-free/Contents-armhf.gz + 97a85617f3fad1fc9dc7f84dc5a28a755a0244122d11cf80fd429f79cd2a0841 827561 non-free-firmware/Contents-armhf + 32bd59b0a374be3cbb19db17a778589661aef0e0b9b237e7d93431f833249685 40748 non-free-firmware/Contents-armhf.gz + b7573431aa6ccd6d5e9f57534586cd3ce65f3c3f263e001c87305f31af17cd98 790195791 main/Contents-arm64 + edd0d4a89a46028c5e2978ff78edbe67563ad8ceb2f2bfed9c35eff2d52647a3 50608160 main/Contents-arm64.gz + bac9f800a329412b0ba6391813e5a8a675cf6633e5f87143ff4bb3659de0776e 2674846 contrib/Contents-arm64 + 33ce0ed5eeb75084e2601464f821a1a93eb597f44e337c7472627960528ccafc 182575 contrib/Contents-arm64.gz + 6c7130c2b002735c60c097b85478097b4352669708305daa5cd6e0e495cdaa60 15812364 non-free/Contents-arm64 + 0c751cfef5a93961a6d4bd2a874377c92a1cc20d662ca081eef822c59adab409 881847 non-free/Contents-arm64.gz + c4192fde6e9d06c4cb791915b5b1225b753c09b7f7e4adafe4208ed8842db419 828327 non-free-firmware/Contents-arm64 + 4b86da1a9b21b74cea34b8c367f3501532e0cba6f9c5f983719754baec8f892a 40818 non-free-firmware/Contents-arm64.gz diff --git a/userland/capsule_linux_proofs/vectors/kali/Release.gpg b/userland/capsule_linux_proofs/vectors/kali/Release.gpg new file mode 100644 index 0000000000..fa3d31d3bc --- /dev/null +++ b/userland/capsule_linux_proofs/vectors/kali/Release.gpg @@ -0,0 +1,16 @@ +-----BEGIN PGP SIGNATURE----- + +iQIzBAABCgAdFiEEgnyFafJRjMZ3/soa7WVGLsjV5MUFAmq2Y8YACgkQ7WVGLsjV +5MUnqg/+NWG2z90DiY86j35V0n66hFcZvjW0xKeMnUVcc1zAbiTSpOi8asKs/XTB +gP20glFSM+4Wo1osp5wpM6IeT3coCNf3VXrSFu20KPC8sP8kPiLR7z0DpkaDCGlR +AjJfluLu0sMfMKQxxCkv53xVz1wcV/oPGc1PgMxD/zL87VtjWFzqPdE74lf9hALX +sh7xxB8aFNDT6gc760COvOTgx/PmqFDjiS+EUOQ+UzVW2nLho7Ms809+YhWO+x0y +ohJL+YL3f4TkZVMEfkcwuabd1+n2E+o2fhMhtg5D3r508U9bWaqWtaRgB7Yjg120 +t3KsvJPmL9BRp5RArgcChW+zKjrzizqB+jbFJql9BhTmlcmOr3PfD2Ml1Vz8IUHj +tGqJN291j+4IXmXE0HiGNhDEKmwenwpyViaH8EGO7/S7RVu7en8IZcEJQGf54Fiq +ESlDp8Z0JMGAf7oq4HPF/dXMEoT9IcnTDwBAbj5b+K9zgqH30XcO7aS4JD2GC6Uw +A2IVIOUv5dL7BL02VPCHiltTkvkEJuyo0W01HB5qtc9H8MKaJSaTfSmfKbrf0koF +yakgZ0Cr7881VymQOgbd4TDEeGZPYujmdkaWilF8TWvrR6323IWZABCkd0jaRkrg +Vg8pJ+viQf/zVHs7Mvsh4jLGgBi6siqI2Vmsm8tH+Ud1YVpJoG8= +=J/wL +-----END PGP SIGNATURE----- diff --git a/userland/capsule_linux_proofs/vectors/links.tar b/userland/capsule_linux_proofs/vectors/links.tar new file mode 100644 index 0000000000..d055b72714 Binary files /dev/null and b/userland/capsule_linux_proofs/vectors/links.tar differ diff --git a/userland/capsule_market/Capsule.mk b/userland/capsule_market/Capsule.mk index 3782855c50..cfcf7cfbd0 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 := $(TARGET_DIR)/market/index.bin + include nonos-mk/capsule.mk 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 94640ad717..ebd837c5be 100644 --- a/userland/capsule_market/Cargo.toml +++ b/userland/capsule_market/Cargo.toml @@ -25,6 +25,7 @@ path = "src/main.rs" 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 = [] diff --git a/userland/capsule_market/linux-packages.txt b/userland/capsule_market/linux-packages.txt new file mode 100644 index 0000000000..3f27a03a70 --- /dev/null +++ b/userland/capsule_market/linux-packages.txt @@ -0,0 +1 @@ +# Alpine packages the baseline catalogue lists, one per line. diff --git a/userland/capsule_market/src/boot_index.rs b/userland/capsule_market/src/boot_index.rs new file mode 100644 index 0000000000..eb6cf7d382 --- /dev/null +++ b/userland/capsule_market/src/boot_index.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 . + +//! 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, written by tools/nonos-market-index. +/// Empty when the build had no operator seed, which reads as "no baseline" +/// rather than as a failure. +static BASELINE: &[u8] = include_bytes!("../../../target/market/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..c243372dc6 100644 --- a/userland/capsule_market/src/bootstrap_trust/keys.rs +++ b/userland/capsule_market/src/bootstrap_trust/keys.rs @@ -14,9 +14,10 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -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, -]; +/// Marketplace operator, v1. Read from the key file so a scratch build can +/// stand in its own operator the way it stands in its own trust anchor. A +/// file that is not exactly 32 bytes does not compile. +pub(super) const NOX_OPERATOR_V1: [u8; 32] = + *include_bytes!("../../../../.keys/marketplace_operator_ed25519.pub"); 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..d65c6755da 100644 --- a/userland/capsule_market/src/install_ready/checks.rs +++ b/userland/capsule_market/src/install_ready/checks.rs @@ -16,12 +16,18 @@ 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; +/// 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 { @@ -30,8 +36,21 @@ 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; + /* + * 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; let install_ready = index_signature_valid && validation_passed @@ -40,7 +59,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 +69,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/get_release/handle.rs b/userland/capsule_market/src/server/handlers/get_release/handle.rs index 7685da310d..60f6b590eb 100644 --- a/userland/capsule_market/src/server/handlers/get_release/handle.rs +++ b/userland/capsule_market/src/server/handlers/get_release/handle.rs @@ -14,6 +14,7 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . +use super::super::install_ready::find_release::find_release; use super::encode_release::encode_release; use super::parse_pair::parse_pair; use crate::protocol::{Request, E_INVAL, E_MSGSIZE, E_NODATA}; @@ -30,14 +31,10 @@ pub(crate) fn handle(store: &Store, body: &[u8], req: &Request, tx: &mut [u8]) { Some(p) => p, None => return reply_status(tx, req, E_INVAL), }; - let release = accepted - .index - .entries - .iter() - .find(|e| e.listing_id == listing_id) - .and_then(|e| e.releases.iter().find(|r| r.release_id == release_id)); - let release = match release { - Some(r) => r, + // The same resolution readiness uses, so an empty id is the default + // release here too: the two must agree on which release a request means. + let release = match find_release(&accepted.index, listing_id, release_id) { + Some((_, _, r)) => r, None => return reply_status(tx, req, E_NODATA), }; let out = encode_release(release); 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..e5a38af1b7 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 @@ -16,7 +16,7 @@ use nonos_marketplace_abi::{CapsuleRelease, MarketplaceIndex}; -pub(super) fn find_release<'a>( +pub fn find_release<'a>( index: &'a MarketplaceIndex, listing_id: &str, release_id: &str, @@ -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..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), @@ -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/capsule_market/src/server/handlers/install_ready/mod.rs b/userland/capsule_market/src/server/handlers/install_ready/mod.rs index d5a33a5ba9..373afebd9f 100644 --- a/userland/capsule_market/src/server/handlers/install_ready/mod.rs +++ b/userland/capsule_market/src/server/handlers/install_ready/mod.rs @@ -15,7 +15,7 @@ // along with this program. If not, see . mod constants; -mod find_release; +pub(super) mod find_release; mod handle; mod parse_pair; mod take_lp; 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/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" 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]]) -} diff --git a/userland/capsule_policy/src/store/defaults/store.rs b/userland/capsule_policy/src/store/defaults/store.rs index a57fbc2315..54de6858d9 100644 --- a/userland/capsule_policy/src/store/defaults/store.rs +++ b/userland/capsule_policy/src/store/defaults/store.rs @@ -42,7 +42,8 @@ pub const fn store() -> Store { wifi_autoconnect: true, animations_enabled: true, cursor_size: 1, - wallpaper: 48, + // special-variant-9: catalog index 13 + 14 + 18 + 10. + wallpaper: 55, clock_format24: true, prefer_ipv6: false, metered_connection: false, @@ -53,6 +54,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_process_manager/src/pm/critical.rs b/userland/capsule_process_manager/src/pm/critical.rs index a252b162b1..290f32f1f7 100644 --- a/userland/capsule_process_manager/src/pm/critical.rs +++ b/userland/capsule_process_manager/src/pm/critical.rs @@ -15,8 +15,8 @@ // along with this program. If not, see . // Processes that hold the system or desktop up: ending one strands the session -// or the kernel. The monitor still allows it (the authority is real), but arms -// an extra confirmation so it is never a single stray keypress. +// or the kernel, so this monitor refuses to (kill_selected), and the inspector +// draws its actions as disabled rather than offering what it will not do. const CRITICAL: &[&[u8]] = &[ b"init", b"login", diff --git a/userland/capsule_process_manager/src/pm/ui/insp_actions.rs b/userland/capsule_process_manager/src/pm/ui/insp_actions.rs index 7a3221e4c1..a6be54f45b 100644 --- a/userland/capsule_process_manager/src/pm/ui/insp_actions.rs +++ b/userland/capsule_process_manager/src/pm/ui/insp_actions.rs @@ -16,7 +16,7 @@ use nonos_app_skeleton::PaintBuffer; -use crate::pm::theme::{DANGER, DANGER_TINT, TITLE}; +use crate::pm::theme::{DANGER, DANGER_TINT, MUTED, SIDEBAR_LINE, TITLE}; use super::insp_geom::btn; use super::metrics::{BODY_PX, INSP_BTN_RADIUS}; @@ -25,11 +25,26 @@ use super::text; // End Process asks the kernel nicely; Force Quit is the loud one, so it takes a // tinted ground under the same danger outline rather than a second solid fill // that would read as the safer of the two. -pub fn paint(fb: &mut PaintBuffer) { +// A process this monitor will not end gets no button that looks as if it +// would: both are quiet outlines saying so, and kill_selected still refuses. +pub fn paint(fb: &mut PaintBuffer, protected: bool) { + if protected { + quiet(fb, 0, b"Protected"); + quiet(fb, 1, b"Cannot be ended here"); + return; + } button(fb, 0, b"End Process", DANGER, TITLE); button(fb, 1, b"Force Quit", DANGER_TINT, DANGER); } +fn quiet(fb: &mut PaintBuffer, index: usize, label: &[u8]) { + let (x, y, w, h) = btn(fb.width, fb.height, index); + fb.stroke_round(x, y, w, h, INSP_BTN_RADIUS, 1, SIDEBAR_LINE); + let top = text::centred_top(y, h, BODY_PX); + let cx = x + w.saturating_sub(text::width(fb, label, BODY_PX)) / 2; + text::left(fb, cx, top, label, MUTED, BODY_PX); +} + // DANGER_TINT carries alpha and fill_round blends, which is what makes the // tinted ground legal over the pane this capsule has already painted. fn button(fb: &mut PaintBuffer, index: usize, label: &[u8], ground: u32, tint: u32) { diff --git a/userland/capsule_process_manager/src/pm/ui/inspector.rs b/userland/capsule_process_manager/src/pm/ui/inspector.rs index fe63faddd5..90030c8f36 100644 --- a/userland/capsule_process_manager/src/pm/ui/inspector.rs +++ b/userland/capsule_process_manager/src/pm/ui/inspector.rs @@ -44,7 +44,7 @@ pub fn paint(state: &State, fb: &mut PaintBuffer) { let n = u32_decimal(row.caps.count_ones(), &mut buf); y = insp_fields::field(fb, left, y, b"Authority", &buf[..n], TITLE); insp_chips::paint(fb, left, y, w, row.caps); - insp_actions::paint(fb); + insp_actions::paint(fb, crate::pm::critical::is_critical(row.name())); } fn heading(fb: &mut PaintBuffer, x: u32, y: u32, w: u32, row: &Row) -> u32 { diff --git a/userland/capsule_settings/src/settings/app.rs b/userland/capsule_settings/src/settings/app.rs index 2f9848477e..17c396afa4 100644 --- a/userland/capsule_settings/src/settings/app.rs +++ b/userland/capsule_settings/src/settings/app.rs @@ -21,7 +21,7 @@ use super::ipc::{hydrate, lookup_policy_port}; use super::manifest::manifest; use super::paint::paint; use super::section::Section; -use super::state::refresh_wifi::refresh_wifi_status; +use super::state::wifi_enter::refresh_wifi_status; use super::state::{state_new, State}; use super::ui::search_field; @@ -87,11 +87,11 @@ impl App for Settings { paint(&self.state, fb); } - // While the Wi-Fi panel is open, re-poll net_core every tick so a lease that - // binds a few seconds after connecting shows its address without the user - // having to trigger another scan. + // While the Wi-Fi or Network page is open, re-poll net_core every tick so a + // lease that binds a few seconds after connecting shows its address without + // the user having to trigger another scan. fn on_tick(&mut self) -> bool { - if self.state.section == Section::Wifi { + if matches!(self.state.section, Section::Wifi | Section::Network) { refresh_wifi_status(&mut self.state); return true; } diff --git a/userland/capsule_settings/src/settings/event/on_event_wifi.rs b/userland/capsule_settings/src/settings/event/on_event_wifi.rs index 00f9120104..eba73feb03 100644 --- a/userland/capsule_settings/src/settings/event/on_event_wifi.rs +++ b/userland/capsule_settings/src/settings/event/on_event_wifi.rs @@ -16,7 +16,8 @@ use nonos_app_skeleton::{EventOutcome, KEY_DOWN, KEY_ENTER, KEY_ESC, KEY_TAB, KEY_UP}; -use crate::settings::state::refresh_wifi::{connect_selected, run_wifi_scan}; +use crate::settings::state::refresh_wifi::run_wifi_scan; +use crate::settings::state::wifi_join::connect_selected; use crate::settings::state::State; use super::next_section::{next_section, prev_section}; diff --git a/userland/capsule_settings/src/settings/schema/all_fields.rs b/userland/capsule_settings/src/settings/schema/all_fields.rs index cf30f18077..5523d77e7f 100644 --- a/userland/capsule_settings/src/settings/schema/all_fields.rs +++ b/userland/capsule_settings/src/settings/schema/all_fields.rs @@ -16,52 +16,23 @@ use nonos_policy_proto::Field; +/* + * Every field Settings shows, each with the code that acts on it. A field + * with no reader is not listed: a switch that changes nothing is a lie. + * `coverage.rs` holds this list and the screens to each other. + */ pub const ALL_FIELDS: &[Field] = &[ - Field::Brightness, - Field::MouseSensitivity, - Field::SoundEnabled, - Field::AnonymousMode, - Field::NymEnabled, - Field::Theme, - Field::KeyboardLayout, - Field::AutoWipe, - Field::Timezone, - Field::ScreenTimeout, - Field::Language, - Field::DeveloperMode, - Field::HardwareCrypto, - Field::ZkAttestation, - Field::SystemKeysGenerated, - Field::NotificationsEnabled, - Field::HighContrast, - Field::FontSize, - Field::AutoLockTimeout, - Field::WifiAutoconnect, - Field::AnimationsEnabled, - Field::CursorSize, - Field::Wallpaper, - Field::ClockFormat24, - Field::KernelAslr, - Field::KernelStackGuard, - Field::KernelNxBit, - Field::KernelSmep, - Field::KernelSmap, - Field::KernelDebug, - Field::KernelSerial, - Field::KernelWatchdog, - Field::KernelPreempt, - Field::KernelHugepages, - Field::KernelIommu, - Field::KernelSeccomp, - Field::Hostname, - Field::DomainName, - Field::PreferIpv6, - Field::MeteredConnection, - Field::ProxyMode, - Field::WifiRadio, - Field::WifiAskToJoin, - Field::Volume, - Field::AudioBalance, - Field::AlertSounds, - Field::StartupChime, + Field::Hostname, // terminal prompt and identity + Field::Timezone, // shell menubar clock + Field::ClockFormat24, // shell menubar clock + Field::NotificationsEnabled, // shell notify handler + Field::WifiRadio, // this app's scan and join + Field::SystemKeysGenerated, // written by the setup wizard + Field::Wallpaper, // wallpaper capsule + Field::MouseSensitivity, // input router + Field::Persistent, // vfs persistence gate + Field::SoundEnabled, // shell tones + Field::Volume, // shell tones + Field::AlertSounds, // shell tones + Field::KernelPreempt, // scheduler tick ]; diff --git a/userland/capsule_settings/src/settings/schema/blocks/appearance.rs b/userland/capsule_settings/src/settings/schema/blocks/appearance.rs index 0e8d2b28b2..5e7d9272b2 100644 --- a/userland/capsule_settings/src/settings/schema/blocks/appearance.rs +++ b/userland/capsule_settings/src/settings/schema/blocks/appearance.rs @@ -19,30 +19,11 @@ use nonos_policy_proto::Field; use crate::settings::schema::rows::{Block, Pill, Row}; pub const APPEARANCE: &[Block] = &[ - Block { - title: "Theme", - note: None, - pill: Pill::None, - rows: &[ - Row::Field(Field::Theme), - Row::Field(Field::Wallpaper), - Row::Field(Field::HighContrast), - ], - }, - Block { - title: "Display", - note: None, - pill: Pill::None, - rows: &[ - Row::Field(Field::Brightness), - Row::Field(Field::FontSize), - Row::Field(Field::AnimationsEnabled), - ], - }, + Block { title: "Desktop", note: None, pill: Pill::None, rows: &[Row::Field(Field::Wallpaper)] }, Block { title: "Pointer", note: None, pill: Pill::None, - rows: &[Row::Field(Field::CursorSize), Row::Field(Field::MouseSensitivity)], + rows: &[Row::Field(Field::MouseSensitivity)], }, ]; diff --git a/userland/capsule_settings/src/settings/schema/blocks/developer.rs b/userland/capsule_settings/src/settings/schema/blocks/developer.rs index 227ba547e0..a225b84064 100644 --- a/userland/capsule_settings/src/settings/schema/blocks/developer.rs +++ b/userland/capsule_settings/src/settings/schema/blocks/developer.rs @@ -18,23 +18,9 @@ use nonos_policy_proto::Field; use crate::settings::schema::rows::{Block, Pill, Row}; -pub const DEVELOPER: &[Block] = &[ - Block { - title: "Developer mode", - note: Some("Unverified capsules can never spawn in a production build."), - pill: Pill::None, - rows: &[Row::Field(Field::DeveloperMode)], - }, - Block { - title: "Diagnostics", - note: None, - pill: Pill::None, - rows: &[Row::Field(Field::KernelDebug), Row::Field(Field::KernelSerial)], - }, - Block { - title: "Scheduler and memory", - note: None, - pill: Pill::None, - rows: &[Row::Field(Field::KernelPreempt), Row::Field(Field::KernelHugepages)], - }, -]; +pub const DEVELOPER: &[Block] = &[Block { + title: "Scheduler", + note: None, + pill: Pill::None, + rows: &[Row::Field(Field::KernelPreempt)], +}]; diff --git a/userland/capsule_settings/src/settings/schema/blocks/general.rs b/userland/capsule_settings/src/settings/schema/blocks/general.rs index 9dc632c4a7..87653c59fc 100644 --- a/userland/capsule_settings/src/settings/schema/blocks/general.rs +++ b/userland/capsule_settings/src/settings/schema/blocks/general.rs @@ -19,26 +19,16 @@ use nonos_policy_proto::Field; use crate::settings::schema::rows::{Block, Pill, Row}; pub const GENERAL: &[Block] = &[ + Block { title: "Device", note: None, pill: Pill::None, rows: &[Row::Field(Field::Hostname)] }, Block { - title: "Device", + title: "Date and time", note: None, pill: Pill::None, - rows: &[Row::Field(Field::Hostname), Row::Field(Field::DomainName)], - }, - Block { - title: "Language and region", - note: None, - pill: Pill::None, - rows: &[ - Row::Field(Field::Language), - Row::Field(Field::KeyboardLayout), - Row::Field(Field::Timezone), - Row::Field(Field::ClockFormat24), - ], + rows: &[Row::Field(Field::Timezone), Row::Field(Field::ClockFormat24)], }, Block { title: "Notifications", - note: Some("Let capsules post toasts to the desktop shell."), + note: None, pill: Pill::None, rows: &[Row::Field(Field::NotificationsEnabled)], }, diff --git a/userland/capsule_settings/src/settings/schema/blocks/network.rs b/userland/capsule_settings/src/settings/schema/blocks/network.rs index e8792fea6e..d52409daf6 100644 --- a/userland/capsule_settings/src/settings/schema/blocks/network.rs +++ b/userland/capsule_settings/src/settings/schema/blocks/network.rs @@ -14,8 +14,6 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -use nonos_policy_proto::Field; - use crate::settings::schema::rows::{Block, Live, Pill, Row}; pub const NETWORK: &[Block] = &[ @@ -30,21 +28,10 @@ pub const NETWORK: &[Block] = &[ Row::Live("DNS", Live::Dns), ], }, - Block { - title: "Network options", - note: None, - pill: Pill::None, - rows: &[ - Row::Field(Field::WifiAutoconnect), - Row::Field(Field::PreferIpv6), - Row::Field(Field::MeteredConnection), - Row::Field(Field::ProxyMode), - ], - }, Block { title: "Interfaces", note: None, pill: Pill::None, - rows: &[Row::Live("Adapter", Live::Adapter)], + rows: &[Row::Live("Wireless adapter", Live::Adapter)], }, ]; diff --git a/userland/capsule_settings/src/settings/schema/blocks/privacy.rs b/userland/capsule_settings/src/settings/schema/blocks/privacy.rs index 5b6677250f..62ca2f79dc 100644 --- a/userland/capsule_settings/src/settings/schema/blocks/privacy.rs +++ b/userland/capsule_settings/src/settings/schema/blocks/privacy.rs @@ -18,17 +18,9 @@ use nonos_policy_proto::Field; use crate::settings::schema::rows::{Block, Pill, Row}; -pub const PRIVACY: &[Block] = &[ - Block { - title: "Identity", - note: Some("What this device reveals about itself when it talks to a network."), - pill: Pill::None, - rows: &[Row::Field(Field::AnonymousMode), Row::Field(Field::NymEnabled)], - }, - Block { - title: "Screen", - note: None, - pill: Pill::None, - rows: &[Row::Field(Field::ScreenTimeout)], - }, -]; +pub const PRIVACY: &[Block] = &[Block { + title: "Memory", + note: Some("Chosen once, during setup. Without it, nothing is written to disk."), + pill: Pill::None, + rows: &[Row::Field(Field::Persistent)], +}]; diff --git a/userland/capsule_settings/src/settings/schema/blocks/security.rs b/userland/capsule_settings/src/settings/schema/blocks/security.rs index 60c742cae5..7758fe7624 100644 --- a/userland/capsule_settings/src/settings/schema/blocks/security.rs +++ b/userland/capsule_settings/src/settings/schema/blocks/security.rs @@ -16,38 +16,19 @@ use nonos_policy_proto::Field; -use crate::settings::schema::rows::{Block, Pill, Row, Tone}; +use crate::settings::schema::rows::{Block, Pill, Row}; pub const SECURITY: &[Block] = &[ Block { - title: "Lock screen", - note: None, + title: "Keys", + note: Some("Made on this machine by the setup wizard."), pill: Pill::None, - rows: &[Row::Field(Field::AutoLockTimeout), Row::Field(Field::AutoWipe)], + rows: &[Row::Field(Field::SystemKeysGenerated)], }, Block { - title: "Attestation and keys", - note: Some("Groth16 over BLS12-381, checked before any capsule spawns."), - pill: Pill::Fixed("Enforced", Tone::Ok), - rows: &[ - Row::Field(Field::HardwareCrypto), - Row::Field(Field::ZkAttestation), - Row::Field(Field::SystemKeysGenerated), - ], - }, - Block { - title: "Kernel hardening", - note: None, + title: "Kernel protections", + note: Some("SMEP, SMAP, UMIP, NX and WP are set at boot when the CPU has them."), pill: Pill::None, - rows: &[ - Row::Field(Field::KernelAslr), - Row::Field(Field::KernelNxBit), - Row::Field(Field::KernelSmep), - Row::Field(Field::KernelSmap), - Row::Field(Field::KernelStackGuard), - Row::Field(Field::KernelSeccomp), - Row::Field(Field::KernelIommu), - Row::Field(Field::KernelWatchdog), - ], + rows: &[], }, ]; diff --git a/userland/capsule_settings/src/settings/schema/blocks/sound.rs b/userland/capsule_settings/src/settings/schema/blocks/sound.rs index 8b9153145f..1b73164890 100644 --- a/userland/capsule_settings/src/settings/schema/blocks/sound.rs +++ b/userland/capsule_settings/src/settings/schema/blocks/sound.rs @@ -23,16 +23,12 @@ pub const SOUND: &[Block] = &[ title: "Output", note: None, pill: Pill::None, - rows: &[ - Row::Field(Field::SoundEnabled), - Row::Field(Field::Volume), - Row::Field(Field::AudioBalance), - ], + rows: &[Row::Field(Field::SoundEnabled), Row::Field(Field::Volume)], }, Block { title: "Alerts", note: None, pill: Pill::None, - rows: &[Row::Field(Field::AlertSounds), Row::Field(Field::StartupChime)], + rows: &[Row::Field(Field::AlertSounds)], }, ]; diff --git a/userland/capsule_settings/src/settings/schema/blocks/wifi.rs b/userland/capsule_settings/src/settings/schema/blocks/wifi.rs index fa32e15641..83d068c50c 100644 --- a/userland/capsule_settings/src/settings/schema/blocks/wifi.rs +++ b/userland/capsule_settings/src/settings/schema/blocks/wifi.rs @@ -21,15 +21,9 @@ use crate::settings::schema::rows::{Block, Pill, Row}; pub const WIFI: &[Block] = &[ Block { title: "Wi-Fi", - note: Some("Power the wireless radio on or off."), + note: Some("Off stops every scan and join from this machine."), pill: Pill::Radio, rows: &[Row::Field(Field::WifiRadio)], }, Block { title: "Networks", note: None, pill: Pill::None, rows: &[Row::Networks] }, - Block { - title: "Behaviour", - note: None, - pill: Pill::None, - rows: &[Row::Field(Field::WifiAskToJoin), Row::Field(Field::WifiAutoconnect)], - }, ]; diff --git a/userland/capsule_settings/src/settings/schema/coverage.rs b/userland/capsule_settings/src/settings/schema/coverage.rs index c455b75fe8..1f05609d30 100644 --- a/userland/capsule_settings/src/settings/schema/coverage.rs +++ b/userland/capsule_settings/src/settings/schema/coverage.rs @@ -58,4 +58,4 @@ const fn all_placed() -> bool { true } -const _: () = assert!(all_placed(), "every policy field must appear on a settings screen"); +const _: () = assert!(all_placed(), "every listed field must appear on a settings screen"); diff --git a/userland/capsule_settings/src/settings/schema/coverage_listed.rs b/userland/capsule_settings/src/settings/schema/coverage_listed.rs new file mode 100644 index 0000000000..98aba68715 --- /dev/null +++ b/userland/capsule_settings/src/settings/schema/coverage_listed.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 . + +//! The other direction: a row may only name a field in `ALL_FIELDS`, the list +//! of fields that some code reads, so a switch wired to nothing fails the build. + +use crate::settings::section::{SECTIONS, SECTION_COUNT}; + +use super::all_fields::ALL_FIELDS; +use super::blocks_for::blocks_for; +use super::rows::Row; + +const fn listed(id: u32) -> bool { + let mut i = 0; + while i < ALL_FIELDS.len() { + if ALL_FIELDS[i] as u32 == id { + return true; + } + i += 1; + } + false +} + +// Every row on a screen names a field from the list of fields with a reader. +const fn all_listed() -> bool { + let mut s = 0; + while s < SECTION_COUNT { + let blocks = blocks_for(SECTIONS[s]); + let mut b = 0; + while b < blocks.len() { + let mut r = 0; + while r < blocks[b].rows.len() { + if let Row::Field(f) = blocks[b].rows[r] { + if !listed(f as u32) { + return false; + } + } + r += 1; + } + b += 1; + } + s += 1; + } + true +} + +const _: () = assert!(all_listed(), "a settings row must name a field that something reads"); diff --git a/userland/capsule_settings/src/settings/schema/mod.rs b/userland/capsule_settings/src/settings/schema/mod.rs index 44b839beee..e3961ff86b 100644 --- a/userland/capsule_settings/src/settings/schema/mod.rs +++ b/userland/capsule_settings/src/settings/schema/mod.rs @@ -18,6 +18,7 @@ pub mod all_fields; pub mod blocks; pub mod blocks_for; pub mod coverage; +mod coverage_listed; pub mod read_only; pub mod rows; pub mod section_fields; diff --git a/userland/capsule_settings/src/settings/schema/read_only.rs b/userland/capsule_settings/src/settings/schema/read_only.rs index 70d6001d6f..82e2bc44f9 100644 --- a/userland/capsule_settings/src/settings/schema/read_only.rs +++ b/userland/capsule_settings/src/settings/schema/read_only.rs @@ -25,5 +25,6 @@ use nonos_policy_proto::Field; /// Whether `field` is a status the panel displays without editing. pub fn read_only(field: Field) -> bool { - matches!(field, Field::SystemKeysGenerated) + // Persistence is granted with consent in the setup wizard, not from a row. + matches!(field, Field::SystemKeysGenerated | Field::Persistent) } diff --git a/userland/capsule_settings/src/settings/section_text.rs b/userland/capsule_settings/src/settings/section_text.rs index a393aadafa..1b94b52ec9 100644 --- a/userland/capsule_settings/src/settings/section_text.rs +++ b/userland/capsule_settings/src/settings/section_text.rs @@ -31,32 +31,26 @@ pub fn subtitle(section: Section) -> &'static str { fn text(section: Section) -> (&'static str, &'static str, &'static str) { match section { Section::General => { - ("General", "General", "Device identity, language and how NONOS presents itself.") + ("General", "General", "This machine's name, the clock and notifications.") } Section::Network => { ("Network", "Network", "Manage how NONOS connects to networks and the internet.") } - Section::Wifi => { - ("Wi-Fi", "Wi-Fi", "Join a wireless network and manage the ones you have saved.") + Section::Wifi => ("Wi-Fi", "Wi-Fi", "Find and join a wireless network."), + Section::Security => { + ("Security", "Security", "This machine's keys and the protections the kernel keeps on.") } - Section::Security => ( - "Security", - "Security", - "Lock behaviour, attestation, and the kernel hardening posture.", - ), Section::Appearance => { - ("Appearance", "Appearance", "Theme, wallpaper, and how text and pointers are sized.") + ("Appearance", "Appearance", "The wallpaper and how the pointer moves.") } Section::Privacy => { - ("Privacy", "Privacy", "Identity and anonymity for everything this device sends.") + ("Privacy", "Privacy", "What this machine keeps once it is switched off.") } - Section::Sound => ("Sound", "Sound", "Output levels and system alert behaviour."), + Section::Sound => ("Sound", "Sound", "System tones and how loud they are."), Section::Storage => { ("Storage", "Storage", "How the capsule store and the filesystem are being used.") } Section::Updates => ("Updates", "Updates", "The signed image this machine is running."), - Section::Developer => { - ("Developer", "Developer", "Diagnostics and kernel switches for development builds.") - } + Section::Developer => ("Developer", "Developer", "How the scheduler shares the processor."), } } diff --git a/userland/capsule_settings/src/settings/state/mod.rs b/userland/capsule_settings/src/settings/state/mod.rs index e973d3f582..33c7dfe829 100644 --- a/userland/capsule_settings/src/settings/state/mod.rs +++ b/userland/capsule_settings/src/settings/state/mod.rs @@ -35,6 +35,8 @@ pub mod status; pub mod store_value; pub mod track_scroll; pub mod view_h; +pub mod wifi_enter; +pub mod wifi_join; pub use cache::FieldValue; pub use cached_value::cached_value; diff --git a/userland/capsule_settings/src/settings/state/refresh_wifi.rs b/userland/capsule_settings/src/settings/state/refresh_wifi.rs index 99628f9212..165ef9fce9 100644 --- a/userland/capsule_settings/src/settings/state/refresh_wifi.rs +++ b/userland/capsule_settings/src/settings/state/refresh_wifi.rs @@ -14,13 +14,11 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -use crate::wifi::{ - connect_network, driver_datapath, driver_stage, net_status, scan_adapters, scan_networks, - DriverStage, ScanOutcome, -}; +use crate::wifi::{scan_networks, DriverStage, ScanOutcome}; -use super::edit_buffer::EditBuffer; use super::state::{State, WifiConnect, WifiScan}; +use super::wifi_enter::refresh_wifi_status; +use super::wifi_join::radio_on; /// Ask the driver how far its radio came up and, only if it answered ready, scan. /// The quick status probe is also a liveness gate: a scan is a long blocking call, @@ -31,6 +29,13 @@ use super::state::{State, WifiConnect, WifiScan}; /// range. pub fn run_wifi_scan(state: &mut State) { refresh_wifi_status(state); + if !radio_on(state) { + // The Wi-Fi switch is off: no scan, and no stale list to join from. + state.wifi_network_count = 0; + state.wifi_cursor = 0; + state.wifi_scan = WifiScan::Idle; + return; + } // Once connected, a channel scan would retune the radio off the live link and // drop the connection (and net_core's traffic), so refreshing the status is // all a connected panel does; the scan is only for finding networks to join. @@ -65,65 +70,3 @@ pub fn run_wifi_scan(state: &mut State) { } } } - -/// Refresh the driver bring-up stage, the data-path frame counts and net_core's -/// lease without touching the radio, so the connected view (address, counters) -/// stays current on a live link that a channel scan would otherwise drop. -pub fn refresh_wifi_status(state: &mut State) { - state.wifi_stage = driver_stage(); - state.wifi_datapath = driver_datapath(); - state.wifi_net = net_status(); -} - -/// Re-enumerate the wireless adapters into the WiFi panel state and keep the -/// selection cursor inside the new list. -pub fn refresh_wifi(state: &mut State) { - state.wifi_adapter_count = scan_adapters(&mut state.wifi_adapters); - if state.wifi_cursor >= state.wifi_adapter_count { - state.wifi_cursor = state.wifi_adapter_count.saturating_sub(1); - } -} - -/// Begin or complete a connection to the selected network. A secured network -/// first opens the passphrase editor; the second call (or an open network on the -/// first) sends the driver the SSID and passphrase and runs the whole join, which -/// blocks for a few seconds. The result is recorded for the panel. -pub fn connect_selected(state: &mut State) { - if state.wifi_network_count == 0 { - return; - } - let idx = state.wifi_cursor.min(state.wifi_network_count - 1); - let secured = state.wifi_networks[idx].secured; - // A secured network needs a passphrase: open the editor on the first Enter. - if secured && !state.wifi_pass_active { - state.wifi_pass_active = true; - state.wifi_pass = EditBuffer::empty(); - return; - } - // The passphrase is in (or the network is open): join now. The driver call - // blocks for the length of the handshake, and the key handler returns Repaint - // straight after, so the outcome is painted the same frame the join finishes. - let idx = state.wifi_cursor.min(state.wifi_network_count - 1); - let mut ssid = [0u8; 32]; - let slen = { - let s = state.wifi_networks[idx].ssid(); - let n = s.len().min(32); - ssid[..n].copy_from_slice(&s[..n]); - n - }; - let result = connect_network(&ssid[..slen], state.wifi_pass.as_slice()); - state.wifi_connect = - if result.code == 0 { WifiConnect::Connected } else { WifiConnect::Failed(result) }; - state.wifi_pass_active = false; -} - -/// Switch to the Wi-Fi tab and enumerate adapters. Does not scan here: a scan is a -/// blocking request to the driver, and running it on tab entry would freeze the -/// whole app if the driver were slow to answer. The user starts a scan with Enter, -/// which keeps the app responsive while navigating. Leaves editing behind, like -/// selecting any other section. -pub fn enter_wifi(state: &mut State) { - state.editing = false; - refresh_wifi(state); - refresh_wifi_status(state); -} diff --git a/userland/capsule_settings/src/settings/state/set_section.rs b/userland/capsule_settings/src/settings/state/set_section.rs index 8e511f3517..a823808086 100644 --- a/userland/capsule_settings/src/settings/state/set_section.rs +++ b/userland/capsule_settings/src/settings/state/set_section.rs @@ -16,17 +16,17 @@ use crate::settings::section::Section; -use super::refresh_wifi::enter_wifi; use super::state::State; use super::track_scroll::track_scroll; +use super::wifi_enter::enter_wifi; -/// Select a section. Entering Wi-Fi enumerates adapters and re-reads net_core, -/// which is what the old Wi-Fi tab did on entry; it still does not scan, because -/// a scan blocks on the driver and would freeze the panel on navigation. +/// Select a section. Entering Wi-Fi or Network enumerates adapters and re-reads +/// net_core, so both pages show the link as it is; neither scans, because a scan +/// blocks on the driver and would freeze the panel on navigation. pub fn set_section(state: &mut State, section: Section) { state.section = section; state.editing = false; - if section == Section::Wifi { + if matches!(section, Section::Wifi | Section::Network) { enter_wifi(state); } track_scroll(state); diff --git a/userland/capsule_settings/src/settings/state/wifi_enter.rs b/userland/capsule_settings/src/settings/state/wifi_enter.rs new file mode 100644 index 0000000000..5364d5440e --- /dev/null +++ b/userland/capsule_settings/src/settings/state/wifi_enter.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 . + +use crate::wifi::{driver_datapath, driver_stage, net_status, scan_adapters}; + +use super::state::State; + +/// Re-enumerate the wireless adapters into the WiFi panel state and keep the +/// selection cursor inside the new list. +pub fn refresh_wifi(state: &mut State) { + state.wifi_adapter_count = scan_adapters(&mut state.wifi_adapters); + if state.wifi_cursor >= state.wifi_adapter_count { + state.wifi_cursor = state.wifi_adapter_count.saturating_sub(1); + } +} + +/// Switch to the Wi-Fi tab and enumerate adapters. Does not scan here: a scan is a +/// blocking request to the driver, and running it on tab entry would freeze the +/// whole app if the driver were slow to answer. The user starts a scan with Enter, +/// which keeps the app responsive while navigating. Leaves editing behind, like +/// selecting any other section. +pub fn enter_wifi(state: &mut State) { + state.editing = false; + refresh_wifi(state); + refresh_wifi_status(state); +} + +/// Refresh the driver bring-up stage, the data-path frame counts and net_core's +/// lease without touching the radio, so the connected view (address, counters) +/// stays current on a live link that a channel scan would otherwise drop. +pub fn refresh_wifi_status(state: &mut State) { + state.wifi_stage = driver_stage(); + state.wifi_datapath = driver_datapath(); + state.wifi_net = net_status(); +} diff --git a/userland/capsule_settings/src/settings/state/wifi_join.rs b/userland/capsule_settings/src/settings/state/wifi_join.rs new file mode 100644 index 0000000000..5e0dfa8aee --- /dev/null +++ b/userland/capsule_settings/src/settings/state/wifi_join.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 nonos_policy_proto::Field; + +use crate::wifi::connect_network; + +use super::cache::FieldValue; +use super::cached_value::cached_value; +use super::edit_buffer::EditBuffer; +use super::state::{State, WifiConnect}; + +/// Begin or complete a connection to the selected network. A secured network +/// first opens the passphrase editor; the second call (or an open network on the +/// first) sends the driver the SSID and passphrase and runs the whole join, which +/// blocks for a few seconds. The result is recorded for the panel. +pub fn connect_selected(state: &mut State) { + if state.wifi_network_count == 0 || !radio_on(state) { + return; + } + let idx = state.wifi_cursor.min(state.wifi_network_count - 1); + let secured = state.wifi_networks[idx].secured; + // A secured network needs a passphrase: open the editor on the first Enter. + if secured && !state.wifi_pass_active { + state.wifi_pass_active = true; + state.wifi_pass = EditBuffer::empty(); + return; + } + // The passphrase is in (or the network is open): join now. The driver call + // blocks for the length of the handshake, and the key handler returns Repaint + // straight after, so the outcome is painted the same frame the join finishes. + let idx = state.wifi_cursor.min(state.wifi_network_count - 1); + let mut ssid = [0u8; 32]; + let slen = { + let s = state.wifi_networks[idx].ssid(); + let n = s.len().min(32); + ssid[..n].copy_from_slice(&s[..n]); + n + }; + let result = connect_network(&ssid[..slen], state.wifi_pass.as_slice()); + state.wifi_connect = + if result.code == 0 { WifiConnect::Connected } else { WifiConnect::Failed(result) }; + state.wifi_pass_active = false; +} + +// Off only when the store says so; an unread value does not block the radio. +pub(super) fn radio_on(state: &State) -> bool { + !matches!(cached_value(state, Field::WifiRadio), FieldValue::Bool(false)) +} diff --git a/userland/capsule_settings/src/settings/ui/field_note_system.rs b/userland/capsule_settings/src/settings/ui/field_note_system.rs index f4dcd3be34..1a7ae84944 100644 --- a/userland/capsule_settings/src/settings/ui/field_note_system.rs +++ b/userland/capsule_settings/src/settings/ui/field_note_system.rs @@ -18,20 +18,8 @@ use nonos_policy_proto::Field; pub fn note(field: Field) -> Option<&'static str> { Some(match field { - Field::KernelAslr => "Randomise the kernel's virtual layout each boot.", - Field::KernelStackGuard => "Trap on stack overflow with a guard page.", - Field::KernelNxBit => "Refuse execution from writable pages.", - Field::KernelSmep => "Block the kernel from running user-mode pages.", - Field::KernelSmap => "Block stray kernel reads of user memory.", - Field::KernelIommu => "Confine device DMA to granted pages.", - Field::KernelSeccomp => "Restrict capsules to their declared syscalls.", - Field::KernelWatchdog => "Reset the machine if the scheduler stalls.", - Field::KernelDebug => "Emit kernel debug records on the serial line.", - Field::KernelSerial => "Mirror kernel logging to the serial port.", - Field::KernelPreempt => "Preempt kernel threads on the timer tick.", - Field::KernelHugepages => "Back large mappings with 2 MiB pages.", - Field::Hostname => "The name this machine announces on a network.", - Field::DomainName => "The domain this machine reports itself under.", + Field::KernelPreempt => "End a program's turn on the timer, so none can hold the CPU.", + Field::Hostname => "Shown in the terminal. Never sent on a network.", _ => return None, }) } diff --git a/userland/capsule_settings/src/settings/ui/field_note_user.rs b/userland/capsule_settings/src/settings/ui/field_note_user.rs index 9df923d102..334922bc68 100644 --- a/userland/capsule_settings/src/settings/ui/field_note_user.rs +++ b/userland/capsule_settings/src/settings/ui/field_note_user.rs @@ -18,23 +18,14 @@ use nonos_policy_proto::Field; pub fn note(field: Field) -> Option<&'static str> { Some(match field { - Field::AnonymousMode => "Route capsule traffic through the anonymity layer.", - Field::NymEnabled => "Announce this machine on the Nym mixnet.", - Field::AutoWipe => "Erase RAM-resident state when the machine powers down.", - Field::ZkAttestation => "Prove capsule integrity without revealing the binary.", - Field::HardwareCrypto => "Use CPU crypto instructions when the machine offers them.", - Field::SystemKeysGenerated => "Identity keys were minted during first boot.", - Field::WifiAutoconnect => "Automatically connect to known Wi-Fi networks.", - Field::PreferIpv6 => "Use IPv6 when available on supported networks.", - Field::MeteredConnection => "Hold background transfers on this connection.", - Field::WifiAskToJoin => "Offer open networks when no known one is in range.", - Field::AutoLockTimeout => "Minutes of inactivity before the session locks.", - Field::ScreenTimeout => "Minutes before the display sleeps.", - Field::DeveloperMode => "Unlock kernel diagnostics and unsigned tooling.", - Field::AnimationsEnabled => "Animate window and launcher transitions.", - Field::HighContrast => "Raise contrast across all system chrome.", - Field::AlertSounds => "Play a tone for system alerts.", - Field::StartupChime => "Play a tone when the machine finishes booting.", + Field::NotificationsEnabled => "News from apps. Warnings and errors always show.", + Field::WifiRadio => "Scan for and join wireless networks.", + Field::SystemKeysGenerated => "Set when setup has made this machine's keys.", + Field::Timezone => "Hours from UTC, used by the menu bar clock.", + Field::MouseSensitivity => "Scales mouse movement only.", + Field::Persistent => "Files and installed apps are kept between boots.", + Field::SoundEnabled => "Every tone the system plays.", + Field::AlertSounds => "A tone for warnings and errors.", _ => return None, }) } diff --git a/userland/capsule_setup_wizard/Capsule.mk b/userland/capsule_setup_wizard/Capsule.mk index 9b5919c852..2d184795b0 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 := 0x8001919 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/apply.rs b/userland/capsule_setup_wizard/src/consent/apply.rs new file mode 100644 index 0000000000..3b5ee3c921 --- /dev/null +++ b/userland/capsule_setup_wizard/src/consent/apply.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 . + +//! 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. `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(); + // Zeros prove nothing, so the next boot restores nothing. + store(&[0u8; 32]); + } + _ => {} + } +} + +/// 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 { + 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, bytes).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..fd30fdd45e --- /dev/null +++ b/userland/capsule_setup_wizard/src/consent/restore.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 . + +//! 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); + }; + if token == [0u8; 32] { + 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 2475ce2c52..e73e60dcbe 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,7 @@ pub unsafe extern "C" fn _start() -> ! { Ok(ctx) => ctx, Err(_) => mk_exit(2), }; + let mut ctx = ctx; + server::restore_poll::poll(&mut ctx); server::runner::run(ctx) } diff --git a/userland/capsule_setup_wizard/src/render/screens/appearance.rs b/userland/capsule_setup_wizard/src/render/screens/appearance.rs index bc6df4b57d..ca05e6e057 100644 --- a/userland/capsule_setup_wizard/src/render/screens/appearance.rs +++ b/userland/capsule_setup_wizard/src/render/screens/appearance.rs @@ -2,10 +2,17 @@ use crate::render::{self, widgets::rows}; use crate::server::step::{default_key, list_nav, Outcome}; use crate::state::Context; -const WALLS: &[&[u8]] = &[b"Deep", b"Slate", b"Night"]; +// Names shown in the list, and the wallpaper catalog index each one sets. +const WALLS: &[&[u8]] = &[b"Circuit", b"Emblem", b"Halo", b"Grid", b"Tiles", b"Lattice"]; +const CATALOG: [u8; 6] = [55, 13, 20, 27, 33, 60]; + +// The catalog index for the chosen row; the first row is the system default. +pub fn wallpaper(sel: u8) -> u8 { + CATALOG.get(sel as usize).copied().unwrap_or(CATALOG[0]) +} pub fn draw(ctx: &Context) { - render::frame(ctx, b"Appearance", b"j/k wallpaper, t cycles theme", b"ENTER NEXT ESC BACK"); + render::frame(ctx, b"Appearance", b"j/k to choose a wallpaper", b"ENTER NEXT ESC BACK"); let spx = ctx.stride as usize / 4; let (w, h) = (ctx.width, ctx.height); let buf = render::buffer(ctx); @@ -13,10 +20,6 @@ pub fn draw(ctx: &Context) { } pub fn on_key(ctx: &mut Context, code: u32) -> Outcome { - if code == b't' as u32 { - ctx.theme_sel = (ctx.theme_sel + 1) % 3; - return Outcome::Stay; - } if let Some(o) = list_nav(&mut ctx.wall_sel, WALLS.len() as u8, code) { return o; } 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..9f14ddd1e5 --- /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; + +/// 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( + 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/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"); diff --git a/userland/capsule_setup_wizard/src/render/screens/review.rs b/userland/capsule_setup_wizard/src/render/screens/review.rs index 80e19724cc..36a8d72442 100644 --- a/userland/capsule_setup_wizard/src/render/screens/review.rs +++ b/userland/capsule_setup_wizard/src/render/screens/review.rs @@ -10,10 +10,17 @@ 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, 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] = [ (b"Identity keys", ctx.keys_done), (b"Passphrase set", ctx.pass_len > 0), - (b"Layout/wallpaper chosen", true), + (b"Layout and wallpaper chosen", true), + (local, true), ]; render::widgets::progress::busy(buf, spx, w, h, render::content_x(w), 120, &lines, 0); } @@ -26,12 +33,14 @@ fn commit(ctx: &Context) { let _ = policy::set_u8(p, Field::Language as u32, ctx.lang_sel); let _ = policy::set_u8(p, Field::KeyboardLayout as u32, ctx.kbd_sel); let _ = policy::set_i8(p, Field::Timezone as u32, ctx.tz_off); - let _ = policy::set_u8(p, Field::Wallpaper as u32, ctx.wall_sel); - let _ = policy::set_u8(p, Field::Theme as u32, ctx.theme_sel); + let _ = policy::set_u8(p, Field::Wallpaper as u32, super::appearance::wallpaper(ctx.wall_sel)); let _ = policy::set_bool(p, Field::AnonymousMode as u32, ctx.net_sel == 0); 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); + let _ = policy::set_bool(p, Field::SystemKeysGenerated as u32, ctx.keys_done); + 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_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/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 878ec02893..06eaf10666 100644 --- a/userland/capsule_setup_wizard/src/server/runner.rs +++ b/userland/capsule_setup_wizard/src/server/runner.rs @@ -7,16 +7,47 @@ 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) -> ! { - let _ = input_router::subscribe(ctx.router_port, 1); - let _ = input_router::grab_keyboard(ctx.router_port, 2); + 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 = 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(), 0, &mut sender); + 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; } 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/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..36ca00cdd5 100644 --- a/userland/capsule_setup_wizard/src/state.rs +++ b/userland/capsule_setup_wizard/src/state.rs @@ -15,9 +15,14 @@ pub struct Context { pub keygen_stage: u8, pub lang_sel: u8, pub tz_off: i8, - 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, + /// 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], @@ -53,9 +58,11 @@ impl Context { keygen_stage: 0, lang_sel: 0, tz_off: 0, - theme_sel: 0, net_sel: 0, persist_sel: 0, + local_sel: 0, + local_was: false, + local_pending: false, privacy: 0b0000_0011, admin_len: 0, admin_buf: [0u8; 64], diff --git a/userland/capsule_std_proof/src/big_alloc.rs b/userland/capsule_std_proof/src/big_alloc.rs new file mode 100644 index 0000000000..1f733195b5 --- /dev/null +++ b/userland/capsule_std_proof/src/big_alloc.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 . + +//! One large allocation, every page touched, the shape of a STARK prover's +//! buffers: hundreds of megabytes in one piece, not a heap of small ones. + +const MIB: usize = 1 << 20; +/// Large enough to need the big-allocation path, small enough for the 2 GiB +/// test machine beside the desktop. +pub const SIZE: usize = 256 * MIB; +const PAGE: usize = 4096; + +pub fn prove() -> Result { + let mut buf: Vec = Vec::new(); + buf.try_reserve_exact(SIZE).map_err(|e| format!("reserve {} MiB: {e}", SIZE / MIB))?; + buf.resize(SIZE, 0); + for (i, page) in buf.chunks_mut(PAGE).enumerate() { + page[0] = (i % 251) as u8; + } + let bad = buf.chunks(PAGE).enumerate().find(|(i, p)| p[0] != (*i % 251) as u8); + match bad { + Some((i, _)) => Err(format!("page {i} lost its byte")), + None => Ok(format!("{} MiB in one allocation, {} pages touched", SIZE / MIB, SIZE / PAGE)), + } +} diff --git a/userland/capsule_std_proof/src/file_io.rs b/userland/capsule_std_proof/src/file_io.rs new file mode 100644 index 0000000000..b90ca30500 --- /dev/null +++ b/userland/capsule_std_proof/src/file_io.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 . + +//! A file written, read back, sought into and removed through std::fs over +//! the vfs, the way any crate that keeps a cache on disk would. + +use std::fs::{self, OpenOptions}; +use std::io::{Read, Seek, SeekFrom, Write}; + +const PATH: &str = "/tmp/std_proof.bin"; + +pub fn prove() -> Result { + let body: Vec = (0..4096u32).map(|i| (i * 7 % 256) as u8).collect(); + let mut f = OpenOptions::new() + .create(true) + .write(true) + .read(true) + .truncate(true) + .open(PATH) + .map_err(|e| format!("open {PATH}: {e}"))?; + f.write_all(&body).map_err(|e| format!("write: {e}"))?; + f.seek(SeekFrom::Start(1000)).map_err(|e| format!("seek: {e}"))?; + let mut mid = [0u8; 16]; + f.read_exact(&mut mid).map_err(|e| format!("read after seek: {e}"))?; + drop(f); + let whole = fs::read(PATH).map_err(|e| format!("read back: {e}"))?; + fs::remove_file(PATH).map_err(|e| format!("remove: {e}"))?; + if whole != body || mid[..] != body[1000..1016] { + return Err("bytes read back differ from those written".into()); + } + Ok(format!("{} bytes written, sought to 1000, read back, removed", body.len())) +} diff --git a/userland/capsule_std_proof/src/main.rs b/userland/capsule_std_proof/src/main.rs index e49f93bfc8..5ad28efd06 100644 --- a/userland/capsule_std_proof/src/main.rs +++ b/userland/capsule_std_proof/src/main.rs @@ -8,6 +8,10 @@ // max of a numeric field, and a base64 digest of the document. This shows an // off-the-shelf Rust library doing real work on real input, attested and live. +mod big_alloc; +mod file_io; +mod random; + use base64::Engine; use serde_json::Value; @@ -49,6 +53,15 @@ fn main() { Err(detail) => println!("NONOS std proof FAIL threads: {detail}"), } + let checks: [(&str, fn() -> Result); 3] = + [("alloc", big_alloc::prove), ("file", file_io::prove), ("random", random::prove)]; + for (name, check) in checks { + match check() { + Ok(detail) => println!("NONOS std proof PASS {name}: {detail}"), + Err(detail) => println!("NONOS std proof FAIL {name}: {detail}"), + } + } + println!("NONOS STD PROOF DONE"); } diff --git a/userland/capsule_std_proof/src/random.rs b/userland/capsule_std_proof/src/random.rs new file mode 100644 index 0000000000..4430b1d943 --- /dev/null +++ b/userland/capsule_std_proof/src/random.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 . + +//! Randomness as std draws it: RandomState seeds each map's hasher from the +//! platform's source, which on NONOS is the kernel's generator. Two states +//! hashing the same value must disagree. + +use std::collections::hash_map::RandomState; +use std::hash::BuildHasher; + +pub fn prove() -> Result { + let draws: Vec = (0..4).map(|_| RandomState::new().hash_one(0x4e4f_4e4f_u64)).collect(); + let distinct = draws.iter().enumerate().all(|(i, a)| draws[..i].iter().all(|b| a != b)); + match distinct { + true => Ok(format!("{} RandomState seeds, all distinct", draws.len())), + false => Err(format!("seeds repeated: {draws:x?}")), + } +} diff --git a/userland/capsule_terminal/src/command/builtin/help.rs b/userland/capsule_terminal/src/command/builtin/help.rs index 5abf25b1a1..8b5be463ec 100644 --- a/userland/capsule_terminal/src/command/builtin/help.rs +++ b/userland/capsule_terminal/src/command/builtin/help.rs @@ -14,52 +14,62 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -//! List the available commands, grouped, so a new user can discover the shell -//! without leaving it. +//! `help`: the commands, grouped, and the way to each deeper page. Keys and +//! shell syntax were in the same wall once, which filled the window and pushed +//! the command that asked for it out of sight; now `help keys`, `help shell`. use crate::command::output::Output; +const GROUPS: &[(&[u8], &[u8])] = &[ + (b"files", b"ls tree cat cd pwd mkdir touch rm rmdir mv cp stat find du"), + (b"text", b"head tail grep wc echo sort uniq cut nl tac rev"), + (b"system", b"capsules service ps kill sys id whoami date uptime battery about"), + (b"net", b"ping ifconfig nslookup curl nym"), + (b"apps", b"market install pkg git nox"), +]; + +const DEEPER: &[(&[u8], &[u8])] = &[ + (b"help keys", b"editing, history, tabs and view keys"), + (b"help shell", b"pipes, redirects, jobs and aliases"), + (b"help ", b"what one command takes"), +]; + pub fn run(out: &mut Output<'_>) { - out.writeln(b"files ls tree cat cd pwd mkdir touch rm rmdir mv cp stat"); - out.writeln(b" find du basename dirname pull push"); - out.writeln(b"text head tail grep wc echo (pipe: sort uniq[-c] cut nl tac rev)"); - out.writeln(b"shell | > >> < alias unalias set unset env history clear Ctrl-L"); - out.writeln(b" jobs fg bg exec run/open exit type/which"); - out.writeln(b"editing Ctrl-A start Ctrl-E end Ctrl-Left/Right by word Tab complete"); - out.writeln(b" Ctrl-W cut word Ctrl-K cut to end Ctrl-U cut line Ctrl-Y put back"); - out.writeln(b" Ctrl-D delete Ctrl-R search history Up/Down recall Ctrl-C abandon"); - out.writeln(b"history !! last command !n the nth !text the last starting with text"); - out.writeln(b"tabs Ctrl+Shift+T new Ctrl+Shift+W close Ctrl+PgUp/PgDn switch"); - out.writeln(b"view Ctrl-B side rail Ctrl+= / Ctrl+- font size"); - out.writeln(b"system capsules service ps kill sys id whoami date uptime battery"); - out.writeln(b" version about motd neofetch display theme/profile"); - out.writeln(b"net ping ifconfig/ip nslookup/host curl/http nym"); - out.writeln(b"apps apps/market install pkg git"); - out.writeln(b"nox nox (run 'nox help' for the chain tools)"); - out.writeln(b" help for what one takes"); - tools(out); + out.writeln(b"Type a command and press Enter. Tab completes."); + out.writeln(b""); + for (name, list) in GROUPS { + row(out, name, list, 9); + } + super::help_tools::tools(out); + out.writeln(b""); + for (name, what) in DEEPER { + row(out, name, what, 13); + } } -/// The installed crates.io programs, listed from the table that runs them. -/// -/// These are ordinary published crates, built for this system and admitted by -/// the same spawn gate as everything else. They are worth naming here because -/// nothing else on screen says they exist, and a tool nobody can discover may -/// as well not be installed. -fn tools(out: &mut Output<'_>) { - const LEAD: &[u8] = b"tools "; - let mut line = [b' '; 96]; - line[..LEAD.len()].copy_from_slice(LEAD); - let mut n = LEAD.len(); - for (typed, _) in super::tool::TOOLS { - // Two spaces between names, matching the groups above. A name that - // would not fit is dropped rather than wrapped: the list is a pointer - // to what exists, not the manual. - if n + typed.len() + 2 > line.len() { - break; - } - line[n..n + typed.len()].copy_from_slice(typed); - n += typed.len() + 2; +/// `help keys` and `help shell`; false for anything else. +pub fn topic(out: &mut Output<'_>, name: &[u8]) -> bool { + let lines: &[(&[u8], &[u8])] = match name { + b"keys" => &super::help_pages::KEYS, + b"shell" => &super::help_pages::SHELL, + _ => return false, + }; + for (name, what) in lines { + row(out, name, what, 10); } - out.writeln(&line[..n]); + true +} + +/// A label in the accent colour, padded to `pad`, then the text. +pub(super) fn row(out: &mut Output<'_>, name: &[u8], text: &[u8], pad: usize) { + let mut plain = alloc::vec![b' '; 2]; + plain.extend_from_slice(name); + plain.resize(2 + pad.max(name.len() + 1), b' '); + plain.extend_from_slice(text); + let mut styled = alloc::vec::Vec::with_capacity(plain.len() + 12); + styled.extend_from_slice(b" \x1b[36m"); + styled.extend_from_slice(name); + styled.extend_from_slice(b"\x1b[0m"); + styled.extend_from_slice(&plain[2 + name.len()..]); + out.writeln_styled(&plain, &styled); } diff --git a/userland/capsule_terminal/src/command/builtin/help_pages.rs b/userland/capsule_terminal/src/command/builtin/help_pages.rs new file mode 100644 index 0000000000..11be511256 --- /dev/null +++ b/userland/capsule_terminal/src/command/builtin/help_pages.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 deeper help pages: keys and shell syntax. + +pub const KEYS: [(&[u8], &[u8]); 8] = [ + (b"move", b"Ctrl-A start Ctrl-E end Ctrl-Left/Right by word"), + (b"cut", b"Ctrl-W word Ctrl-K to end Ctrl-U line Ctrl-Y put back Ctrl-D delete"), + (b"complete", b"Tab completes a command or a path"), + (b"recall", b"Up/Down walk history Ctrl-R search it Ctrl-C abandon the line"), + (b"history", b"!! the last command !n the nth !text the last starting with text"), + (b"tabs", b"Ctrl+Shift+T new Ctrl+Shift+W close Ctrl+PgUp/PgDn switch"), + (b"view", b"Ctrl-B side rail Ctrl+= / Ctrl+- font size Ctrl-L clear"), + (b"theme", b"theme or profile to change colours"), +]; + +pub const SHELL: [(&[u8], &[u8]); 6] = [ + (b"pipe", b"a | b feed a's output to b"), + (b"redirect", b"a > f a >> f a < f to, onto, or from a file"), + (b"chain", b"a && b a || b a ; b on success, on failure, always"), + (b"jobs", b"a & jobs fg bg run in the background and bring it back"), + (b"alias", b"alias ll ls -l unalias ll set unset env"), + (b"run", b"run or open exec type or which exit"), +]; diff --git a/userland/capsule_terminal/src/command/builtin/help_tools.rs b/userland/capsule_terminal/src/command/builtin/help_tools.rs new file mode 100644 index 0000000000..5583439203 --- /dev/null +++ b/userland/capsule_terminal/src/command/builtin/help_tools.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 . + +//! The tools line of `help`. + +use crate::command::output::Output; + +/// The installed crates.io programs, listed from the table that runs them. +/// +/// These are ordinary published crates, built for this system and admitted by +/// the same spawn gate as everything else. They are worth naming here because +/// nothing else on screen says they exist, and a tool nobody can discover may +/// as well not be installed. +pub fn tools(out: &mut Output<'_>) { + let mut list = alloc::vec::Vec::with_capacity(96); + for (typed, _) in super::tool::TOOLS { + // Two spaces between names, matching the groups above. A name that + // would not fit is dropped rather than wrapped: the list is a pointer + // to what exists, not the manual. + if list.len() + typed.len() + 2 > 84 { + break; + } + list.extend_from_slice(typed); + list.extend_from_slice(b" "); + } + // Styled and aligned like every other group. + super::help::row(out, b"tools", &list, 9); +} diff --git a/userland/capsule_terminal/src/command/builtin/mod.rs b/userland/capsule_terminal/src/command/builtin/mod.rs index 8fd1c2c4c3..6246b43d9e 100644 --- a/userland/capsule_terminal/src/command/builtin/mod.rs +++ b/userland/capsule_terminal/src/command/builtin/mod.rs @@ -26,6 +26,8 @@ pub mod fs; pub mod git; pub mod help; pub mod help_one; +mod help_pages; +mod help_tools; pub mod history_cmd; pub mod jobs; pub mod market; diff --git a/userland/capsule_terminal/src/command/builtin/tool.rs b/userland/capsule_terminal/src/command/builtin/tool.rs index e6981ab083..3574506e39 100644 --- a/userland/capsule_terminal/src/command/builtin/tool.rs +++ b/userland/capsule_terminal/src/command/builtin/tool.rs @@ -32,9 +32,10 @@ pub const TOOLS: &[(&[u8], &[u8])] = &[ (b"csview", b"csview"), (b"rg", b"ripgrep"), (b"ripgrep", b"ripgrep"), - (b"install", b"install"), ]; +// `install` is absent for the same reason as `sd`: the builtin that installs +// from the market answers to the name first (jobs::classify). // `sd` is deliberately absent. It runs from the vfs store through `STORE_TOOLS` // in jobs::classify, which is checked before this table, so an entry here would // never be reached and would read as a second answer to the same question. diff --git a/userland/capsule_terminal/src/command/dispatch/exec.rs b/userland/capsule_terminal/src/command/dispatch/exec.rs index b2c2ea8017..4f74ed8e81 100644 --- a/userland/capsule_terminal/src/command/dispatch/exec.rs +++ b/userland/capsule_terminal/src/command/dispatch/exec.rs @@ -76,7 +76,8 @@ pub(super) fn exec(state: &mut State, args: &[&[u8]]) -> Outcome { let mut out = Output::new(&mut state.scrollback); match args.get(1) { Some(name) => { - let ok = builtin::help_one::run(&mut out, name); + let ok = builtin::help::topic(&mut out, name) + || builtin::help_one::run(&mut out, name); state.last_status = i32::from(!ok); } None => builtin::help::run(&mut out), diff --git a/userland/capsule_terminal/src/paint/prompt.rs b/userland/capsule_terminal/src/paint/prompt.rs index 91d6894375..a23ddfd440 100644 --- a/userland/capsule_terminal/src/paint/prompt.rs +++ b/userland/capsule_terminal/src/paint/prompt.rs @@ -56,8 +56,10 @@ pub fn draw_prompt( // The mark takes the colour of what the last command did, so a reader who // looked away while it ran learns the outcome where they are about to // type rather than by finding the block it came from. + // Where you are, then the mark, then a space: `~/src $ `. Read the other + // way round, `>~`, it looked like a redirect into a file named `~`. let mark = if state.last_status == 0 { t.accent } else { t.err }; - text(fb, ox, y, b">", mark, adv, px); - text(fb, ox + adv, y, &cwd[cwd.len() - take..], t.path, adv, px); - 1 + take + 1 + text(fb, ox, y, &cwd[cwd.len() - take..], t.path, adv, px); + text(fb, ox + (take as u32 + 1) * adv, y, b"$", mark, adv, px); + take + 3 } diff --git a/userland/capsule_terminal/src/term/manifest.rs b/userland/capsule_terminal/src/term/manifest.rs index 0b00eefd33..584d19dfa1 100644 --- a/userland/capsule_terminal/src/term/manifest.rs +++ b/userland/capsule_terminal/src/term/manifest.rs @@ -22,9 +22,12 @@ use nonos_app_skeleton::{AppManifest, WindowKind}; /// every command-line tool has assumed since terminals were hardware, and the /// width this shell's own `help` is written to. At the previous 520 by 300 the /// text area was about fifty-eight columns and `help` was clipped at the right -/// edge, silently, with no wrap and no scroll to reach the rest. -pub const WIDTH: u32 = 760; -pub const HEIGHT: u32 = 460; +/// edge, silently, with no wrap and no scroll to reach the rest. At 760 by +/// 460 `help` alone filled the window, so its output scrolled the command +/// that asked for it out of sight; this holds a hundred columns and room to +/// read what a command printed under it. +pub const WIDTH: u32 = 960; +pub const HEIGHT: u32 = 540; const INPUT_KEY_DOWN_BIT: u32 = 1 << 0; @@ -33,8 +36,9 @@ pub fn manifest() -> AppManifest { title: b"Terminal", window_id: 0x5445_524D, kind: WindowKind::Normal, - initial_x: 188, - initial_y: 404, + // Centred on a 1280 by 720 screen, clear of the top bar and the dock. + initial_x: 160, + initial_y: 90, width: WIDTH, height: HEIGHT, input_kind_mask: INPUT_KEY_DOWN_BIT, diff --git a/userland/capsule_terminal/src/term/prompt/bytes.rs b/userland/capsule_terminal/src/term/prompt/bytes.rs index 445e9dfe05..ffcf83bd1d 100644 --- a/userland/capsule_terminal/src/term/prompt/bytes.rs +++ b/userland/capsule_terminal/src/term/prompt/bytes.rs @@ -19,4 +19,4 @@ /// The same mark `draw_prompt` puts in front of the line being typed, so what /// a command looked like while it was entered is what it looks like once it is /// history. It sits under the `user@host:path` line the block opens with. -pub const PROMPT_BYTES: &[u8] = b"> "; +pub const PROMPT_BYTES: &[u8] = b"$ "; 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/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/blk/store_header.rs b/userland/capsule_vfs/src/blk/store_header.rs index 75b84da82d..12961b4779 100644 --- a/userland/capsule_vfs/src/blk/store_header.rs +++ b/userland/capsule_vfs/src/blk/store_header.rs @@ -25,7 +25,11 @@ const VERSION: u32 = 1; pub const HEADER_LEN: usize = 32; pub const ENTRY_LEN: usize = 128; -pub const MAX_ENTRIES: usize = 64; +// The packed boot image's file count. The table is decoded into a heap Vec +// and every byte is still bounded by MAX_TOTAL_BYTES, so this only sizes the +// table: 128 entries is 16 KiB. Raised from 64 once the Linux-guest test +// image packed more than that many signed files. +pub const MAX_ENTRIES: usize = 128; pub fn entry_count(head: &[u8]) -> Result { if head.len() < HEADER_LEN || &head[0..8] != MAGIC || le_u32(head, 8) != VERSION { 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/list.rs b/userland/capsule_vfs/src/server/handlers/list.rs index b5469bf0cf..6c81b791cc 100644 --- a/userland/capsule_vfs/src/server/handlers/list.rs +++ b/userland/capsule_vfs/src/server/handlers/list.rs @@ -25,7 +25,7 @@ use crate::store::Store; // Reply body: concatenated `` entries, capped // at MAX_LIST_BYTES. pub fn list(store: &mut Store, req: Request<'_>, sender_pid: u32) -> Vec { - let (_pid, rest) = match split_caller(req.payload, sender_pid) { + let (pid, rest) = match split_caller(req.payload, sender_pid) { Ok(v) => v, Err(s) => return encode_response(OP_LIST, req.flags, req.request_id, s, &[]), }; @@ -43,6 +43,6 @@ pub fn list(store: &mut Store, req: Request<'_>, sender_pid: u32) -> Vec { Ok(s) => s, Err(_) => return encode_response(OP_LIST, req.flags, req.request_id, EINVAL, &[]), }; - let body: Vec = store.list(prefix, MAX_LIST_BYTES as usize); + let body: Vec = store.list(prefix, MAX_LIST_BYTES as usize, pid); encode_response(OP_LIST, req.flags, req.request_id, 0, &body) } 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/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/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/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/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_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 2ed05a0ffd..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; @@ -38,13 +39,24 @@ 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), + &[], + ) } }; + 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/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/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/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/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()); +} diff --git a/userland/capsule_vfs/src/store/fdtable/query.rs b/userland/capsule_vfs/src/store/fdtable/query.rs index 0141c3bc94..5ef552a628 100644 --- a/userland/capsule_vfs/src/store/fdtable/query.rs +++ b/userland/capsule_vfs/src/store/fdtable/query.rs @@ -18,6 +18,9 @@ use alloc::vec::Vec; use super::types::{Store, StoreError}; +/// Where the Linux personality keeps each family's private directories. +const PRIVATE: &str = "/linux-private"; + impl Store { pub fn stat(&self, path: &str) -> Result<(u64, bool, u64, u16), StoreError> { match self.find(path) { @@ -44,10 +47,13 @@ impl Store { .count() as u64 } - pub fn list(&self, prefix: &str, max_bytes: usize) -> Vec { + /// Every path under `prefix` that `viewer` may see. A Linux family's + /// scratch directories are its own: listed to the process that made + /// them, and to no file manager, editor or other capsule. + pub fn list(&self, prefix: &str, max_bytes: usize, viewer: u32) -> Vec { let mut out = Vec::new(); for f in self.files.iter() { - if !f.name.starts_with(prefix) { + if !f.name.starts_with(prefix) || (f.name.starts_with(PRIVATE) && f.owner != viewer) { continue; } let mut nb = Vec::from(f.name.as_bytes()); diff --git a/userland/capsule_wallet_nonos/Cargo.lock b/userland/capsule_wallet_nonos/Cargo.lock index 7132348c57..5788b43344 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" @@ -107,7 +114,9 @@ dependencies = [ "nonos_qr", "nonos_seal", "nonos_tls", + "nonos_toolkit", "nonos_userland_libc", + "spin", ] [[package]] diff --git a/userland/capsule_wallet_nonos/Cargo.toml b/userland/capsule_wallet_nonos/Cargo.toml index 9785738566..6037a633fe 100644 --- a/userland/capsule_wallet_nonos/Cargo.toml +++ b/userland/capsule_wallet_nonos/Cargo.toml @@ -15,6 +15,8 @@ path = "src/main.rs" nonos_libc = { package = "nonos_userland_libc", path = "../libc" } nonos_seal = { path = "../nonos_seal" } nonos_app_skeleton = { path = "../app_skeleton" } +nonos_toolkit = { path = "../toolkit", default-features = false } +spin = "0.9" nonos_qr = { path = "../nonos_qr" } nonos_hd = { path = "../nonos_hd" } nonos_tls = { path = "../nonos_tls" } diff --git a/userland/capsule_wallet_nonos/src/wallet/etna/backdrop.rs b/userland/capsule_wallet_nonos/src/wallet/etna/backdrop.rs new file mode 100644 index 0000000000..74f76836e4 --- /dev/null +++ b/userland/capsule_wallet_nonos/src/wallet/etna/backdrop.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 . + +//! The section photographs: Etna in eruption, recoloured to ink, deep teal +//! and cyan. Ten sections, eight photographs; see assets/etna/index.txt. +//! Photo: gnuckx, CC BY 2.0, recoloured by NONOS (assets/etna/CREDITS.txt). + +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum Backdrop { + Welcome, + Home, + Send, + Receive, + Deposit, + Withdraw, + Proving, + History, + Settings, + Backup, +} + +const WELCOME: &[u8] = include_bytes!("../../../../assets/etna/etna-387f21b9dbd2.png"); +const HOME: &[u8] = include_bytes!("../../../../assets/etna/etna-90380f7829df.png"); +const SEND: &[u8] = include_bytes!("../../../../assets/etna/etna-4bb30d031341.png"); +const RECEIVE: &[u8] = include_bytes!("../../../../assets/etna/etna-fcb6fdda9325.png"); +const DEPOSIT: &[u8] = include_bytes!("../../../../assets/etna/etna-460f26b9c468.png"); +const WITHDRAW: &[u8] = include_bytes!("../../../../assets/etna/etna-a0754afa8556.png"); +const PROVING: &[u8] = include_bytes!("../../../../assets/etna/etna-0b0046f5f585.png"); +const HISTORY: &[u8] = include_bytes!("../../../../assets/etna/etna-32f5aa765d0e.png"); + +impl Backdrop { + /// The encoded photograph; sections that share one share the bytes. + pub fn png(self) -> &'static [u8] { + match self { + Backdrop::Welcome => WELCOME, + Backdrop::Home => HOME, + Backdrop::Send | Backdrop::Backup => SEND, + Backdrop::Receive | Backdrop::Settings => RECEIVE, + Backdrop::Deposit => DEPOSIT, + Backdrop::Withdraw => WITHDRAW, + Backdrop::Proving => PROVING, + Backdrop::History => HISTORY, + } + } +} + +/// The credit every place the photographs appear must carry. +pub const CREDIT: &str = "Photo: gnuckx, CC BY 2.0, recoloured by N\u{d8}NOS"; diff --git a/userland/capsule_wallet_nonos/src/wallet/etna/band.rs b/userland/capsule_wallet_nonos/src/wallet/etna/band.rs new file mode 100644 index 0000000000..8143e21b63 --- /dev/null +++ b/userland/capsule_wallet_nonos/src/wallet/etna/band.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 . + +//! The photograph at the head of a section, full width under the bar, then +//! the fade the phones lay over it: ink at 35% at the top, clear from 30% to +//! 55%, solid ink at the foot, so it melts into the ground. + +use alloc::vec::Vec; + +use nonos_app_skeleton::PaintBuffer; +use nonos_toolkit::png::decoder::decode_png_argb8888; +use spin::Mutex; + +use super::backdrop::Backdrop; +use super::tokens::{BANNER_H, COLUMN, INK}; + +/// One decoded photograph at a time: the section on screen. +static DECODED: Mutex)>> = Mutex::new(None); + +fn fade(row: u32, h: u32) -> u32 { + let t = row * 1000 / h.max(1); + let alpha = match t { + 0..=299 => 350 * (300 - t) / 300, + 300..=549 => 0, + _ => 1000 * (t - 550) / 450, + }; + alpha.min(1000) * 255 / 1000 +} + +/// Draw `which` `h` rows tall with its top-left at `x, top`, rows above the +/// screen left out. A band shorter than the photograph shows its middle, +/// which is where each photograph's eruption sits. +pub fn band(fb: &mut PaintBuffer, x: u32, top: i64, which: Backdrop, h: u32) -> u32 { + let h = h.min(BANNER_H); + let skip = (BANNER_H - h) / 2; + let mut slot = DECODED.lock(); + if slot.as_ref().map(|(b, _)| *b) != Some(which) { + let mut px = alloc::vec![0u32; (COLUMN * BANNER_H) as usize]; + let ok = decode_png_argb8888(which.png(), &mut px).is_ok_and(|s| s.width == COLUMN); + *slot = ok.then_some((which, px)); + } + for row in 0..h { + let Ok(y) = u32::try_from(top + i64::from(row)) else { + continue; + }; + if let Some((_, px)) = slot.as_ref() { + let from = ((row + skip) * COLUMN) as usize; + for col in 0..COLUMN { + fb.blend_px(x + col, y, px[from + col as usize] | 0xFF00_0000); + } + } else { + fb.fill_rect(x, y, COLUMN, 1, INK); + } + fb.blend_rect(x, y, COLUMN, 1, (fade(row, h) << 24) | (INK & 0x00FF_FFFF)); + } + h +} diff --git a/userland/capsule_wallet_nonos/src/wallet/etna/face.rs b/userland/capsule_wallet_nonos/src/wallet/etna/face.rs new file mode 100644 index 0000000000..f5f8938c2b --- /dev/null +++ b/userland/capsule_wallet_nonos/src/wallet/etna/face.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 two faces the brand uses and no third: Geist for prose in three +//! weights, JetBrains Mono for every value, label and button. Both are OFL, +//! see assets/fonts/Geist-OFL.txt and assets/fonts/JetBrainsMono-OFL.txt. + +use nonos_toolkit::ttf::FontRef; +use spin::Once; + +const GEIST: &[u8] = include_bytes!("../../../../assets/fonts/Geist-Regular.ttf"); +const GEIST_MEDIUM: &[u8] = include_bytes!("../../../../assets/fonts/Geist-Medium.ttf"); +const GEIST_SEMI: &[u8] = include_bytes!("../../../../assets/fonts/Geist-SemiBold.ttf"); +const MONO: &[u8] = include_bytes!("../../../../assets/fonts/JetBrainsMono-Regular.ttf"); + +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum Face { + Sans, + SansMedium, + SansSemi, + Mono, +} + +static FACES: [Once>>; 4] = + [Once::new(), Once::new(), Once::new(), Once::new()]; + +/// The parsed face, once. A face that does not parse draws nothing rather +/// than falling back to a font the design does not use. +pub fn font(face: Face) -> Option<&'static FontRef<'static>> { + let (slot, bytes) = match face { + Face::Sans => (0, GEIST), + Face::SansMedium => (1, GEIST_MEDIUM), + Face::SansSemi => (2, GEIST_SEMI), + Face::Mono => (3, MONO), + }; + FACES[slot].call_once(|| FontRef::try_from_slice(bytes).ok()).as_ref() +} diff --git a/userland/capsule_wallet_nonos/src/wallet/etna/frame.rs b/userland/capsule_wallet_nonos/src/wallet/etna/frame.rs new file mode 100644 index 0000000000..ef9271b7aa --- /dev/null +++ b/userland/capsule_wallet_nonos/src/wallet/etna/frame.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 one frame every wallet screen sits in, as ScreenFrame.swift draws +//! it. The photograph and the content scroll together between a fixed bar +//! and a fixed foot, so the frame is drawn in two passes: `begin` lays the +//! ground and the scrolled photograph and says where content goes, the +//! screen draws, then `end` lays the bar, the footer and the status line +//! over whatever ran past, which is the clip. + +use nonos_app_skeleton::PaintBuffer; + +use super::band::band; +use super::frame_bar::{band_height, bar}; +use super::frame_foot::{foot, foot_height}; +use super::frame_spec::{FrameLayout, FrameSpec}; +use super::parts::failure::failure; +use super::rect::Rect; +use super::tokens::{BAR_H, COLUMN, GAP, INK, ROOM, SIDE}; + +pub fn column_x(fb: &PaintBuffer) -> u32 { + fb.width.saturating_sub(COLUMN) / 2 +} + +/// Ground, photograph and error banner, shifted up by `spec.scroll`. +pub fn begin(fb: &mut PaintBuffer, spec: &FrameSpec) -> FrameLayout { + let mut out = FrameLayout::default(); + fb.fill_rect(0, 0, fb.width, fb.height, INK); + let x0 = column_x(fb); + let top = (BAR_H + 1) as i64 - i64::from(spec.scroll); + let mut y = top; + if let Some(which) = spec.backdrop { + let h = band_height(fb, spec); + if y + i64::from(h) > 0 { + band(fb, x0, y, which, h); + } + y += i64::from(h); + } + y += i64::from(ROOM); + let (cx, cw) = (x0 + SIDE, COLUMN - 2 * SIDE); + if let Some(text) = spec.failure { + let fy = y.max(0) as u32; + let (h, dismiss) = failure(fb, cx, fy, cw, text); + out.dismiss = Some(dismiss); + y += i64::from(h + GAP); + } + let bottom = fb.height.saturating_sub(foot_height(spec)); + let cy = y.max(0) as u32; + out.content = Rect::new(cx, cy, cw, bottom.saturating_sub(cy + ROOM)); + out.content_bottom = bottom; + out +} + +/// Bar, footer and status line, over the content. +pub fn end(fb: &mut PaintBuffer, spec: &FrameSpec, out: &mut FrameLayout) { + let x0 = column_x(fb); + fb.fill_rect(x0, 0, COLUMN, BAR_H + 1, INK); + out.back = bar(fb, x0, spec); + foot(fb, x0, spec, out); +} diff --git a/userland/capsule_wallet_nonos/src/wallet/etna/frame_bar.rs b/userland/capsule_wallet_nonos/src/wallet/etna/frame_bar.rs new file mode 100644 index 0000000000..7f5c2d63cd --- /dev/null +++ b/userland/capsule_wallet_nonos/src/wallet/etna/frame_bar.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 fixed parts of the frame: the bar at the top with the way back and +//! the screen's number and name, and the foot with the pinned actions and +//! the status line. + +use nonos_app_skeleton::PaintBuffer; + +use super::frame_foot::foot_height; +use super::frame_spec::FrameSpec; +use super::parts::label::screen_label; +use super::parts::rule::rule; +use super::rect::Rect; +use super::roles::Role; +use super::symbol::{symbol, Symbol}; +use super::text::line; +use super::tokens::{BACK, BANNER_H, BAR_H, COLUMN, HALF, SIDE, TEXT_3}; + +pub fn bar(fb: &mut PaintBuffer, x0: u32, spec: &FrameSpec) -> Option { + let mut label_x = x0 + SIDE; + let mut back = None; + if spec.back { + let at = Rect::new(x0 + HALF, (BAR_H - BACK) / 2, BACK, BACK); + symbol(fb, (at.x + BACK / 2) as i32, (at.y + BACK / 2) as i32, Symbol::ChevronLeft, TEXT_3); + label_x = at.x + BACK; + back = Some(at); + } + let ly = (BAR_H - line(Role::ScreenLabel) as u32) / 2; + screen_label(fb, label_x as i32, ly as i32, spec.number, spec.title); + rule(fb, x0, BAR_H, COLUMN, 0); + back +} + +/// The photograph's height here: the phones' full 1290:860, or on a window +/// too short for it, half of what lies between the bar and the foot. +pub fn band_height(fb: &PaintBuffer, spec: &FrameSpec) -> u32 { + let open = fb.height.saturating_sub(BAR_H + 1 + foot_height(spec)); + BANNER_H.min(open / 2) +} diff --git a/userland/capsule_wallet_nonos/src/wallet/etna/frame_foot.rs b/userland/capsule_wallet_nonos/src/wallet/etna/frame_foot.rs new file mode 100644 index 0000000000..a6660ae7ac --- /dev/null +++ b/userland/capsule_wallet_nonos/src/wallet/etna/frame_foot.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 fixed foot of the frame: the pinned actions and the status line. + +use nonos_app_skeleton::PaintBuffer; + +use super::frame_spec::{FrameLayout, FrameSpec}; +use super::parts::action::action; +use super::parts::rule::rule; +use super::parts::status::status_line; +use super::rect::Rect; +use super::tokens::{COLUMN, INK, ROOM, SIDE, STATUS_H, TALL, TIGHT}; + +fn buttons_height(n: u32) -> u32 { + if n == 0 { + 0 + } else { + ROOM * 2 + TALL * n + TIGHT * (n - 1) + 1 + } +} + +pub fn foot_height(spec: &FrameSpec) -> u32 { + let status = if spec.status.is_empty() { 0 } else { STATUS_H }; + buttons_height(spec.footer.len() as u32) + status +} + +pub fn foot(fb: &mut PaintBuffer, x0: u32, spec: &FrameSpec, out: &mut FrameLayout) { + let top = fb.height.saturating_sub(foot_height(spec)); + fb.fill_rect(x0, top, COLUMN, fb.height - top, INK); + let n = spec.footer.len() as u32; + if n > 0 { + rule(fb, x0, top, COLUMN, 0); + for (i, (title, weight, enabled)) in spec.footer.iter().enumerate() { + let at = Rect::new( + x0 + SIDE, + top + 1 + ROOM + (TALL + TIGHT) * i as u32, + COLUMN - 2 * SIDE, + TALL, + ); + action(fb, at, title, *weight, *enabled); + out.footer[i.min(2)] = at; + } + } + if !spec.status.is_empty() { + let sy = fb.height - STATUS_H; + rule(fb, x0, sy, COLUMN, 0); + status_line(fb, x0, sy + 1, spec.status); + } +} diff --git a/userland/capsule_wallet_nonos/src/wallet/etna/frame_spec.rs b/userland/capsule_wallet_nonos/src/wallet/etna/frame_spec.rs new file mode 100644 index 0000000000..d840e1b432 --- /dev/null +++ b/userland/capsule_wallet_nonos/src/wallet/etna/frame_spec.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 . + +//! What a screen asks its frame for, and where the frame put things. + +use super::backdrop::Backdrop; +use super::parts::action::Weight; +use super::rect::Rect; + +pub struct FrameSpec<'a> { + pub number: &'a str, + pub title: &'a str, + pub back: bool, + pub backdrop: Option, + pub failure: Option<&'a str>, + /// Up to three pinned actions, top to bottom. + pub footer: &'a [(&'a str, Weight, bool)], + pub status: &'a [&'a str], + /// How far the photograph and content have scrolled up, in pixels. + pub scroll: u32, +} + +#[derive(Default)] +pub struct FrameLayout { + pub back: Option, + pub dismiss: Option, + /// The column left for the screen's own content. + pub content: Rect, + pub footer: [Rect; 3], + /// Where the fixed foot begins; content below it is covered. + pub content_bottom: u32, +} diff --git a/userland/capsule_wallet_nonos/src/wallet/etna/groups.rs b/userland/capsule_wallet_nonos/src/wallet/etna/groups.rs new file mode 100644 index 0000000000..29ddb807c3 --- /dev/null +++ b/userland/capsule_wallet_nonos/src/wallet/etna/groups.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 . + +//! Long values broken into runs of eight so a person can check them and a +//! quiet alteration shows, and the two cuts the phones use. The same rules +//! as Groups.swift. + +use alloc::string::String; +use alloc::vec::Vec; + +pub const RUN: usize = 8; +const ENDS: usize = 6; +const MARK: &str = "\u{2026}"; + +pub fn grouped(value: &str) -> String { + let chars: Vec = value.chars().collect(); + let runs: Vec = chars.chunks(RUN).map(|c| c.iter().collect()).collect(); + runs.join(" ") +} + +/// A hash cut to its ends, as in 0x6def9b\u{2026}f3a4, so a forged prefix must +/// also match the suffix. +pub fn hash(value: &str) -> String { + let n = value.chars().count(); + if !value.starts_with("0x") || n <= 12 { + return String::from(value); + } + let head: String = value.chars().take(8).collect(); + let tail: String = value.chars().skip(n - 4).collect(); + alloc::format!("{head}{MARK}{tail}") +} + +pub fn shortened(value: &str) -> String { + let n = value.chars().count(); + if n <= ENDS * 2 + 1 { + return String::from(value); + } + let head: String = value.chars().take(ENDS).collect(); + let tail: String = value.chars().skip(n - ENDS).collect(); + alloc::format!("{head}{MARK}{tail}") +} diff --git a/userland/capsule_wallet_nonos/src/wallet/etna/mod.rs b/userland/capsule_wallet_nonos/src/wallet/etna/mod.rs new file mode 100644 index 0000000000..c14996d624 --- /dev/null +++ b/userland/capsule_wallet_nonos/src/wallet/etna/mod.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 . + +//! The Etna design system in the wallet capsule: tokens, faces and type +//! roles first, then the parts and the one frame every screen sits in. + +pub mod backdrop; +mod band; +pub mod face; +pub mod frame; +mod frame_bar; +mod frame_foot; +pub mod frame_spec; +pub mod groups; +pub mod parts; +pub mod rect; +mod roles; +pub mod symbol; +pub mod text; +pub mod tokens; +pub mod wrap; + +pub use roles::Role; diff --git a/userland/capsule_wallet_nonos/src/wallet/etna/parts/action.rs b/userland/capsule_wallet_nonos/src/wallet/etna/parts/action.rs new file mode 100644 index 0000000000..734205cbc9 --- /dev/null +++ b/userland/capsule_wallet_nonos/src/wallet/etna/parts/action.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 two weights of action, and no third. A primary is cyan with ink text; +//! a secondary is an outline in the text colour; an action that cannot be +//! pressed is an outline in text-3, never a faded cyan. + +use nonos_app_skeleton::PaintBuffer; + +use super::super::rect::Rect; +use super::super::roles::Role; +use super::super::text::{draw_in, line, width}; +use super::super::tokens::{CORNER, CYAN, INK, OUTLINE, TEXT, TEXT_3}; + +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum Weight { + Primary, + Secondary, +} + +pub fn action(fb: &mut PaintBuffer, at: Rect, title: &str, weight: Weight, enabled: bool) { + let filled = weight == Weight::Primary && enabled; + let ink = match (filled, enabled) { + (true, _) => INK, + (false, true) => TEXT, + (false, false) => TEXT_3, + }; + if filled { + fb.fill_round(at.x, at.y, at.w, at.h, CORNER, CYAN); + } else { + fb.stroke_round(at.x, at.y, at.w, at.h, CORNER, 1, OUTLINE); + } + let tx = at.x as i32 + (at.w as i32 - width(Role::Button, title)) / 2; + let ty = at.y as i32 + (at.h as i32 - line(Role::Button)) / 2; + draw_in(fb, tx, ty, Role::Button, title, ink); +} diff --git a/userland/capsule_wallet_nonos/src/wallet/etna/parts/fact.rs b/userland/capsule_wallet_nonos/src/wallet/etna/parts/fact.rs new file mode 100644 index 0000000000..a473539730 --- /dev/null +++ b/userland/capsule_wallet_nonos/src/wallet/etna/parts/fact.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 . + +//! What the wallet knows, as values instead of sentences: a name in mono +//! capitals on the left, the value on the right. Never a placeholder value. + +use nonos_app_skeleton::PaintBuffer; + +use super::super::roles::Role; +use super::super::text::{draw, draw_in, line, width}; +use super::super::tokens::TEXT_3; + +/// Facts are 10 apart. +pub const FACT_GAP: u32 = 10; + +pub fn fact(fb: &mut PaintBuffer, x: u32, y: u32, w: u32, name: &str, value: &str) -> u32 { + let upper = name.to_uppercase(); + draw_in(fb, x as i32, y as i32, Role::Fact, &upper, TEXT_3); + let vx = (x + w) as i32 - width(Role::Fact, value); + draw(fb, vx, y as i32, Role::Fact, value); + line(Role::Fact) as u32 + FACT_GAP +} diff --git a/userland/capsule_wallet_nonos/src/wallet/etna/parts/failure.rs b/userland/capsule_wallet_nonos/src/wallet/etna/parts/failure.rs new file mode 100644 index 0000000000..3e6a605067 --- /dev/null +++ b/userland/capsule_wallet_nonos/src/wallet/etna/parts/failure.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 . + +//! What the core refused, in one plain sentence on the banner ground, and a +//! way to put it away. It is the first thing in a screen's content. + +use nonos_app_skeleton::PaintBuffer; + +use super::super::rect::Rect; +use super::super::roles::Role; +use super::super::text::{draw_in, line, width}; +use super::super::tokens::{BANNER, RADIUS, TEXT, TEXT_3}; +use super::super::wrap::wrapped; + +const PAD: u32 = 16; +const GAP: u32 = 8; + +/// Draw across `w` at `x, y`; returns the height and where Dismiss is. +pub fn failure(fb: &mut PaintBuffer, x: u32, y: u32, w: u32, text: &str) -> (u32, Rect) { + let inner = (w - 2 * PAD) as i32; + // Measured first by drawing off the bottom, then drawn on its ground. + let body = wrapped(fb, (x + PAD) as i32, i32::MAX / 2, inner, Role::Lead, text, TEXT) as u32; + let h = PAD * 2 + body + GAP + line(Role::Lead) as u32; + fb.fill_round(x, y, w, h, RADIUS, BANNER); + wrapped(fb, (x + PAD) as i32, (y + PAD) as i32, inner, Role::Lead, text, TEXT); + let dy = y + PAD + body + GAP; + draw_in(fb, (x + PAD) as i32, dy as i32, Role::Lead, "Dismiss", TEXT_3); + let dw = width(Role::Lead, "Dismiss") as u32; + (h, Rect::new(x + PAD, dy, dw, line(Role::Lead) as u32)) +} diff --git a/userland/capsule_wallet_nonos/src/wallet/etna/parts/label.rs b/userland/capsule_wallet_nonos/src/wallet/etna/parts/label.rs new file mode 100644 index 0000000000..20838cf414 --- /dev/null +++ b/userland/capsule_wallet_nonos/src/wallet/etna/parts/label.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 . + +//! The screen's number and name in the bar, "03 RECEIVE", and the lead +//! sentence a screen is allowed. + +use alloc::{format, string::String}; + +use nonos_app_skeleton::PaintBuffer; + +use super::super::roles::Role; +use super::super::text::draw; + +pub fn screen_label(fb: &mut PaintBuffer, x: i32, top: i32, number: &str, title: &str) -> i32 { + let text = if number.is_empty() { String::from(title) } else { format!("{number} {title}") }; + draw(fb, x, top, Role::ScreenLabel, &text) +} diff --git a/userland/capsule_wallet_nonos/src/wallet/etna/parts/mod.rs b/userland/capsule_wallet_nonos/src/wallet/etna/parts/mod.rs new file mode 100644 index 0000000000..ad27938839 --- /dev/null +++ b/userland/capsule_wallet_nonos/src/wallet/etna/parts/mod.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 . + +//! The parts every wallet screen is built from, as the phones' Parts folder +//! names them. + +pub mod action; +pub mod fact; +pub mod failure; +pub mod label; +pub mod qr; +pub mod quiet; +pub mod round; +pub mod rule; +pub mod status; +pub mod tile; +pub mod value; diff --git a/userland/capsule_wallet_nonos/src/wallet/etna/parts/qr.rs b/userland/capsule_wallet_nonos/src/wallet/etna/parts/qr.rs new file mode 100644 index 0000000000..b87b588ea1 --- /dev/null +++ b/userland/capsule_wallet_nonos/src/wallet/etna/parts/qr.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 . + +//! A value as a QR code, drawn here so nothing leaves the machine to draw +//! it: medium correction, the four-module quiet zone, dark modules on the +//! paper colour with 12 of padding and the control corner, as the phones. + +use nonos_app_skeleton::PaintBuffer; + +use super::super::tokens::{CORNER, INK, TEXT}; + +const PAD: u32 = 12; +const QUIET: u32 = 4; + +/// Draw a `side` square at `x, y`; false when the value does not encode. +pub fn qr(fb: &mut PaintBuffer, x: u32, y: u32, side: u32, value: &[u8]) -> bool { + let Some(code) = nonos_qr::encode(value, nonos_qr::Ecc::Medium) else { + return false; + }; + fb.fill_round(x, y, side, side, CORNER, TEXT); + let span = code.size as u32 + 2 * QUIET; + let scale = ((side - 2 * PAD) / span).max(1); + let drawn = span * scale; + let ox = x + (side - drawn) / 2 + QUIET * scale; + let oy = y + (side - drawn) / 2 + QUIET * scale; + for my in 0..code.size { + for mx in 0..code.size { + if code.get(mx, my) { + fb.fill_rect(ox + mx as u32 * scale, oy + my as u32 * scale, scale, scale, INK); + } + } + } + true +} diff --git a/userland/capsule_wallet_nonos/src/wallet/etna/parts/quiet.rs b/userland/capsule_wallet_nonos/src/wallet/etna/parts/quiet.rs new file mode 100644 index 0000000000..b0ff106bd3 --- /dev/null +++ b/userland/capsule_wallet_nonos/src/wallet/etna/parts/quiet.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 . + +//! The actions a screen offers without asking for them: rows of mono 14 in +//! one outlined box, with rules inset under the text, like a settings list. + +use nonos_app_skeleton::PaintBuffer; + +use super::super::rect::Rect; +use super::super::roles::Role; +use super::super::text::{draw_in, line}; +use super::super::tokens::{CORNER, OUTLINE, TEXT, TEXT_3}; +use super::rule::rule; + +const PAD_X: u32 = 16; +const PAD_Y: u32 = 14; + +pub fn quiet_row_height() -> u32 { + line(Role::RowValue) as u32 + 2 * PAD_Y +} + +/// Draw the group; each row's rectangle is written to `hits` for clicks. +pub fn quiet_group( + fb: &mut PaintBuffer, + x: u32, + y: u32, + w: u32, + rows: &[(&str, bool)], + hits: &mut [Rect], +) -> u32 { + let rh = quiet_row_height(); + for (i, (title, enabled)) in rows.iter().enumerate() { + let ry = y + rh * i as u32; + let ink = if *enabled { TEXT } else { TEXT_3 }; + draw_in(fb, (x + PAD_X) as i32, (ry + PAD_Y) as i32, Role::RowValue, title, ink); + if i + 1 < rows.len() { + rule(fb, x, ry + rh - 1, w, PAD_X); + } + if let Some(slot) = hits.get_mut(i) { + *slot = Rect::new(x, ry, w, rh); + } + } + let h = rh * rows.len() as u32; + fb.stroke_round(x, y, w, h, CORNER, 1, OUTLINE); + h +} diff --git a/userland/capsule_wallet_nonos/src/wallet/etna/parts/round.rs b/userland/capsule_wallet_nonos/src/wallet/etna/parts/round.rs new file mode 100644 index 0000000000..953b59cf71 --- /dev/null +++ b/userland/capsule_wallet_nonos/src/wallet/etna/parts/round.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 of the four things a holder does from home: a 56 pixel cyan circle +//! with its sign in ink and a word under it. Disabled, the circle is an +//! outline and the sign and word are text-3. + +use nonos_app_skeleton::PaintBuffer; + +use super::super::rect::Rect; +use super::super::roles::Role; +use super::super::symbol::{symbol, Symbol}; +use super::super::text::{draw_in, line, width}; +use super::super::tokens::{CYAN, INK, LINE_2, ROUND, TEXT, TEXT_3}; + +/// The sign sits 8 above its word. +const UNDER: u32 = 8; + +pub fn round_height() -> u32 { + ROUND + UNDER + line(Role::ActionLabel) as u32 +} + +/// Draw centred in `at`, which the caller keeps for the click. +pub fn round_action(fb: &mut PaintBuffer, at: Rect, title: &str, sign: Symbol, enabled: bool) { + let cx = at.x + at.w / 2; + let cy = at.y + ROUND / 2; + if enabled { + fb.circle(cx, cy, ROUND / 2, CYAN); + } else { + fb.ring(cx, cy, ROUND / 2, 1, LINE_2); + } + symbol(fb, cx as i32, cy as i32, sign, if enabled { INK } else { TEXT_3 }); + let tx = cx as i32 - width(Role::ActionLabel, title) / 2; + let ink = if enabled { TEXT } else { TEXT_3 }; + draw_in(fb, tx, (at.y + ROUND + UNDER) as i32, Role::ActionLabel, title, ink); +} diff --git a/userland/capsule_wallet_nonos/src/wallet/etna/parts/rule.rs b/userland/capsule_wallet_nonos/src/wallet/etna/parts/rule.rs new file mode 100644 index 0000000000..6c8c23dce1 --- /dev/null +++ b/userland/capsule_wallet_nonos/src/wallet/etna/parts/rule.rs @@ -0,0 +1,26 @@ +// 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 one-pixel rule that divides without shouting, optionally inset so it +//! starts under the text of a row. + +use nonos_app_skeleton::PaintBuffer; + +use super::super::tokens::LINE; + +pub fn rule(fb: &mut PaintBuffer, x: u32, y: u32, w: u32, inset: u32) { + fb.fill_rect(x.saturating_add(inset), y, w.saturating_sub(inset), 1, LINE); +} diff --git a/userland/capsule_wallet_nonos/src/wallet/etna/parts/status.rs b/userland/capsule_wallet_nonos/src/wallet/etna/parts/status.rs new file mode 100644 index 0000000000..d39e4dd625 --- /dev/null +++ b/userland/capsule_wallet_nonos/src/wallet/etna/parts/status.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 . + +//! The machine's own line at the foot of every screen, for example +//! "Sepolia · synced · Tor", in mono 10. + +use alloc::string::String; + +use nonos_app_skeleton::PaintBuffer; + +use super::super::roles::Role; +use super::super::text::{draw, line}; +use super::super::tokens::{SIDE, STATUS_H}; + +pub fn status_line(fb: &mut PaintBuffer, x: u32, y: u32, parts: &[&str]) { + let joined: String = parts.join(" \u{b7} "); + let top = y + (STATUS_H - line(Role::Status) as u32) / 2; + draw(fb, (x + SIDE) as i32, top as i32, Role::Status, &joined); +} diff --git a/userland/capsule_wallet_nonos/src/wallet/etna/parts/tile.rs b/userland/capsule_wallet_nonos/src/wallet/etna/parts/tile.rs new file mode 100644 index 0000000000..5cc5a013f5 --- /dev/null +++ b/userland/capsule_wallet_nonos/src/wallet/etna/parts/tile.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 . + +//! A quiet surface for a group of rows: surface fill, the tile corner and a +//! hairline. Each row is a name on the left in text-3 and a mono value on +//! the right. + +use nonos_app_skeleton::PaintBuffer; + +use super::super::rect::Rect; +use super::super::roles::Role; +use super::super::text::{draw, line, width}; +use super::super::tokens::{LINE, RADIUS, SURFACE, TIGHT}; +use super::rule::rule; + +/// Rows sit 16 in from the tile's edge, 12 above and below their text. +pub const ROW_PAD_X: u32 = 16; + +pub fn tile(fb: &mut PaintBuffer, at: Rect) { + fb.fill_round(at.x, at.y, at.w, at.h, RADIUS, SURFACE); + fb.stroke_round(at.x, at.y, at.w, at.h, RADIUS, 1, LINE); +} + +pub fn row_height() -> u32 { + (line(Role::RowName).max(line(Role::RowValue)) as u32) + 2 * TIGHT +} + +/// One row at `y` inside `at`; a rule under it unless it is the last. +pub fn tile_row(fb: &mut PaintBuffer, at: Rect, y: u32, name: &str, value: &str, last: bool) { + let top = (y + TIGHT) as i32; + draw(fb, (at.x + ROW_PAD_X) as i32, top, Role::RowName, name); + let vx = (at.x + at.w - ROW_PAD_X) as i32 - width(Role::RowValue, value); + draw(fb, vx, top, Role::RowValue, value); + if !last { + rule(fb, at.x, y + row_height() - 1, at.w, ROW_PAD_X); + } +} diff --git a/userland/capsule_wallet_nonos/src/wallet/etna/parts/value.rs b/userland/capsule_wallet_nonos/src/wallet/etna/parts/value.rs new file mode 100644 index 0000000000..8161649981 --- /dev/null +++ b/userland/capsule_wallet_nonos/src/wallet/etna/parts/value.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 value shown to be read off the screen, an address or a hash: grouped in +//! runs of eight inside a hairline box, with its length under it so a reader +//! knows they have all of it. + +use nonos_app_skeleton::PaintBuffer; + +use super::super::groups::grouped; +use super::super::rect::Rect; +use super::super::roles::Role; +use super::super::text::{draw_in, line}; +use super::super::tokens::{CORNER, OUTLINE, TEXT, TEXT_3}; +use super::super::wrap::wrapped; + +const PAD: u32 = 16; +/// Lines of a value sit 6 apart, then 10 before the count. +const LEADING: i32 = 6; +const BEFORE_COUNT: i32 = 10; + +/// Draw the box at `x, y` across `w`; returns its height. +pub fn value_block(fb: &mut PaintBuffer, x: u32, y: u32, w: u32, value: &str) -> u32 { + let inner = (w - 2 * PAD) as i32; + let top = (y + PAD) as i32; + let body = wrapped(fb, (x + PAD) as i32, top, inner, Role::Fact, &grouped(value), TEXT); + let lines = (body / line(Role::Fact).max(1)).max(1); + let body = body + LEADING * (lines - 1); + let count = alloc::format!("{} characters", value.chars().count()); + draw_in(fb, (x + PAD) as i32, top + body + BEFORE_COUNT, Role::Fact, &count, TEXT_3); + let h = PAD * 2 + (body + BEFORE_COUNT + line(Role::Fact)) as u32; + let at = Rect::new(x, y, w, h); + fb.stroke_round(at.x, at.y, at.w, at.h, CORNER, 1, OUTLINE); + h +} diff --git a/userland/capsule_wallet_nonos/src/wallet/etna/rect.rs b/userland/capsule_wallet_nonos/src/wallet/etna/rect.rs new file mode 100644 index 0000000000..376909adc7 --- /dev/null +++ b/userland/capsule_wallet_nonos/src/wallet/etna/rect.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 . + +//! One rectangle type for painting and hit testing, so what is pressed is +//! always what was drawn. + +#[derive(Clone, Copy, Default, PartialEq, Eq)] +pub struct Rect { + pub x: u32, + pub y: u32, + pub w: u32, + pub h: u32, +} + +impl Rect { + pub const fn new(x: u32, y: u32, w: u32, h: u32) -> Rect { + Rect { x, y, w, h } + } + + pub fn contains(&self, px: u32, py: u32) -> bool { + px >= self.x + && py >= self.y + && px < self.x.saturating_add(self.w) + && py < self.y.saturating_add(self.h) + } + + pub fn bottom(&self) -> u32 { + self.y.saturating_add(self.h) + } + + /// The same box with `by` taken off every side. + pub fn inset(&self, by: u32) -> Rect { + let twice = by.saturating_mul(2); + Rect::new( + self.x + by, + self.y + by, + self.w.saturating_sub(twice), + self.h.saturating_sub(twice), + ) + } +} diff --git a/userland/capsule_wallet_nonos/src/wallet/etna/roles.rs b/userland/capsule_wallet_nonos/src/wallet/etna/roles.rs new file mode 100644 index 0000000000..4cb42c603c --- /dev/null +++ b/userland/capsule_wallet_nonos/src/wallet/etna/roles.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 . + +//! Which face, size, tracking, case and colour each type role takes. + +use super::face::Face; +use super::tokens::{TEXT, TEXT_3}; + +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum Role { + ScreenLabel, + Lead, + RowName, + RowValue, + Fact, + Button, + ActionLabel, + Status, + Statement, +} + +/// Face, size, tracking, capitals, colour. +pub(super) struct Spec(pub Face, pub f32, pub f32, pub bool, pub u32); + +pub(super) fn spec(role: Role) -> Spec { + match role { + Role::ScreenLabel => Spec(Face::Mono, 12.0, 2.4, true, TEXT_3), + Role::Lead | Role::RowName => Spec(Face::Sans, 14.0, 0.0, false, TEXT_3), + Role::RowValue => Spec(Face::Mono, 13.0, 0.0, false, TEXT), + Role::Fact => Spec(Face::Mono, 12.0, 0.0, false, TEXT), + Role::Button => Spec(Face::Mono, 12.0, 1.6, true, TEXT), + Role::ActionLabel => Spec(Face::SansMedium, 13.0, 0.0, false, TEXT), + Role::Status => Spec(Face::Mono, 10.0, 0.6, false, TEXT_3), + Role::Statement => Spec(Face::Mono, 21.0, 0.0, false, TEXT), + } +} diff --git a/userland/capsule_wallet_nonos/src/wallet/etna/symbol.rs b/userland/capsule_wallet_nonos/src/wallet/etna/symbol.rs new file mode 100644 index 0000000000..9813335bf9 --- /dev/null +++ b/userland/capsule_wallet_nonos/src/wallet/etna/symbol.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 few signs the wallet draws, as two-pixel strokes about a centre, in +//! place of the phones' SF Symbols of the same names. + +use nonos_app_skeleton::PaintBuffer; + +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum Symbol { + ArrowUp, + ArrowDown, + ArrowLeftRight, + LockShield, + ArrowDownToLine, + ChevronLeft, +} + +fn stroke(fb: &mut PaintBuffer, cx: i32, cy: i32, segs: &[(i32, i32, i32, i32)], argb: u32) { + for &(x0, y0, x1, y1) in segs { + for d in 0..2 { + fb.line(cx + x0 + d, cy + y0, cx + x1 + d, cy + y1, argb); + fb.line(cx + x0, cy + y0 + d, cx + x1, cy + y1 + d, argb); + } + } +} + +pub fn symbol(fb: &mut PaintBuffer, cx: i32, cy: i32, which: Symbol, argb: u32) { + let segs: &[(i32, i32, i32, i32)] = match which { + Symbol::ArrowUp => &[(0, -8, 0, 8), (-6, -2, 0, -8), (6, -2, 0, -8)], + Symbol::ArrowDown => &[(0, -8, 0, 8), (-6, 2, 0, 8), (6, 2, 0, 8)], + Symbol::ArrowDownToLine => &[(0, -8, 0, 4), (-5, -1, 0, 4), (5, -1, 0, 4), (-7, 8, 7, 8)], + Symbol::ArrowLeftRight => &[(-8, -3, 8, -3), (4, -7, 8, -3), (8, 3, -8, 3), (-4, 7, -8, 3)], + Symbol::LockShield => &[ + (-7, -7, 0, -9), + (0, -9, 7, -7), + (-7, -7, -6, 2), + (7, -7, 6, 2), + (-6, 2, 0, 8), + (6, 2, 0, 8), + (-3, -1, 3, -1), + (-3, -1, -3, 4), + (3, -1, 3, 4), + (-3, 4, 3, 4), + ], + Symbol::ChevronLeft => &[(3, -7, -4, 0), (-4, 0, 3, 7)], + }; + stroke(fb, cx, cy, segs, argb); +} + +/// The way to settings: a toothed ring, drawn centred in `at`. +pub fn gear(fb: &mut PaintBuffer, at: super::rect::Rect) { + let (cx, cy) = (at.x + at.w / 2, at.y + at.h / 2); + fb.ring(cx, cy, 7, 2, super::tokens::TEXT); + for (dx, dy) in [(0, -1), (1, 0), (0, 1), (-1, 0), (1, 1), (-1, -1), (1, -1), (-1, 1)] { + let (x0, y0) = (cx as i32 + dx * 7, cy as i32 + dy * 7); + fb.line(x0, y0, x0 + dx * 3, y0 + dy * 3, super::tokens::TEXT); + } +} diff --git a/userland/capsule_wallet_nonos/src/wallet/etna/text.rs b/userland/capsule_wallet_nonos/src/wallet/etna/text.rs new file mode 100644 index 0000000000..db91824a85 --- /dev/null +++ b/userland/capsule_wallet_nonos/src/wallet/etna/text.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 . + +//! The type roles, each one face, one size, one tracking and one colour, as +//! the phones' Typography and the parts that use it set them. Desktop draws +//! the base sizes; the phones draw the same roles a quarter larger. + +use alloc::string::String; + +use nonos_app_skeleton::PaintBuffer; +use nonos_toolkit::ttf::{draw_text_tracked, line_height_with, measure_tracked}; + +use super::face::font; +use super::roles::{spec, Role, Spec}; + +fn shaped(caps: bool, text: &str) -> String { + if caps { + text.to_uppercase() + } else { + String::from(text) + } +} + +/// Draw in the role's own colour; returns the pen x after the run. +pub fn draw(fb: &mut PaintBuffer, x: i32, top: i32, role: Role, text: &str) -> i32 { + let colour = spec(role).4; + draw_in(fb, x, top, role, text, colour) +} + +/// Draw in a given colour, for the few places the design recolours a role: +/// ink on cyan, text-3 on a disabled control. +pub fn draw_in(fb: &mut PaintBuffer, x: i32, top: i32, role: Role, text: &str, argb: u32) -> i32 { + let Spec(face, px, track, caps, _) = spec(role); + let Some(f) = font(face) else { return x }; + let (w, h, stride) = (fb.width, fb.height, fb.stride_words as usize); + draw_text_tracked(f, fb.pixels, stride, w, h, x, top, &shaped(caps, text), argb, px, track) +} + +pub fn width(role: Role, text: &str) -> i32 { + let Spec(face, px, track, caps, _) = spec(role); + font(face).map_or(0, |f| measure_tracked(f, &shaped(caps, text), px, track)) +} + +pub fn line(role: Role) -> i32 { + let Spec(face, px, ..) = spec(role); + font(face).map_or(0, |f| line_height_with(f, px)) +} diff --git a/userland/capsule_wallet_nonos/src/wallet/etna/tokens.rs b/userland/capsule_wallet_nonos/src/wallet/etna/tokens.rs new file mode 100644 index 0000000000..be689feab6 --- /dev/null +++ b/userland/capsule_wallet_nonos/src/wallet/etna/tokens.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 . + +//! The Etna design tokens: the phones' design-tokens.json, as the framebuffer +//! takes them. Nothing on a wallet screen is drawn in any other colour or at +//! any other spacing, so a value missing here is a value the design lacks. + +pub const INK: u32 = 0xFF0A_0B0D; +pub const SURFACE: u32 = 0xFF10_1215; +pub const TEXT: u32 = 0xFFF2_F3F7; +pub const TEXT_2: u32 = 0xFFA3_A4A7; +pub const TEXT_3: u32 = 0xFF87_888B; +pub const LINE: u32 = 0xFF1F_2022; +pub const LINE_2: u32 = 0xFF2F_3032; +pub const OUTLINE: u32 = 0xFF27_282A; +pub const FIELD: u32 = 0xFF16_1719; +pub const BANNER: u32 = 0xFF1E_1F21; +pub const CYAN: u32 = 0xFF66_FFFF; +pub const DEEP_TEAL: u32 = 0xFF2E_5C5C; +pub const BAD: u32 = 0xFFFF_8A8A; + +// Room: the only spacing numbers there are. +pub const SIDE: u32 = 24; +pub const HALF: u32 = 12; +pub const ROOM: u32 = 28; +pub const GAP: u32 = 26; +pub const TIGHT: u32 = 12; + +// Edge: nearly square controls, rounder tiles, one-pixel lines. +pub const CORNER: u32 = 2; +pub const RADIUS: u32 = 14; +pub const HAIR: u32 = 1; +pub const TALL: u32 = 58; +pub const ROUND: u32 = 56; + +/// The column the phone's frame is laid in on a desktop, and so the width the +/// section photographs were sized to by tools/nonos-etna-banners. +pub const COLUMN: u32 = 560; +/// The photographs keep the phones' 1290 by 860. +pub const BANNER_H: u32 = COLUMN * 860 / 1290; +/// The back control is a 44 point square; the bar holds it with room above +/// and below. +pub const BACK: u32 = 44; +pub const BAR_H: u32 = BACK + TIGHT; +/// Mono 10 with 10 above and below. +pub const STATUS_H: u32 = 32; diff --git a/userland/capsule_wallet_nonos/src/wallet/etna/wrap.rs b/userland/capsule_wallet_nonos/src/wallet/etna/wrap.rs new file mode 100644 index 0000000000..4cefdb23b9 --- /dev/null +++ b/userland/capsule_wallet_nonos/src/wallet/etna/wrap.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 . + +//! Text set in a column: words broken onto lines no wider than the column, +//! for the lead sentence and every other run of prose. + +use alloc::string::String; + +use nonos_app_skeleton::PaintBuffer; + +use super::roles::Role; +use super::text::{draw_in, line, width}; + +/// Draw `text` wrapped to `w` and return the height it took. +pub fn wrapped( + fb: &mut PaintBuffer, + x: i32, + top: i32, + w: i32, + role: Role, + text: &str, + argb: u32, +) -> i32 { + let step = line(role); + let mut y = top; + let mut row = String::new(); + for word in text.split(' ') { + let trial = + if row.is_empty() { String::from(word) } else { alloc::format!("{row} {word}") }; + if !row.is_empty() && width(role, &trial) > w { + draw_in(fb, x, y, role, &row, argb); + y += step; + row = String::from(word); + } else { + row = trial; + } + } + if !row.is_empty() { + draw_in(fb, x, y, role, &row, argb); + y += step; + } + y - top +} diff --git a/userland/capsule_wallet_nonos/src/wallet/event/etna_click.rs b/userland/capsule_wallet_nonos/src/wallet/event/etna_click.rs new file mode 100644 index 0000000000..80608f4e08 --- /dev/null +++ b/userland/capsule_wallet_nonos/src/wallet/event/etna_click.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 . + +//! A click or a scroll on a screen drawn on the Etna frame. The old chrome's +//! header icons and side rail are not on these screens, so their hit zones +//! must not answer here: this handler runs instead of them, not before them. + +use nonos_app_skeleton::clients::clipboard::clipboard_copy; +use nonos_app_skeleton::EventOutcome; + +use crate::wallet::screen::hits::{at, Press}; +use crate::wallet::state::{State, VIEW_HOME, VIEW_RECEIVE, VIEW_SEND, VIEW_SHIELD, VIEW_SWAP}; + +/// Whether the screen on show is drawn on the Etna frame. +pub fn on_etna(state: &State) -> bool { + state.panel == 0 + && (state.view == VIEW_HOME || (state.view == VIEW_RECEIVE && !state.import_active)) +} + +fn go(state: &mut State, view: u8) -> EventOutcome { + state.view = view; + state.scroll = 0; + EventOutcome::Repaint +} + +pub fn etna_click(state: &mut State, x: u32, y: u32) -> EventOutcome { + let Some(press) = at(x, y) else { + return EventOutcome::Idle; + }; + match (press, state.view) { + (Press::Footer(0), VIEW_HOME) if !state.address_ready => super::generate::generate(state), + (Press::Footer(1), VIEW_HOME) if !state.address_ready => { + super::import::toggle_import(state) + } + (Press::Footer(0), VIEW_RECEIVE) => { + let hex = crate::wallet::screen::receive_address::address_hex(state); + let _ = clipboard_copy(hex.as_bytes()); + state.status = b"address copied"; + EventOutcome::Repaint + } + (Press::Back, _) => go(state, VIEW_HOME), + (Press::Send, _) => go(state, VIEW_SEND), + (Press::Receive, _) => go(state, VIEW_RECEIVE), + (Press::Swap, _) => go(state, VIEW_SWAP), + (Press::Shield, _) => go(state, VIEW_SHIELD), + (Press::Settings | Press::Accounts, _) => { + state.panel = 3; + EventOutcome::Repaint + } + _ => EventOutcome::Idle, + } +} diff --git a/userland/capsule_wallet_nonos/src/wallet/event/etna_scroll.rs b/userland/capsule_wallet_nonos/src/wallet/event/etna_scroll.rs new file mode 100644 index 0000000000..7d00ef65b2 --- /dev/null +++ b/userland/capsule_wallet_nonos/src/wallet/event/etna_scroll.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 . + +//! Scrolling an Etna screen. + +use nonos_app_skeleton::EventOutcome; + +use super::etna_click::on_etna; +use crate::wallet::screen::hits::limit; +use crate::wallet::state::State; + +/// The wheel moves the photograph and content under the fixed bar and foot, +/// no further than the screen's content reaches. +pub fn scroll(state: &mut State, delta_y: i32) -> EventOutcome { + if !on_etna(state) { + return EventOutcome::Idle; + } + let next = (i64::from(state.scroll) - i64::from(delta_y) * 60).clamp(0, i64::from(limit())); + state.scroll = next as u32; + EventOutcome::Repaint +} + +/// The arrow and page keys scroll an Etna screen as the wheel does: one +/// wheel step for an arrow, five for a page. +pub fn scroll_key(state: &mut State, code: u32) -> Option { + use nonos_app_skeleton::{KEY_DOWN, KEY_PAGE_DOWN, KEY_PAGE_UP, KEY_UP}; + let steps = match code { + KEY_UP => 1, + KEY_DOWN => -1, + KEY_PAGE_UP => 5, + KEY_PAGE_DOWN => -5, + _ => return None, + }; + on_etna(state).then(|| scroll(state, steps)) +} diff --git a/userland/capsule_wallet_nonos/src/wallet/event/mod.rs b/userland/capsule_wallet_nonos/src/wallet/event/mod.rs index 1b7b90822b..db8851aeaa 100644 --- a/userland/capsule_wallet_nonos/src/wallet/event/mod.rs +++ b/userland/capsule_wallet_nonos/src/wallet/event/mod.rs @@ -17,6 +17,8 @@ mod backup; mod broadcast; mod broadcast_arm; +mod etna_click; +mod etna_scroll; mod edit_amount; mod edit_nonce; mod export_key; @@ -54,5 +56,6 @@ mod tx_freshen; mod unstake_flow; mod keep; +pub use etna_click::on_etna; pub use on_event::on_event; pub use probe_tick::probe_tick; diff --git a/userland/capsule_wallet_nonos/src/wallet/event/on_event.rs b/userland/capsule_wallet_nonos/src/wallet/event/on_event.rs index 4d3bc6e561..a4aa0f26ba 100644 --- a/userland/capsule_wallet_nonos/src/wallet/event/on_event.rs +++ b/userland/capsule_wallet_nonos/src/wallet/event/on_event.rs @@ -26,6 +26,7 @@ pub fn on_event(state: &mut State, event: InputEvent) -> EventOutcome { InputKind::KeyDown if event.code == KEY_ESC => EventOutcome::Close, InputKind::KeyDown => super::on_key::on_key(state, event.code), InputKind::ButtonDown => super::on_pointer::on_pointer(state, event.x, event.y), + InputKind::Wheel => super::etna_scroll::scroll(state, event.delta_y), _ => EventOutcome::Idle, } } diff --git a/userland/capsule_wallet_nonos/src/wallet/event/on_key.rs b/userland/capsule_wallet_nonos/src/wallet/event/on_key.rs index 12fc6faa7d..efc37aa77a 100644 --- a/userland/capsule_wallet_nonos/src/wallet/event/on_key.rs +++ b/userland/capsule_wallet_nonos/src/wallet/event/on_key.rs @@ -36,6 +36,9 @@ pub fn on_key(state: &mut State, code: u32) -> EventOutcome { if state.recover_active { return super::recover::recover_input(state, code); } + if let Some(done) = super::etna_scroll::scroll_key(state, code) { + return done; + } // While the import field is open it owns every key, so a typed hex digit is // never mistaken for a view shortcut. if state.import_active { diff --git a/userland/capsule_wallet_nonos/src/wallet/event/on_pointer.rs b/userland/capsule_wallet_nonos/src/wallet/event/on_pointer.rs index b90b1aad89..3b102440fb 100644 --- a/userland/capsule_wallet_nonos/src/wallet/event/on_pointer.rs +++ b/userland/capsule_wallet_nonos/src/wallet/event/on_pointer.rs @@ -30,6 +30,9 @@ pub fn on_pointer(state: &mut State, x: i32, y: i32) -> EventOutcome { state.locked = false; return EventOutcome::Repaint; } + if super::etna_click::on_etna(state) { + return super::etna_click::etna_click(state, x, y); + } if header_icon(state, x, y) { return EventOutcome::Repaint; } diff --git a/userland/capsule_wallet_nonos/src/wallet/manifest.rs b/userland/capsule_wallet_nonos/src/wallet/manifest.rs index f902128093..7c71b075be 100644 --- a/userland/capsule_wallet_nonos/src/wallet/manifest.rs +++ b/userland/capsule_wallet_nonos/src/wallet/manifest.rs @@ -20,8 +20,10 @@ use super::theme::{HEIGHT, WIDTH}; const INPUT_KEY_DOWN_BIT: u32 = 1 << 0; const INPUT_POINTER_ABS_BIT: u32 = 1 << 3; +const INPUT_WHEEL_BIT: u32 = 1 << 4; const INPUT_BUTTON_DOWN_BIT: u32 = 1 << 5; -const INPUT_MASK: u32 = INPUT_KEY_DOWN_BIT | INPUT_POINTER_ABS_BIT | INPUT_BUTTON_DOWN_BIT; +const INPUT_MASK: u32 = + INPUT_KEY_DOWN_BIT | INPUT_POINTER_ABS_BIT | INPUT_WHEEL_BIT | INPUT_BUTTON_DOWN_BIT; pub fn manifest() -> AppManifest { AppManifest { diff --git a/userland/capsule_wallet_nonos/src/wallet/mod.rs b/userland/capsule_wallet_nonos/src/wallet/mod.rs index c18937a526..a47aeb197f 100644 --- a/userland/capsule_wallet_nonos/src/wallet/mod.rs +++ b/userland/capsule_wallet_nonos/src/wallet/mod.rs @@ -15,6 +15,7 @@ // along with this program. If not, see . mod app; +pub mod etna; mod event; mod hex; mod ipc; @@ -26,6 +27,7 @@ pub mod paint; pub mod vault; mod pool; mod rpc; +pub mod screen; mod shield; mod state; mod swap; diff --git a/userland/capsule_wallet_nonos/src/wallet/paint/mod.rs b/userland/capsule_wallet_nonos/src/wallet/paint/mod.rs index 4d9bcdf364..9b8deae26b 100644 --- a/userland/capsule_wallet_nonos/src/wallet/paint/mod.rs +++ b/userland/capsule_wallet_nonos/src/wallet/paint/mod.rs @@ -23,7 +23,7 @@ mod format_u64; mod hex_hash; mod home_activity_row; pub mod home_geom; -mod logo; +pub mod logo; mod logo_bits; mod nav_glyph; mod nav_icon; diff --git a/userland/capsule_wallet_nonos/src/wallet/paint/paint.rs b/userland/capsule_wallet_nonos/src/wallet/paint/paint.rs index f40bb33eff..84469460de 100644 --- a/userland/capsule_wallet_nonos/src/wallet/paint/paint.rs +++ b/userland/capsule_wallet_nonos/src/wallet/paint/paint.rs @@ -21,6 +21,15 @@ use crate::wallet::state::{ }; pub fn paint(state: &State, fb: &mut PaintBuffer) { + // Home and the first screen are drawn on the Etna frame, alone. + if crate::wallet::event::on_etna(state) { + match (state.view, state.address_ready) { + (VIEW_RECEIVE, _) => crate::wallet::screen::receive::receive(state, fb), + (_, true) => crate::wallet::screen::home::home(state, fb), + (_, false) => crate::wallet::screen::welcome::welcome(state, fb), + } + return; + } crate::wallet::theme::set_light(state.light_mode); super::paint_background::paint_background(fb); super::paint_sysbar::paint_sysbar(fb); diff --git a/userland/capsule_wallet_nonos/src/wallet/screen/amounts.rs b/userland/capsule_wallet_nonos/src/wallet/screen/amounts.rs new file mode 100644 index 0000000000..df62ed7227 --- /dev/null +++ b/userland/capsule_wallet_nonos/src/wallet/screen/amounts.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 . + +//! Balances as a person reads them, always from the wallet's own figures: +//! four places for ETH, two for NOX, and a dash until the figure is read, +//! as the phones show a coin not yet heard from. + +use alloc::format; +use alloc::string::String; + +use crate::wallet::nox::{format_nox, held_wei}; +use crate::wallet::state::State; + +const WEI: u128 = 1_000_000_000_000_000_000; +pub const UNREAD: &str = "\u{2013}"; + +/// The low 128 bits of a 256-bit word: every real balance, where 64 bits +/// stopped at 18.4 ETH. +fn low_u128(v: &[u8; 32]) -> u128 { + let mut b = [0u8; 16]; + b.copy_from_slice(&v[16..]); + u128::from_be_bytes(b) +} + +pub fn eth(state: &State) -> String { + if !state.balance_ready { + return String::from(UNREAD); + } + let wei = low_u128(&state.balance_wei); + format!("{}.{:04}", wei / WEI, (wei % WEI) / (WEI / 10_000)) +} + +pub fn nox(state: &State) -> String { + match held_wei(state.nox.balance_ready, &state.nox.balance_wei) { + Some(wei) => { + let mut out = [0u8; 64]; + let n = format_nox(wei, &mut out); + String::from(core::str::from_utf8(&out[..n]).unwrap_or(UNREAD)) + } + None => String::from(UNREAD), + } +} diff --git a/userland/capsule_wallet_nonos/src/wallet/screen/hits.rs b/userland/capsule_wallet_nonos/src/wallet/screen/hits.rs new file mode 100644 index 0000000000..1221b57d4e --- /dev/null +++ b/userland/capsule_wallet_nonos/src/wallet/screen/hits.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 . + +//! Where the last painted screen put the things that can be pressed. The +//! same pass that draws them writes them here, so a click is tested against +//! exactly what the person saw. + +use spin::Mutex; + +use crate::wallet::etna::rect::Rect; + +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum Press { + Send, + Receive, + Swap, + Shield, + Settings, + Accounts, + Back, + Dismiss, + Footer(u8), +} + +const MAX: usize = 16; +static HITS: Mutex<([(Press, Rect); MAX], usize)> = + Mutex::new(([(Press::Back, Rect::new(0, 0, 0, 0)); MAX], 0)); + +pub fn clear() { + HITS.lock().1 = 0; +} + +pub fn put(what: Press, at: Rect) { + let mut h = HITS.lock(); + let n = h.1; + if n < MAX { + h.0[n] = (what, at); + h.1 = n + 1; + } +} + +pub fn at(x: u32, y: u32) -> Option { + let h = HITS.lock(); + h.0[..h.1].iter().find(|(_, r)| r.contains(x, y)).map(|(p, _)| *p) +} + +static LIMIT: Mutex = Mutex::new(0); + +/// How far the screen on show may scroll: where its content ended, less the +/// room above the fixed foot. Written by the screen as it paints. +pub fn reach(content_end: u32, scroll: u32, content_bottom: u32) { + *LIMIT.lock() = (content_end + scroll).saturating_sub(content_bottom); +} + +pub fn limit() -> u32 { + *LIMIT.lock() +} diff --git a/userland/capsule_wallet_nonos/src/wallet/screen/home.rs b/userland/capsule_wallet_nonos/src/wallet/screen/home.rs new file mode 100644 index 0000000000..08ef3b7f51 --- /dev/null +++ b/userland/capsule_wallet_nonos/src/wallet/screen/home.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 . + +//! 03 WALLET, the home screen, as Overview.swift lays it out: the mark and +//! the way to settings, the account and network, the coins the account +//! holds, and the four things a holder does. + +use nonos_app_skeleton::PaintBuffer; + +use super::amounts::{eth, nox, UNREAD}; +use super::hits::{self, Press}; +use super::home_pills::pills; +use crate::wallet::etna::backdrop::Backdrop; +use crate::wallet::etna::frame::{begin, end}; +use crate::wallet::etna::frame_spec::FrameSpec; +use crate::wallet::etna::parts::tile::{row_height, tile, tile_row}; +use crate::wallet::etna::rect::Rect; +use crate::wallet::etna::tokens::{BACK, GAP}; +use crate::wallet::state::State; + +pub fn home(state: &State, fb: &mut PaintBuffer) { + hits::clear(); + let status = super::status::parts(state); + let spec = FrameSpec { + number: "03", + title: "Wallet", + back: false, + backdrop: Some(Backdrop::Home), + failure: None, + footer: &[], + status: &status, + scroll: state.scroll, + }; + let mut l = begin(fb, &spec); + let c = l.content; + crate::wallet::paint::logo::logo(fb, c.x, c.y + (BACK - 18) / 2, 18); + let gear = Rect::new(c.x + c.w - BACK, c.y, BACK, BACK); + crate::wallet::etna::symbol::gear(fb, gear); + hits::put(Press::Settings, gear); + let mut y = c.y + BACK + GAP; + y += pills(state, fb, c, y) + GAP; + let rows = + [("ETH", eth(state)), ("NOX", nox(state)), ("USDC", alloc::string::String::from(UNREAD))]; + let th = row_height() * rows.len() as u32; + let at = Rect::new(c.x, y, c.w, th); + tile(fb, at); + for (i, (name, value)) in rows.iter().enumerate() { + tile_row(fb, at, y + row_height() * i as u32, name, value, i + 1 == rows.len()); + } + y += th + GAP; + super::home_actions::actions(state, fb, c, y); + hits::reach( + y + crate::wallet::etna::parts::round::round_height(), + state.scroll, + l.content_bottom, + ); + end(fb, &spec, &mut l); +} diff --git a/userland/capsule_wallet_nonos/src/wallet/screen/home_actions.rs b/userland/capsule_wallet_nonos/src/wallet/screen/home_actions.rs new file mode 100644 index 0000000000..f34d69d9c0 --- /dev/null +++ b/userland/capsule_wallet_nonos/src/wallet/screen/home_actions.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 four things a holder does from home, a row of round actions. Send +//! waits for the balance, since there is nothing to send until it is read. + +use nonos_app_skeleton::PaintBuffer; + +use super::hits::{self, Press}; +use crate::wallet::etna::parts::round::{round_action, round_height}; +use crate::wallet::etna::rect::Rect; +use crate::wallet::etna::symbol::Symbol; +use crate::wallet::state::State; + +const ACTIONS: [(&str, Symbol, Press); 4] = [ + ("Send", Symbol::ArrowUp, Press::Send), + ("Receive", Symbol::ArrowDown, Press::Receive), + ("Swap", Symbol::ArrowLeftRight, Press::Swap), + ("Shield", Symbol::LockShield, Press::Shield), +]; + +pub fn actions(state: &State, fb: &mut PaintBuffer, c: Rect, y: u32) { + let cell = c.w / ACTIONS.len() as u32; + for (i, (title, sign, press)) in ACTIONS.iter().enumerate() { + let enabled = *press != Press::Send || state.balance_ready; + let at = Rect::new(c.x + cell * i as u32, y, cell, round_height()); + round_action(fb, at, title, *sign, enabled); + if enabled { + hits::put(*press, at); + } + } +} diff --git a/userland/capsule_wallet_nonos/src/wallet/screen/home_pills.rs b/userland/capsule_wallet_nonos/src/wallet/screen/home_pills.rs new file mode 100644 index 0000000000..e9e59c7b67 --- /dev/null +++ b/userland/capsule_wallet_nonos/src/wallet/screen/home_pills.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 . + +//! The account the home screen is about, cut to its ends so it can be +//! checked, and the network it is on: two outlined pills on one line. + +use alloc::string::String; + +use nonos_app_skeleton::PaintBuffer; + +use super::hits::{self, Press}; +use crate::wallet::etna::groups::shortened; +use crate::wallet::etna::rect::Rect; +use crate::wallet::etna::text::{draw, line, width}; +use crate::wallet::etna::tokens::{CORNER, HALF, OUTLINE}; +use crate::wallet::etna::Role; +use crate::wallet::state::State; + +const PILL_H: u32 = 36; + +fn address(state: &State) -> String { + if !state.address_ready { + return String::from("No account yet"); + } + let mut hex = String::from("0x"); + for b in state.address { + hex.push_str(&alloc::format!("{b:02x}")); + } + shortened(&hex) +} + +fn pill(fb: &mut PaintBuffer, x: u32, y: u32, text: &str) -> Rect { + let w = width(Role::RowValue, text) as u32 + 2 * HALF; + fb.stroke_round(x, y, w, PILL_H, CORNER, 1, OUTLINE); + draw( + fb, + (x + HALF) as i32, + (y + (PILL_H - line(Role::RowValue) as u32) / 2) as i32, + Role::RowValue, + text, + ); + Rect::new(x, y, w, PILL_H) +} + +/// Draw both pills on the line at `y`; returns their height. +pub fn pills(state: &State, fb: &mut PaintBuffer, c: Rect, y: u32) -> u32 { + let account = pill(fb, c.x, y, &address(state)); + hits::put(Press::Accounts, account); + let net = "Ethereum"; + let nw = width(Role::RowValue, net) as u32 + 2 * HALF; + pill(fb, c.x + c.w - nw, y, net); + PILL_H +} diff --git a/userland/capsule_wallet_nonos/src/wallet/screen/mod.rs b/userland/capsule_wallet_nonos/src/wallet/screen/mod.rs new file mode 100644 index 0000000000..4f5ecc2051 --- /dev/null +++ b/userland/capsule_wallet_nonos/src/wallet/screen/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 . + +//! The wallet's screens on the Etna frame, one file each, with the shared +//! status line, balances and the table of what can be pressed. + +pub mod amounts; +pub mod hits; +pub mod home; +mod home_actions; +mod home_pills; +pub mod receive; +pub mod receive_address; +pub mod status; +pub mod welcome; diff --git a/userland/capsule_wallet_nonos/src/wallet/screen/receive.rs b/userland/capsule_wallet_nonos/src/wallet/screen/receive.rs new file mode 100644 index 0000000000..7875fb972c --- /dev/null +++ b/userland/capsule_wallet_nonos/src/wallet/screen/receive.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 . + +//! 03 RECEIVE, the public 0x address, as AccountReceiveView.swift shows it: +//! one sentence saying anyone can see it, the address as a QR code and as a +//! grouped value, what is known about it, and a way to copy it. + +use nonos_app_skeleton::PaintBuffer; + +use super::hits::{self, Press}; +use crate::wallet::etna::backdrop::Backdrop; +use crate::wallet::etna::frame::{begin, end}; +use crate::wallet::etna::frame_spec::FrameSpec; +use crate::wallet::etna::parts::action::Weight; +use crate::wallet::etna::parts::qr::qr; +use crate::wallet::etna::parts::value::value_block; +use crate::wallet::etna::tokens::{GAP, TEXT_3}; +use crate::wallet::etna::wrap::wrapped; +use crate::wallet::etna::Role; +use crate::wallet::state::State; + +const LEAD: &str = "Public: anyone can see what this address holds and sends."; +const QR_SIDE: u32 = 200; + +pub fn receive(state: &State, fb: &mut PaintBuffer) { + hits::clear(); + let status = super::status::parts(state); + let ready = state.address_ready; + let footer = [("Copy address", Weight::Primary, ready)]; + let spec = FrameSpec { + number: "03", + title: "Receive", + back: true, + backdrop: Some(Backdrop::Receive), + failure: None, + footer: &footer, + status: &status, + scroll: state.scroll, + }; + let mut l = begin(fb, &spec); + let c = l.content; + let mut y = c.y + + wrapped(fb, c.x as i32, c.y as i32, c.w as i32, Role::Lead, LEAD, TEXT_3) as u32 + + GAP; + let hex = super::receive_address::address_hex(state); + if ready { + let uri = alloc::format!("ethereum:{hex}"); + if qr(fb, c.x, y, QR_SIDE, uri.as_bytes()) { + y += QR_SIDE + GAP; + } + y += value_block(fb, c.x, y, c.w, &hex) + GAP; + } + y += super::receive_address::facts(state, fb, c, y); + hits::reach(y, state.scroll, l.content_bottom); + end(fb, &spec, &mut l); + if let Some(back) = l.back { + hits::put(Press::Back, back); + } + if ready { + hits::put(Press::Footer(0), l.footer[0]); + } +} diff --git a/userland/capsule_wallet_nonos/src/wallet/screen/receive_address.rs b/userland/capsule_wallet_nonos/src/wallet/screen/receive_address.rs new file mode 100644 index 0000000000..c07d02cecf --- /dev/null +++ b/userland/capsule_wallet_nonos/src/wallet/screen/receive_address.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 account's address as the 0x text a person reads, copies and scans. + +use alloc::string::String; + +use nonos_app_skeleton::PaintBuffer; + +use crate::wallet::etna::parts::fact::fact; +use crate::wallet::etna::rect::Rect; +use crate::wallet::state::State; + +pub fn address_hex(state: &State) -> String { + let mut hex = String::from("0x"); + for b in state.address { + hex.push_str(&alloc::format!("{b:02x}")); + } + hex +} + +/// What is known about the address, as facts; returns their height. +pub fn facts(state: &State, fb: &mut PaintBuffer, c: Rect, y: u32) -> u32 { + let key = if state.vault_saved { "sealed to this machine" } else { "RAM only, gone at reboot" }; + let h = fact(fb, c.x, y, c.w, "network", "Ethereum mainnet"); + h + fact(fb, c.x, y + h, c.w, "key", key) +} diff --git a/userland/capsule_wallet_nonos/src/wallet/screen/status.rs b/userland/capsule_wallet_nonos/src/wallet/screen/status.rs new file mode 100644 index 0000000000..0536405103 --- /dev/null +++ b/userland/capsule_wallet_nonos/src/wallet/screen/status.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 . + +//! The status line, from what the wallet knows at this moment and nothing +//! it would like to be true. It replaced a line that said "keys sealed, TLS +//! secured, security STRONG" whatever the state. + +use alloc::vec::Vec; + +use crate::wallet::state::State; + +pub fn parts(state: &State) -> Vec<&'static str> { + let mut out = Vec::new(); + out.push("Ethereum mainnet"); + out.push(match (state.net.tls_chain_ok, state.net.rpc_connect_ok) { + (true, _) => "TLS 1.3", + (false, true) => "connecting", + (false, false) => "offline", + }); + if state.net.nym_ok { + out.push("Nym"); + } + out.push(if state.balance_ready { "synced" } else { "reading" }); + out.push(if state.vault_saved { "sealed" } else { "RAM only" }); + out +} diff --git a/userland/capsule_wallet_nonos/src/wallet/screen/welcome.rs b/userland/capsule_wallet_nonos/src/wallet/screen/welcome.rs new file mode 100644 index 0000000000..93b81f4069 --- /dev/null +++ b/userland/capsule_wallet_nonos/src/wallet/screen/welcome.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 . + +//! 01 NONOS, the first screen: make a wallet or restore one. The words are +//! the phones' shape with this machine's facts: the keys are made here, and +//! the balance is read from a public node, which this wallet says plainly. + +use nonos_app_skeleton::PaintBuffer; + +use super::hits::{self, Press}; +use crate::wallet::etna::backdrop::Backdrop; +use crate::wallet::etna::frame::{begin, end}; +use crate::wallet::etna::frame_spec::FrameSpec; +use crate::wallet::etna::parts::action::Weight; +use crate::wallet::etna::tokens::{GAP, TEXT, TEXT_3}; +use crate::wallet::etna::wrap::wrapped; +use crate::wallet::etna::Role; +use crate::wallet::state::State; + +const STATEMENT: &str = "The keys are made on this machine and never leave it."; +const LEAD: &str = "Balances are read over TLS from a public Ethereum node, which sees \ + which address asks. Private payments come with Shield."; + +pub fn welcome(state: &State, fb: &mut PaintBuffer) { + hits::clear(); + let status = super::status::parts(state); + let footer = [ + ("Create a wallet", Weight::Primary, true), + ("Import a private key", Weight::Secondary, true), + ]; + let spec = FrameSpec { + number: "01", + title: "N\u{d8}NOS", + back: false, + backdrop: Some(Backdrop::Welcome), + failure: None, + footer: &footer, + status: &status, + scroll: state.scroll, + }; + let mut l = begin(fb, &spec); + let c = l.content; + crate::wallet::paint::logo::logo(fb, c.x, c.y, 44); + let mut y = (c.y + 44 + GAP) as i32; + y += wrapped(fb, c.x as i32, y, c.w as i32, Role::Statement, STATEMENT, TEXT) + GAP as i32; + y += wrapped(fb, c.x as i32, y, c.w as i32, Role::Lead, LEAD, TEXT_3); + hits::reach(y as u32, state.scroll, l.content_bottom); + end(fb, &spec, &mut l); + hits::put(Press::Footer(0), l.footer[0]); + hits::put(Press::Footer(1), l.footer[1]); +} diff --git a/userland/capsule_wallet_nonos/src/wallet/state/new.rs b/userland/capsule_wallet_nonos/src/wallet/state/new.rs index dbbac7e6b0..eab7a36a7a 100644 --- a/userland/capsule_wallet_nonos/src/wallet/state/new.rs +++ b/userland/capsule_wallet_nonos/src/wallet/state/new.rs @@ -51,6 +51,7 @@ pub fn new_state() -> State { fee_ready: false, fee_wei: 0, view: super::types::VIEW_HOME, + scroll: 0, send_focus: super::types::SEND_FIELD_TO, send_to_hex: [0; 40], send_to_len: 0, diff --git a/userland/capsule_wallet_nonos/src/wallet/state/types.rs b/userland/capsule_wallet_nonos/src/wallet/state/types.rs index 45d815990a..9318f901d1 100644 --- a/userland/capsule_wallet_nonos/src/wallet/state/types.rs +++ b/userland/capsule_wallet_nonos/src/wallet/state/types.rs @@ -84,6 +84,8 @@ pub struct State { pub fee_ready: bool, pub fee_wei: u64, pub view: u8, + /// How far an Etna screen has scrolled, reset when the view changes. + pub scroll: u32, pub send_focus: u8, pub send_to_hex: [u8; 40], pub send_to_len: usize, diff --git a/userland/capsule_wallpaper/src/paint/blit_argb.rs b/userland/capsule_wallpaper/src/paint/blit_argb.rs index b631584ed7..5d74f6374f 100644 --- a/userland/capsule_wallpaper/src/paint/blit_argb.rs +++ b/userland/capsule_wallpaper/src/paint/blit_argb.rs @@ -14,6 +14,10 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . +use nonos_toolkit::image::scale::scale_cover; + +// Fill the backing surface with `src`, scaled to cover it at the image's own +// aspect: area-averaged when shrinking, bilinear when enlarging, exact at 1:1. pub fn blit_argb( backing_va: u64, stride_bytes: u32, @@ -22,24 +26,19 @@ pub fn blit_argb( src: &[u32], src_w: u32, src_h: u32, -) { - if src_w == 0 || src_h == 0 || backing_w == 0 || backing_h == 0 { - return; - } +) -> bool { let stride_px = (stride_bytes / 4) as usize; - let dst_ptr = backing_va as *mut u32; - for dy in 0..backing_h { - let sy = (dy as u64 * src_h as u64 / backing_h as u64) as u32; - let sy = if sy >= src_h { src_h - 1 } else { sy }; - let row_off_dst = (dy as usize) * stride_px; - let row_off_src = (sy as usize) * (src_w as usize); - for dx in 0..backing_w { - let sx = (dx as u64 * src_w as u64 / backing_w as u64) as u32; - let sx = if sx >= src_w { src_w - 1 } else { sx }; - let pixel = src[row_off_src + sx as usize]; - unsafe { - core::ptr::write_volatile(dst_ptr.add(row_off_dst + dx as usize), pixel); - } - } + if stride_px < backing_w as usize { + return false; } + let dst = backing_va as *mut u32; + scale_cover(src, src_w, src_h, backing_w, backing_h, |y, row| { + let at = y as usize * stride_px; + for (x, &p) in row.iter().enumerate() { + // SAFETY: the backing is `stride_bytes * backing_h` bytes, mapped + // read-write by `prime::backing::allocate`; `y < backing_h` and + // `x < backing_w <= stride_px`, checked above, so the write is inside it. + unsafe { core::ptr::write_volatile(dst.add(at + x), p) }; + } + }) } diff --git a/userland/capsule_wallpaper/src/paint/paint_image.rs b/userland/capsule_wallpaper/src/paint/paint_image.rs index 11d3c4f978..01784232b0 100644 --- a/userland/capsule_wallpaper/src/paint/paint_image.rs +++ b/userland/capsule_wallpaper/src/paint/paint_image.rs @@ -19,14 +19,7 @@ use crate::state::Context; use super::blit_argb::blit_argb; use super::decode_jpeg::DecodedImage; -pub fn paint_image(ctx: &Context, img: &DecodedImage) { - blit_argb( - ctx.backing_va, - ctx.stride, - ctx.width, - ctx.height, - &img.pixels, - img.width, - img.height, - ); +// False when the image or the surface is malformed; the surface is then unchanged. +pub fn paint_image(ctx: &Context, img: &DecodedImage) -> bool { + blit_argb(ctx.backing_va, ctx.stride, ctx.width, ctx.height, &img.pixels, img.width, img.height) } diff --git a/userland/capsule_wallpaper/src/setup/prime/run.rs b/userland/capsule_wallpaper/src/setup/prime/run.rs index ccacaebaf7..8f6725afab 100644 --- a/userland/capsule_wallpaper/src/setup/prime/run.rs +++ b/userland/capsule_wallpaper/src/setup/prime/run.rs @@ -22,9 +22,10 @@ use crate::paint::{decode_jpeg, fill_argb, paint_image}; use crate::policy_client::lookup_policy; use crate::state::{Context, FadeTimeline, Policy}; -const DEFAULT_ARGB: u32 = 0xFF00_80FF; +// Ink, the desktop's darkest tone, shows if no image decodes. +const DEFAULT_ARGB: u32 = 0xFF0A_0B0D; const EMBEDDED_WALLPAPER: &[u8] = - include_bytes!("../../../../../nonos-data/wallpapers/special-variant-6-1080p.jpg"); + include_bytes!("../../../../../nonos-data/wallpapers/special-variant-9.jpg"); pub fn run() -> Result { let compositor_port = discover::lookup_compositor_port()?; @@ -49,7 +50,7 @@ pub fn run() -> Result { }; ctx.set_argb(DEFAULT_ARGB); if let Some(img) = decode_jpeg(EMBEDDED_WALLPAPER) { - paint_image(&ctx, &img); + let _ = paint_image(&ctx, &img); } let rid = ctx.issue_request_id(); register::register_wallpaper(compositor_port, rid, &backing)?; diff --git a/userland/capsule_wallpaper/src/subscriber/apply.rs b/userland/capsule_wallpaper/src/subscriber/apply.rs index 1d8e5bccdd..033d19c84f 100644 --- a/userland/capsule_wallpaper/src/subscriber/apply.rs +++ b/userland/capsule_wallpaper/src/subscriber/apply.rs @@ -32,7 +32,9 @@ pub fn apply(ctx: &mut Context, index: u8) -> bool { Some(image) => image, None => return false, }; - paint_image(ctx, &img); + if !paint_image(ctx, &img) { + return false; + } let rid = ctx.issue_request_id(); let _ = push_damage_commit(ctx.compositor_port, rid, 0, 0, ctx.width, ctx.height); true 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 7af794523d..a61bf5c3df 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; 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); } 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/codes.rs b/userland/inflate/src/codes.rs index 7baaa53ccc..c430eafac7 100644 --- a/userland/inflate/src/codes.rs +++ b/userland/inflate/src/codes.rs @@ -18,9 +18,11 @@ use alloc::vec::Vec; use super::bits::Bits; use super::huff::{decode, Huff}; -use super::tables::{DBASE, DEXT, LBASE, LEXT, MAX_OUT}; +use super::tables::{DBASE, DEXT, LBASE, LEXT}; -pub fn codes(b: &mut Bits, out: &mut Vec, lit: &Huff, dist: &Huff) -> Option<()> { +/// `limit` bounds the output as it grows, so a small stream that expands +/// without end stops at the bound instead of exhausting memory. +pub fn codes(b: &mut Bits, out: &mut Vec, lit: &Huff, dist: &Huff, limit: usize) -> Option<()> { loop { let sym = decode(b, lit)?; if sym == 256 { @@ -29,15 +31,15 @@ pub fn codes(b: &mut Bits, out: &mut Vec, lit: &Huff, dist: &Huff) -> Option if sym < 256 { out.push(sym as u8); } else { - copy_match(b, out, sym, dist)?; + copy_match(b, out, sym, dist, limit)?; } - if out.len() > MAX_OUT { + if out.len() > limit { return None; } } } -fn copy_match(b: &mut Bits, out: &mut Vec, sym: u16, dist: &Huff) -> Option<()> { +fn copy_match(b: &mut Bits, out: &mut Vec, sym: u16, dist: &Huff, limit: usize) -> Option<()> { let s = (sym - 257) as usize; if s >= 29 { return None; @@ -48,7 +50,7 @@ fn copy_match(b: &mut Bits, out: &mut Vec, sym: u16, dist: &Huff) -> Option< return None; } let dist_v = DBASE[dsym] as usize + b.bits(DEXT[dsym] as u32)? as usize; - if dist_v == 0 || dist_v > out.len() || out.len().checked_add(len)? > MAX_OUT { + if dist_v == 0 || dist_v > out.len() || out.len().checked_add(len)? > limit { return None; } let start = out.len() - dist_v; diff --git a/userland/inflate/src/dynamic.rs b/userland/inflate/src/dynamic.rs index f5301e11ed..45c216dce9 100644 --- a/userland/inflate/src/dynamic.rs +++ b/userland/inflate/src/dynamic.rs @@ -21,7 +21,7 @@ use super::codes::codes; use super::huff::{build, decode}; use super::tables::{MAX_OUT, ORDER}; -pub fn dynamic(b: &mut Bits, out: &mut Vec) -> Option<()> { +pub fn dynamic(b: &mut Bits, out: &mut Vec, limit: usize) -> Option<()> { let hlit = b.bits(5)? as usize + 257; let hdist = b.bits(5)? as usize + 1; let hclen = b.bits(4)? as usize + 4; @@ -40,7 +40,7 @@ pub fn dynamic(b: &mut Bits, out: &mut Vec) -> Option<()> { } let lit = build(&lengths[..hlit]); let dist = build(&lengths[hlit..hlit + hdist]); - codes(b, out, &lit, &dist) + codes(b, out, &lit, &dist, limit) } fn push_lengths(sym: u16, b: &mut Bits, lengths: &mut Vec, limit: usize) -> Option<()> { diff --git a/userland/inflate/src/fixed.rs b/userland/inflate/src/fixed.rs index 28d9e1819e..ff003b08cd 100644 --- a/userland/inflate/src/fixed.rs +++ b/userland/inflate/src/fixed.rs @@ -20,7 +20,7 @@ use super::bits::Bits; use super::codes::codes; use super::huff::build; -pub fn fixed(b: &mut Bits, out: &mut Vec) -> Option<()> { +pub fn fixed(b: &mut Bits, out: &mut Vec, limit: usize) -> Option<()> { let mut ll = [0u8; 288]; for item in ll.iter_mut().take(144) { *item = 8; @@ -36,5 +36,5 @@ pub fn fixed(b: &mut Bits, out: &mut Vec) -> Option<()> { } let lit = build(&ll); let dist = build(&[5u8; 30]); - codes(b, out, &lit, &dist) + codes(b, out, &lit, &dist, limit) } diff --git a/userland/inflate/src/gzip.rs b/userland/inflate/src/gzip.rs index b918db8586..3b65889cb2 100644 --- a/userland/inflate/src/gzip.rs +++ b/userland/inflate/src/gzip.rs @@ -19,29 +19,34 @@ use alloc::vec::Vec; use super::crc32::crc32; use super::gzip_header::body_at; -use super::inflate_raw::inflate_counted; +use super::inflate_raw::inflate_counted_within; use super::tables::MAX_OUT; /// A member ends with a CRC32 and an ISIZE. 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. pub fn gunzip(data: &[u8]) -> Option> { - let (mut out, mut at) = verified(data)?; + gunzip_within(data, MAX_OUT) +} + +/// The same, with the caller's bound on the whole output. +pub fn gunzip_within(data: &[u8], limit: usize) -> Option> { + let (mut out, mut at) = verified(data, limit)?; for _ in 1..MAX_MEMBERS { let rest = data.get(at..)?; if rest.is_empty() { return Some(out); } - let Some((mut part, end)) = inflated(rest) else { + let Some((mut part, end)) = inflated(rest, limit) else { return Some(out); }; checked(rest, &part, end)?; - if out.len().checked_add(part.len())? > MAX_OUT { + if out.len().checked_add(part.len())? > limit { return None; } out.append(&mut part); @@ -50,16 +55,16 @@ pub fn gunzip(data: &[u8]) -> Option> { (at == data.len()).then_some(out) } -fn verified(d: &[u8]) -> Option<(Vec, usize)> { - let (out, end) = inflated(d)?; +pub(super) fn verified(d: &[u8], limit: usize) -> Option<(Vec, usize)> { + let (out, end) = inflated(d, limit)?; checked(d, &out, end)?; Some((out, end.checked_add(TRAILER)?)) } /// The member's output, and the offset of its trailer. -fn inflated(d: &[u8]) -> Option<(Vec, usize)> { +fn inflated(d: &[u8], limit: usize) -> Option<(Vec, usize)> { let start = body_at(d)?; - let (out, used) = inflate_counted(d.get(start..)?)?; + let (out, used) = inflate_counted_within(d.get(start..)?, limit)?; Some((out, start.checked_add(used)?)) } diff --git a/userland/inflate/src/inflate_raw.rs b/userland/inflate/src/inflate_raw.rs index 5d6c667810..fddd417bc4 100644 --- a/userland/inflate/src/inflate_raw.rs +++ b/userland/inflate/src/inflate_raw.rs @@ -28,17 +28,22 @@ pub fn inflate(src: &[u8]) -> Option> { /// The same, and how many bytes of `src` the stream occupied. pub fn inflate_counted(src: &[u8]) -> Option<(Vec, usize)> { + inflate_counted_within(src, MAX_OUT) +} + +/// The same, with the caller's bound on the output in place of MAX_OUT. +pub fn inflate_counted_within(src: &[u8], limit: usize) -> Option<(Vec, usize)> { let mut b = Bits::new(src); let mut out: Vec = Vec::new(); loop { let last = b.bit()?; match b.bits(2)? { - 0 => stored(&mut b, &mut out)?, - 1 => fixed(&mut b, &mut out)?, - 2 => dynamic(&mut b, &mut out)?, + 0 => stored(&mut b, &mut out, limit)?, + 1 => fixed(&mut b, &mut out, limit)?, + 2 => dynamic(&mut b, &mut out, limit)?, _ => return None, } - if out.len() > MAX_OUT { + if out.len() > limit { return None; } if last == 1 { diff --git a/userland/inflate/src/lib.rs b/userland/inflate/src/lib.rs index fa73064ae5..3327e06cdd 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 gzip::{gunzip, gunzip_within}; pub use inflate_raw::inflate; +pub use members::{members, members_within, 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..93687f6a8a --- /dev/null +++ b/userland/inflate/src/members.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 . +//! 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> { + members_within(data, MAX_OUT) +} + +/// The same, with the caller's bound on all members' output together. +pub fn members_within(data: &[u8], limit: usize) -> 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..)?, limit)?; + total = total.checked_add(body.len())?; + if total > limit { + return None; + } + let end = at.checked_add(len)?; + out.push(Member { start: at, end, body }); + at = end; + } + (!out.is_empty()).then_some(out) +} diff --git a/userland/inflate/src/stored.rs b/userland/inflate/src/stored.rs index 3fc6e5bf5d..d3d3b6a248 100644 --- a/userland/inflate/src/stored.rs +++ b/userland/inflate/src/stored.rs @@ -17,9 +17,7 @@ use alloc::vec::Vec; use super::bits::Bits; -use super::tables::MAX_OUT; - -pub fn stored(b: &mut Bits, out: &mut Vec) -> Option<()> { +pub fn stored(b: &mut Bits, out: &mut Vec, limit: usize) -> Option<()> { b.align(); let lo = b.take()? as usize; let hi = b.take()? as usize; @@ -27,7 +25,7 @@ pub fn stored(b: &mut Bits, out: &mut Vec) -> Option<()> { let nlo = b.take()? as usize; let nhi = b.take()? as usize; let nlen = nlo | (nhi << 8); - if len ^ nlen != 0xffff || out.len().checked_add(len)? > MAX_OUT { + if len ^ nlen != 0xffff || out.len().checked_add(len)? > limit { return None; } for _ in 0..len { diff --git a/userland/kernel_proofs/src/ipc_peers_tests.rs b/userland/kernel_proofs/src/ipc_peers_tests.rs new file mode 100644 index 0000000000..a772c0c929 --- /dev/null +++ b/userland/kernel_proofs/src/ipc_peers_tests.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 IPC peer list, run as the kernel runs it: a capsule held to a list +//! reaches its peers and nothing else, and a capsule off the list is left +//! as it was. Deleting the prover's row makes `prover_reaches_only_core` fail. + +#[path = "../../../src/services/registry/peers.rs"] +mod peers; + +use peers::may_reach; + +#[test] +fn prover_reaches_only_core() { + assert!(may_reach("shield_prover", "shield.core")); + for other in ["net.sockets", "net.tcp", "net.nym", "vfs", "wallet", "shield.net"] { + assert!(!may_reach("shield_prover", other), "the prover reached {other}"); + } +} + +#[test] +fn an_unlisted_capsule_is_unaffected() { + assert!(may_reach("vfs", "net.sockets")); + assert!(may_reach("shield_core", "shield.prover")); +} diff --git a/userland/kernel_proofs/src/lib.rs b/userland/kernel_proofs/src/lib.rs index 1786638e5f..b078d8ad4f 100644 --- a/userland/kernel_proofs/src/lib.rs +++ b/userland/kernel_proofs/src/lib.rs @@ -38,6 +38,8 @@ pub mod usercopy; #[cfg(test)] mod authorization_tests; #[cfg(test)] +mod ipc_peers_tests; +#[cfg(test)] mod elf_tests; #[cfg(test)] mod inbox_name_tests; 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/foreign.rs b/userland/libc/src/foreign.rs index f3217b39e2..a50956d75b 100644 --- a/userland/libc/src/foreign.rs +++ b/userland/libc/src/foreign.rs @@ -54,9 +54,12 @@ pub fn mk_foreign_exec(pid: u32, entry: u64, rsp: u64) -> i64 { } /// A thread in a guest, sharing its address space. `tls` is the FS base -/// it wakes with, which a C runtime reads before anything else. -pub fn mk_foreign_thread(pid: u32, entry: u64, rsp: u64, tls: u64) -> i64 { - call_raw(N_MK_FOREIGN_THREAD, [pid as u64, entry, rsp, tls, 0, 0]) +/// it wakes with, which a C runtime reads before anything else. `from` is +/// the guest thread parked in the call that asked for it, whose registers the +/// new thread starts on, as a Linux clone child does; zero starts it on fresh +/// ones. +pub fn mk_foreign_thread(pid: u32, entry: u64, rsp: u64, tls: u64, from: u32) -> i64 { + call_raw(N_MK_FOREIGN_THREAD, [pid as u64, entry, rsp, tls, from as u64, 0]) } /// Block until a guest of this process makes a call the kernel refuses, diff --git a/userland/libc/src/foreign_frame.rs b/userland/libc/src/foreign_frame.rs index cf8f6f413a..e172f161ce 100644 --- a/userland/libc/src/foreign_frame.rs +++ b/userland/libc/src/foreign_frame.rs @@ -21,6 +21,16 @@ //! the System V convention, so a supervisor reads its guest's arguments //! without knowing anything about this kernel. +/// A frame the kernel delivers to a supervisor, not a guest call: the guest +/// thread named by `pid` ended on a signal, its `arg0` the kernel's code. +/// No reply follows. Its value cannot collide with a syscall number. +pub const FOREIGN_NR_DIED: u64 = u64::MAX; +/// A frame the kernel delivers to a supervisor for a thread it asked to have +/// stopped (`mk_foreign_interrupt`): the thread was running and is parked +/// with its whole register file. A signal answer enters a handler; any +/// other reply resumes it where it was. +pub const FOREIGN_NR_INTERRUPTED: u64 = u64::MAX - 1; + #[repr(C)] #[derive(Clone, Copy, Default)] pub struct ForeignFrame { diff --git a/userland/libc/src/foreign_signal.rs b/userland/libc/src/foreign_signal.rs new file mode 100644 index 0000000000..43a9316157 --- /dev/null +++ b/userland/libc/src/foreign_signal.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 . + +//! What a personality needs to deliver a signal: a parked guest's registers, +//! and answering it with a whole context instead of a value. + +use crate::syscall::{call_raw, N_MK_FOREIGN_CONTEXT, N_MK_FOREIGN_INTERRUPT, N_MK_FOREIGN_SIGNAL}; + +/// r8..r15, rdi, rsi, rbp, rbx, rdx, rax, rcx, rsp, rip, rflags: the order of +/// Linux's `struct sigcontext`, so a frame can be copied without reshuffling. +pub type ForeignRegs = [u64; 18]; + +/// Enter a handler: the kernel keeps the guest's FPU state until it returns. +pub const SIGNAL_DELIVER: u64 = 0; +/// Return from a handler: the FPU state saved at delivery comes back. +pub const SIGNAL_RETURN: u64 = 1; + +/// The registers of a guest parked in a call, as it made the call. +pub fn mk_foreign_context(pid: u32, out: &mut ForeignRegs) -> i64 { + call_raw(N_MK_FOREIGN_CONTEXT, [pid as u64, out.as_mut_ptr() as u64, 0, 0, 0, 0]) +} + +/// Wake a parked guest into `regs`. The kernel forces user selectors, keeps +/// the TLS base, masks rflags to the program's own bits, and refuses a rip or +/// rsp outside user space. +pub fn mk_foreign_signal(pid: u32, regs: &ForeignRegs, kind: u64) -> i64 { + call_raw(N_MK_FOREIGN_SIGNAL, [pid as u64, regs.as_ptr() as u64, kind, 0, 0, 0]) +} + +/// Stop a guest thread that is running its own code at its next timer tick, +/// and hand it over parked, numbered `FOREIGN_NR_INTERRUPTED`, so a signal can +/// be delivered to it. 1 says it is parked in a call already, whose answer can +/// carry the signal; 0 says it will be stopped. +pub fn mk_foreign_interrupt(pid: u32) -> i64 { + call_raw(N_MK_FOREIGN_INTERRUPT, [pid as u64, 0, 0, 0, 0, 0]) +} diff --git a/userland/libc/src/lib.rs b/userland/libc/src/lib.rs index 2ae1a75650..3e8dc3745d 100644 --- a/userland/libc/src/lib.rs +++ b/userland/libc/src/lib.rs @@ -28,6 +28,7 @@ pub mod crypto; pub mod debug; pub mod foreign; pub mod foreign_frame; +pub mod foreign_signal; pub mod graphics; #[cfg(feature = "heap")] pub mod heap; @@ -76,14 +77,23 @@ 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 foreign_frame::{ForeignFrame, FOREIGN_NR_DIED, FOREIGN_NR_INTERRUPTED}; +pub use foreign_signal::{ + mk_foreign_context, mk_foreign_interrupt, mk_foreign_signal, ForeignRegs, SIGNAL_DELIVER, + SIGNAL_RETURN, +}; 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_app_install_status, 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}; pub use install_source::{ diff --git a/userland/libc/src/local_sign.rs b/userland/libc/src/local_sign.rs index ad420f1199..aa50798b25 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_INSTALL_STATUS, 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 { @@ -45,8 +47,21 @@ 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]) } +/// 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]) +} + +/// Where an asked-for install of `listing` stands: 0 nothing asked, 1 queued, +/// 2 installing, 3 installed, 4 refused before it started, 16 plus the +/// installer's reason code when it failed. +pub fn mk_app_install_status(listing: &[u8]) -> i64 { + call_raw(N_MK_APP_INSTALL_STATUS, [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 ba5262c373..21548aec7d 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_APP_INSTALL_STATUS, 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_CONTEXT, N_MK_FOREIGN_EXEC, N_MK_FOREIGN_FORK, N_MK_FOREIGN_REPLY, N_MK_FOREIGN_SIGNAL, N_MK_FOREIGN_INTERRUPT, 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..7b79f12f55 100644 --- a/userland/libc/src/syscall/numbers/foreign.rs +++ b/userland/libc/src/syscall/numbers/foreign.rs @@ -23,6 +23,9 @@ pub(crate) const N_MK_FOREIGN_SPAWN: i64 = tag4(b"MFSP"); pub(crate) const N_MK_FOREIGN_START: i64 = tag4(b"MFST"); pub(crate) const N_MK_FOREIGN_WAIT: i64 = tag4(b"MFWT"); pub(crate) const N_MK_FOREIGN_REPLY: i64 = tag4(b"MFRP"); +pub(crate) const N_MK_FOREIGN_CONTEXT: i64 = tag4(b"MFCX"); +pub(crate) const N_MK_FOREIGN_SIGNAL: i64 = tag4(b"MFSG"); +pub(crate) const N_MK_FOREIGN_INTERRUPT: i64 = tag4(b"MFIN"); pub(crate) const N_MK_PEER_MAP: i64 = tag4(b"MPMP"); pub(crate) const N_MK_PEER_COPY: i64 = tag4(b"MPCP"); pub(crate) const N_MK_PEER_PROTECT: i64 = tag4(b"MPPT"); @@ -35,4 +38,8 @@ 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_APP_LAUNCH: i64 = tag4(b"MAPL"); +pub(crate) const N_MK_APP_INSTALL_STATUS: i64 = tag4(b"MAIS"); pub(crate) const N_MK_DEV_ROOT_CONFIRM: i64 = tag4(b"MDRC"); 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/GuestFiles.mk b/userland/linux_guests/GuestFiles.mk new file mode 100644 index 0000000000..75a4184554 --- /dev/null +++ b/userland/linux_guests/GuestFiles.mk @@ -0,0 +1,43 @@ +# The files a guest-test image carries beside the guests. Included by Guests.mk. + +# 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) && 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) + +# 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) + +# 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) + +# 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 new file mode 100644 index 0000000000..ec08c9b0ea --- /dev/null +++ b/userland/linux_guests/Guests.mk @@ -0,0 +1,134 @@ +# Linux guests signed and enrolled like capsules, for test images. +# +# 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) +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 $* +$(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[, 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) +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 := $(or $(4),$(LINUX_GUESTS_OUT)/$(1)) +CAPSULE_MK_FILE := $(LINUX_GUESTS_DIR)/Guests.mk +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$(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)) +$(eval $(call LINUX_GUEST,holder,4952,4953)) +$(eval $(call LINUX_GUEST,reader,4954,4955)) +$(eval $(call LINUX_GUEST,window,4964,4965)) +$(eval $(call LINUX_GUEST,signal,4966,4967)) +# 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)) + +# Tier 1: static Go binaries, the cheapest guests: no cgo, no loader, no libc. +# Built here with the toolchain the container carries. A tool that comes up at +# all proves the runtime's threads, memory and signals under the personality. +GO := /usr/local/go/bin/go +GO_OUT := $(TARGET_DIR)/linux-guests/go +$(GO_OUT)/%: $(LINUX_GUESTS_DIR)/go/%/main.go + @mkdir -p $(@D) && cd $(LINUX_GUESTS_DIR)/go/$* && \ + CGO_ENABLED=0 GOOS=linux GOARCH=amd64 GOFLAGS=-trimpath \ + GOCACHE=$(abspath $(GO_OUT))/cache GOPATH=$(abspath $(GO_OUT))/path \ + $(GO) build -ldflags '-s -w' -o $(abspath $@) . +$(eval $(call LINUX_GUEST,gohello,4968,4969,$(GO_OUT)/hello)) +$(eval $(call LINUX_GUEST,goconc,4970,4971,$(GO_OUT)/conc)) +# Go's network poller: a timer's epoll wait and its eventfd wake, and a pipe +# read through the poller to end of file. +$(eval $(call LINUX_GUEST,gopoll,4976,4977,$(GO_OUT)/poll)) +# A goroutine spinning with no call, which only a signal to its running +# thread can move off the one CPU the guest has. +$(eval $(call LINUX_GUEST,gopreempt,4944,4945,$(GO_OUT)/preempt)) + +# A C guest that faults in a worker thread while main joins: it proves the +# whole process ends, as on Linux, and that musl threads run. Static, so no +# loader is needed. musl carries pthreads in libc, so no -lpthread. +$(LINUX_GUESTS_C)/threadfault: $(LINUX_GUESTS_DIR)/c/threadfault.c + @mkdir -p $(@D) && musl-gcc -O2 -static -o $@ $< +$(eval $(call LINUX_GUEST,threadfault,4972,4973,$(LINUX_GUESTS_C)/threadfault)) + +# musl pthreads end to end: stacks reserved and committed by mprotect, a mutex, +# and joins that wait on the clear-tid word each exit zeroes and wakes. +$(LINUX_GUESTS_C)/cthreads: $(LINUX_GUESTS_DIR)/c/cthreads.c + @mkdir -p $(@D) && musl-gcc -O2 -static -o $@ $< +$(eval $(call LINUX_GUEST,cthreads,4974,4975,$(LINUX_GUESTS_C)/cthreads)) + +# Waiting as Linux waits: futex timeouts and requeue, eventfd, epoll_wait's +# timeout and wake, a non-blocking pipe, a full pipe, and edge-triggered epoll. +$(LINUX_GUESTS_C)/cwait: $(LINUX_GUESTS_DIR)/c/cwait.c + @mkdir -p $(@D) && musl-gcc -O2 -static -o $@ $< +$(eval $(call LINUX_GUEST,cwait,4978,4979,$(LINUX_GUESTS_C)/cwait)) + +# A caught signal for a thread spinning with no call: it arrives only if the +# kernel stops the running thread for its supervisor. +$(LINUX_GUESTS_C)/cpreempt: $(LINUX_GUESTS_DIR)/c/cpreempt.c + @mkdir -p $(@D) && musl-gcc -O2 -static -o $@ $< +$(eval $(call LINUX_GUEST,cpreempt,4946,4947,$(LINUX_GUESTS_C)/cpreempt)) + +# The Linux-guest test store is about guests, not the desktop's media and demo +# capsules. Drop both so the signed guest set fits the vfs load budget; the +# normal image, which does not set NONOS_LINUX_GUESTS, still ships them. +override NONOS_STORE_MEDIA_ENTRIES := +override NONOS_STORE_DEMO_ENTRIES := + +include $(LINUX_GUESTS_DIR)/GuestFiles.mk diff --git a/userland/linux_guests/c/cpreempt.c b/userland/linux_guests/c/cpreempt.c new file mode 100644 index 0000000000..38a86a8963 --- /dev/null +++ b/userland/linux_guests/c/cpreempt.c @@ -0,0 +1,53 @@ +// A caught signal sent to a thread that is running its own code, with no +// call for it to arrive on: the handler has to run inside the spin, or the +// spin never ends and the join never returns. +#define _GNU_SOURCE +#include +#include +#include +#include +#include +#include + +static volatile sig_atomic_t hit; +static volatile int spinning; +static volatile unsigned long spins; + +static void on_usr1(int sig) { + (void)sig; + hit = 1; +} + +static void *spin(void *arg) { + (void)arg; + spinning = 1; + while (!hit) { + spins++; + } + return 0; +} + +static long now_ms(void) { + struct timespec ts; + clock_gettime(CLOCK_MONOTONIC, &ts); + return ts.tv_sec * 1000 + ts.tv_nsec / 1000000; +} + +int main(void) { + struct sigaction sa; + memset(&sa, 0, sizeof sa); + sa.sa_handler = on_usr1; + sigaction(SIGUSR1, &sa, 0); + pthread_t t; + pthread_create(&t, 0, spin, 0); + while (!spinning) { + sched_yield(); + } + long t0 = now_ms(); + pthread_kill(t, SIGUSR1); + pthread_join(t, 0); + printf("[C] cpreempt PASS: the handler ran in a thread spinning with no call, after %ld ms\n", + now_ms() - t0); + fflush(stdout); + return 0; +} diff --git a/userland/linux_guests/c/cthreads.c b/userland/linux_guests/c/cthreads.c new file mode 100644 index 0000000000..866e24e3a8 --- /dev/null +++ b/userland/linux_guests/c/cthreads.c @@ -0,0 +1,49 @@ +// musl pthreads end to end: pthread_create reserves each stack with PROT_NONE +// and commits it with mprotect, the workers sum known ranges under a mutex, +// and pthread_join waits for each worker's exit, which musl learns from the +// clear-tid word the personality zeroes and wakes. A create that fails, a +// wrong total, or a join that never returns is a broken thread runtime. +#include +#include + +#define WORKERS 8 +#define EACH 10000UL + +static pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER; +static unsigned long long total; + +static void *work(void *arg) { + unsigned long base = (unsigned long)arg; + unsigned long long sum = 0; + for (unsigned long i = 0; i < EACH; i++) { + sum += base + i; + } + pthread_mutex_lock(&lock); + total += sum; + pthread_mutex_unlock(&lock); + return 0; +} + +int main(void) { + pthread_t t[WORKERS]; + for (unsigned long w = 0; w < WORKERS; w++) { + if (pthread_create(&t[w], 0, work, (void *)(w * EACH)) != 0) { + printf("[C] cthreads FAIL: create %lu\n", w); + fflush(stdout); + return 1; + } + } + for (int w = 0; w < WORKERS; w++) { + pthread_join(t[w], 0); + } + unsigned long long n = WORKERS * EACH; + unsigned long long want = (n - 1) * n / 2; + if (total != want) { + printf("[C] cthreads FAIL: total=%llu want=%llu\n", total, want); + fflush(stdout); + return 1; + } + printf("[C] cthreads PASS: %d pthreads joined, summed %llu\n", WORKERS, total); + fflush(stdout); + return 0; +} diff --git a/userland/linux_guests/c/cwait.c b/userland/linux_guests/c/cwait.c new file mode 100644 index 0000000000..0b476f232a --- /dev/null +++ b/userland/linux_guests/c/cwait.c @@ -0,0 +1,672 @@ +// Waiting, as Linux waits: a timed futex, a condition variable broadcast, an +// eventfd read blocking until another thread writes, epoll_wait's timeout and +// its wake from another thread, a non-blocking pipe and its end of file, a +// write to a full pipe waiting for room, edge-triggered epoll, two readers +// blocked on one pipe, poll, ppoll and select with their timeouts, a closed +// descriptor leaving epoll, timerfd one-shot, periodic and absolute, and the +// descriptor ioctls and an epoll list carried through fork, and the scheduler +// calls with epoll_create and epoll_pwait2, and tgkill with the numbers +// getpid and gettid give. Each part prints as it passes and every part runs, +// so one run names each part that fails, and a hang the part it hung in. +#define _GNU_SOURCE +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +static int parts; + +static long now_ms(void) { + struct timespec ts; + clock_gettime(CLOCK_MONOTONIC, &ts); + return ts.tv_sec * 1000 + ts.tv_nsec / 1000000; +} + +static void nap_ms(long ms) { + struct timespec ts = {ms / 1000, (ms % 1000) * 1000000}; + nanosleep(&ts, 0); +} + +static int fail(const char *what, long a, long b) { + printf("[C] cwait FAIL: %s (%ld, %ld)\n", what, a, b); + fflush(stdout); + return 1; +} + +static void ok(const char *part, const char *detail, long n) { + parts++; + printf("[C] cwait %s ok: %s %ld\n", part, detail, n); + fflush(stdout); +} + +static pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER; +static pthread_cond_t cond = PTHREAD_COND_INITIALIZER; +static int go_flag, woke; + +static void *cond_waiter(void *arg) { + (void)arg; + pthread_mutex_lock(&lock); + while (!go_flag) { + pthread_cond_wait(&cond, &lock); + } + woke++; + pthread_mutex_unlock(&lock); + return 0; +} + +static int efd_late; +static void *late_write(void *arg) { + nap_ms(100); + uint64_t v = (uint64_t)(uintptr_t)arg; + write(efd_late, &v, 8); + return 0; +} + +static int pipe_drain; +static void *late_read(void *arg) { + (void)arg; + char buf[4096]; + nap_ms(100); + read(pipe_drain, buf, sizeof buf); + return 0; +} + +static int two_in; +static char two_got[2]; +static void *pipe_reader(void *arg) { + read(two_in, &two_got[(uintptr_t)arg], 1); + return 0; +} + +static int late_fd; +static void *late_byte(void *arg) { + (void)arg; + nap_ms(100); + write(late_fd, "z", 1); + return 0; +} + +static int timed_futex(void) { + struct timespec at; + clock_gettime(CLOCK_REALTIME, &at); + at.tv_nsec += 100 * 1000000; + if (at.tv_nsec >= 1000000000) { + at.tv_sec++; + at.tv_nsec -= 1000000000; + } + long t0 = now_ms(); + pthread_mutex_lock(&lock); + int rc = pthread_cond_timedwait(&cond, &lock, &at); + pthread_mutex_unlock(&lock); + long took = now_ms() - t0; + if (rc != ETIMEDOUT || took < 95 || took > 2000) { + return fail("cond_timedwait 100ms", rc, took); + } + ok("futex-timeout", "cond_timedwait 100ms answered ETIMEDOUT after ms", took); + return 0; +} + +static int broadcast(void) { + pthread_t t[4]; + for (int i = 0; i < 4; i++) { + pthread_create(&t[i], 0, cond_waiter, 0); + } + nap_ms(50); + pthread_mutex_lock(&lock); + go_flag = 1; + pthread_cond_broadcast(&cond); + pthread_mutex_unlock(&lock); + for (int i = 0; i < 4; i++) { + pthread_join(t[i], 0); + } + if (woke != 4) { + return fail("broadcast woke", woke, 4); + } + ok("broadcast", "condition variable waiters woken and joined:", woke); + return 0; +} + +static int eventfd_semaphore(void) { + int fd = eventfd(3, EFD_NONBLOCK | EFD_SEMAPHORE); + uint64_t v = 0; + for (int i = 0; i < 3; i++) { + if (read(fd, &v, 8) != 8 || v != 1) { + return fail("semaphore read", i, (long)v); + } + } + if (read(fd, &v, 8) != -1 || errno != EAGAIN) { + return fail("empty non-blocking eventfd read", (long)v, errno); + } + int plain = eventfd(0, EFD_NONBLOCK); + v = 5; + write(plain, &v, 8); + write(plain, &v, 8); + if (read(plain, &v, 8) != 8 || v != 10) { + return fail("eventfd count", (long)v, 10); + } + close(fd); + close(plain); + ok("eventfd", "3 semaphore reads of 1, EAGAIN at zero, then a count of", 10); + return 0; +} + +static int eventfd_blocking(void) { + efd_late = eventfd(0, 0); + pthread_t t; + long t0 = now_ms(); + pthread_create(&t, 0, late_write, (void *)(uintptr_t)7); + uint64_t v = 0; + ssize_t n = read(efd_late, &v, 8); + long took = now_ms() - t0; + pthread_join(t, 0); + if (n != 8 || v != 7 || took < 90) { + return fail("blocking eventfd read", (long)v, took); + } + ok("eventfd-wait", "a read waited for another thread's write of 7, ms", took); + return 0; +} + +static int epoll_timeout(void) { + int ep = epoll_create1(EPOLL_CLOEXEC); + int fd = eventfd(0, EFD_NONBLOCK); + struct epoll_event ev = {.events = EPOLLIN, .data.u64 = 0xfeed}; + epoll_ctl(ep, EPOLL_CTL_ADD, fd, &ev); + long t0 = now_ms(); + int n = epoll_wait(ep, &ev, 1, 100); + long took = now_ms() - t0; + if (n != 0 || took < 95 || took > 2000) { + return fail("epoll_wait 100ms", n, took); + } + ok("epoll-timeout", "epoll_wait 100ms with nothing ready answered 0 after ms", took); + efd_late = fd; + pthread_t t; + t0 = now_ms(); + pthread_create(&t, 0, late_write, (void *)(uintptr_t)1); + n = epoll_wait(ep, &ev, 1, -1); + took = now_ms() - t0; + pthread_join(t, 0); + if (n != 1 || ev.events != EPOLLIN || ev.data.u64 != 0xfeed || took < 90) { + return fail("epoll_wait woken by a write", n, took); + } + ok("epoll-wake", "epoll_wait with no timeout woken by another thread's write, ms", took); + close(fd); + close(ep); + return 0; +} + +static int pipe_nonblock(void) { + int p[2]; + pipe2(p, O_NONBLOCK); + char c; + if (read(p[0], &c, 1) != -1 || errno != EAGAIN) { + return fail("empty non-blocking pipe read", errno, EAGAIN); + } + if (!(fcntl(p[0], F_GETFL) & O_NONBLOCK)) { + return fail("F_GETFL after pipe2(O_NONBLOCK)", fcntl(p[0], F_GETFL), O_NONBLOCK); + } + fcntl(p[0], F_SETFL, 0); + if (fcntl(p[0], F_GETFL) & O_NONBLOCK) { + return fail("F_SETFL 0 kept O_NONBLOCK", fcntl(p[0], F_GETFL), 0); + } + close(p[1]); + struct pollfd pf = {.fd = p[0], .events = POLLIN}; + int ready = poll(&pf, 1, 0); + if (read(p[0], &c, 1) != 0 || ready != 1 || !(pf.revents & POLLHUP)) { + return fail("end of file after the write end closed", ready, pf.revents); + } + close(p[0]); + ok("pipe", "EAGAIN empty, O_NONBLOCK set and cleared, end of file and POLLHUP; revents", + pf.revents); + return 0; +} + +static int pipe_full(void) { + int p[2]; + pipe(p); + fcntl(p[1], F_SETFL, O_NONBLOCK); + static char block[4096]; + long held = 0; + while (write(p[1], block, sizeof block) == sizeof block) { + held += sizeof block; + } + fcntl(p[1], F_SETFL, 0); + pipe_drain = p[0]; + pthread_t t; + long t0 = now_ms(); + pthread_create(&t, 0, late_read, 0); + ssize_t n = write(p[1], block, sizeof block); + long took = now_ms() - t0; + pthread_join(t, 0); + close(p[0]); + close(p[1]); + if (held != 65536 || n != sizeof block || took < 90) { + return fail("write to a full pipe", held, took); + } + ok("pipe-full", "a 4096 byte write to a full 65536 byte pipe waited for a reader, ms", took); + return 0; +} + +static int edge(void) { + int p[2]; + pipe2(p, O_NONBLOCK); + int ep = epoll_create1(0); + struct epoll_event ev = {.events = EPOLLIN | EPOLLET, .data.u64 = 1}; + epoll_ctl(ep, EPOLL_CTL_ADD, p[0], &ev); + struct epoll_event lt = {.events = EPOLLOUT, .data.u64 = 2}; + epoll_ctl(ep, EPOLL_CTL_ADD, p[1], <); + struct epoll_event got[2]; + char c; + write(p[1], "x", 1); + int first = epoll_wait(ep, got, 2, 0); + int again = epoll_wait(ep, got, 2, 0); + read(p[0], &c, 1); + int drained = read(p[0], &c, 1) == -1 && errno == EAGAIN; + write(p[1], "y", 1); + int rearmed = epoll_wait(ep, got, 2, 0); + // The write end is level-triggered and always writable: counted each time. + if (first != 2 || again != 1 || !drained || rearmed != 2) { + return fail("edge-triggered counts", first * 100 + again * 10 + rearmed, drained); + } + if (epoll_ctl(ep, EPOLL_CTL_ADD, p[0], &ev) != -1 || errno != EEXIST) { + return fail("adding twice", errno, EEXIST); + } + close(p[0]); + close(p[1]); + close(ep); + ok("edge", "EPOLLET reported once per rise, again after EAGAIN; events", first + again + rearmed); + return 0; +} + +static int two_readers(void) { + int p[2]; + pipe(p); + two_in = p[0]; + pthread_t t[2]; + pthread_create(&t[0], 0, pipe_reader, (void *)0); + pthread_create(&t[1], 0, pipe_reader, (void *)1); + nap_ms(100); + write(p[1], "ab", 2); + pthread_join(t[0], 0); + pthread_join(t[1], 0); + close(p[0]); + close(p[1]); + int both = (two_got[0] == 'a' && two_got[1] == 'b') || (two_got[0] == 'b' && two_got[1] == 'a'); + if (!both) { + return fail("two blocked pipe readers", two_got[0], two_got[1]); + } + ok("pipe-readers", "two threads blocked reading one pipe, both answered; bytes", 2); + return 0; +} + +static int poll_select(void) { + int p[2]; + pipe(p); + struct pollfd pf = {.fd = p[0], .events = POLLIN}; + long t0 = now_ms(); + int n = poll(&pf, 1, 100); + long polled = now_ms() - t0; + t0 = now_ms(); + poll(0, 0, 50); + long slept = now_ms() - t0; + struct timespec ts = {0, 100 * 1000000}; + t0 = now_ms(); + int m = ppoll(&pf, 1, &ts, 0); + long ppolled = now_ms() - t0; + if (n != 0 || polled < 95 || slept < 45 || m != 0 || ppolled < 95 || polled > 2000) { + return fail("poll and ppoll timeouts", polled, ppolled); + } + fd_set rd; + FD_ZERO(&rd); + FD_SET(p[0], &rd); + struct timeval tv = {0, 100 * 1000}; + t0 = now_ms(); + int s = select(p[0] + 1, &rd, 0, 0, &tv); + long selected = now_ms() - t0; + if (s != 0 || FD_ISSET(p[0], &rd) || selected < 95) { + return fail("select timeout", s, selected); + } + FD_SET(p[0], &rd); + late_fd = p[1]; + pthread_t t; + t0 = now_ms(); + pthread_create(&t, 0, late_byte, 0); + s = select(p[0] + 1, &rd, 0, 0, 0); + long woken = now_ms() - t0; + pthread_join(t, 0); + close(p[0]); + close(p[1]); + if (s != 1 || !FD_ISSET(p[0], &rd) || woken < 90) { + return fail("select woken by a write", s, woken); + } + ok("poll-select", "poll, ppoll and select waited their 100ms timeouts, select woken after ms", + woken); + return 0; +} + +static int close_forgets(void) { + int ep = epoll_create1(0); + int p[2]; + pipe(p); + write(p[1], "x", 1); + struct epoll_event ev = {.events = EPOLLIN, .data.u64 = 7}; + epoll_ctl(ep, EPOLL_CTL_ADD, p[0], &ev); + int number = p[0]; + close(p[0]); + close(p[1]); + int q[2]; + pipe(q); + int reused = q[0] == number; + int added = epoll_ctl(ep, EPOLL_CTL_ADD, q[0], &ev); + struct epoll_event got; + int stale = epoll_wait(ep, &got, 1, 0); + int far = dup2(0, 100000) == -1 && errno == EBADF; + close(q[0]); + close(q[1]); + close(ep); + if (!reused || added != 0 || stale != 0 || !far) { + return fail("close leaves epoll", added * 10 + stale, reused * 10 + far); + } + ok("close-forget", "a closed descriptor left epoll and its number was added again; stale events", + stale); + return 0; +} + +static int timers(void) { + int tf = timerfd_create(CLOCK_MONOTONIC, TFD_NONBLOCK | TFD_CLOEXEC); + uint64_t n = 0; + if (read(tf, &n, 8) != -1 || errno != EAGAIN) { + return fail("unarmed timer read", (long)n, errno); + } + struct itimerspec every = {{0, 50 * 1000000}, {0, 50 * 1000000}}; + timerfd_settime(tf, 0, &every, 0); + nap_ms(180); + struct itimerspec now; + timerfd_gettime(tf, &now); + if (read(tf, &n, 8) != 8 || n < 3 || n > 4 || now.it_interval.tv_nsec != 50 * 1000000) { + return fail("periodic timer count", (long)n, now.it_interval.tv_nsec); + } + long periodic = (long)n; + close(tf); + tf = timerfd_create(CLOCK_MONOTONIC, 0); + struct itimerspec once = {{0, 0}, {0, 100 * 1000000}}; + timerfd_settime(tf, 0, &once, 0); + long t0 = now_ms(); + ssize_t got = read(tf, &n, 8); + long waited = now_ms() - t0; + if (got != 8 || n != 1 || waited < 90) { + return fail("blocking timer read", (long)n, waited); + } + struct timespec at; + clock_gettime(CLOCK_MONOTONIC, &at); + at.tv_nsec += 100 * 1000000; + if (at.tv_nsec >= 1000000000) { + at.tv_sec++; + at.tv_nsec -= 1000000000; + } + struct itimerspec abs = {{0, 0}, at}; + timerfd_settime(tf, TFD_TIMER_ABSTIME, &abs, 0); + int ep = epoll_create1(0); + struct epoll_event ev = {.events = EPOLLIN, .data.u64 = 3}; + epoll_ctl(ep, EPOLL_CTL_ADD, tf, &ev); + t0 = now_ms(); + int ready = epoll_wait(ep, &ev, 1, -1); + long absolute = now_ms() - t0; + close(ep); + close(tf); + if (ready != 1 || absolute < 90 || absolute > 2000) { + return fail("absolute timer through epoll", ready, absolute); + } + printf("[C] cwait timerfd detail: periodic 50ms fired %ld times in 180ms, one-shot read waited %ld " + "ms, absolute +100ms woke epoll after %ld ms\n", + periodic, waited, absolute); + ok("timerfd", "periodic, one-shot blocking and absolute timers; periodic count", periodic); + return 0; +} + +static int ioctls_fork(void) { + int p[2]; + pipe(p); + write(p[1], "abc", 3); + int held = -1; + ioctl(p[0], FIONREAD, &held); + int one = 1; + ioctl(p[0], FIONBIO, &one); + char buf[4]; + ssize_t got = read(p[0], buf, sizeof buf); + int drained = read(p[0], buf, 1) == -1 && errno == EAGAIN; + ioctl(p[1], FIOCLEX); + int cloexec = fcntl(p[1], F_GETFD) == FD_CLOEXEC; + if (held != 3 || got != 3 || !drained || !cloexec) { + return fail("descriptor ioctls", held, got * 10 + drained * 2 + cloexec); + } + int ep = epoll_create1(0); + struct epoll_event ev = {.events = EPOLLIN, .data.u64 = 9}; + epoll_ctl(ep, EPOLL_CTL_ADD, p[0], &ev); + write(p[1], "d", 1); + pid_t child = fork(); + if (child == 0) { + _exit(epoll_wait(ep, &ev, 1, 0) == 1 && ev.data.u64 == 9 ? 0 : 1); + } + int status = -1; + waitpid(child, &status, 0); + close(ep); + close(p[0]); + close(p[1]); + if (child < 0 || !WIFEXITED(status) || WEXITSTATUS(status) != 0) { + return fail("epoll list through fork", child, status); + } + ok("ioctl-fork", "FIONREAD, FIONBIO, FIOCLEX, and a forked child saw the epoll list; bytes held", + held); + return 0; +} + +/* musl answers ENOSYS for the policy calls by design, so they are made raw. + * SCHED_FIFO at priority 0 is EINVAL everywhere, the range being checked + * before the privilege; at priority 1 it depends on privilege, and a CPU-1 + * mask on the CPU count, so those two are reported rather than checked: on + * NONOS they are EPERM and EINVAL. */ +static int scheduler(void) { + struct sched_param prio = {0}; + long policy = syscall(SYS_sched_getscheduler, 0); + int zero_fifo = syscall(SYS_sched_setscheduler, 0, SCHED_FIFO, &prio) == -1 && errno == EINVAL; + struct sched_param one = {.sched_priority = 1}; + long fifo = syscall(SYS_sched_setscheduler, 0, SCHED_FIFO, &one) == -1 ? errno : 0; + syscall(SYS_sched_setscheduler, 0, SCHED_OTHER, &prio); + int other = syscall(SYS_sched_setscheduler, 0, SCHED_OTHER, &prio) == 0; + int range = sched_get_priority_max(SCHED_FIFO) == 99 && sched_get_priority_min(SCHED_RR) == 1; + cpu_set_t set; + CPU_ZERO(&set); + CPU_SET(0, &set); + int pinned = sched_setaffinity(0, sizeof set, &set) == 0; + CPU_ZERO(&set); + CPU_SET(1, &set); + long cpu1 = sched_setaffinity(0, sizeof set, &set) == -1 ? errno : 0; + CPU_ZERO(&set); + CPU_SET(0, &set); + sched_setaffinity(0, sizeof set, &set); + int old = epoll_create(1); + int zero = epoll_create(0) == -1 && errno == EINVAL; + struct epoll_event ev; + struct timespec half = {0, 50 * 1000000}; + long t0 = now_ms(); + long n = syscall(SYS_epoll_pwait2, old, &ev, 1, &half, 0, 8); + long waited = now_ms() - t0; + close(old); + int all = policy == SCHED_OTHER && zero_fifo && other && range && pinned && old >= 0 && zero && + n == 0 && waited >= 45; + if (!all) { + return fail("scheduler and epoll forms", + policy * 10000 + other * 1000 + range * 100 + pinned * 10 + zero, + (n == 0) * 1000 + waited); + } + printf("[C] cwait sched detail: SCHED_FIFO at 1 errno %ld, a CPU-1-only mask errno %ld\n", fifo, + cpu1); + ok("sched", "SCHED_OTHER, CPU 0 pinned, epoll_create, epoll_pwait2 waited ms", waited); + return 0; +} + +static volatile sig_atomic_t usr1; +static void on_usr1(int sig) { + (void)sig; + usr1 = 1; +} + +static int thread_kill(void) { + struct sigaction sa; + memset(&sa, 0, sizeof sa); + sa.sa_handler = on_usr1; + sigaction(SIGUSR1, &sa, 0); + long rc = syscall(SYS_tgkill, getpid(), gettid(), SIGUSR1); + long miss = syscall(SYS_tgkill, getpid(), 99999, SIGUSR1) == -1 ? errno : 0; + getpid(); + if (rc != 0 || !usr1 || miss != ESRCH) { + return fail("tgkill to the caller's own thread", rc * 10 + usr1, miss); + } + ok("tgkill", "a caught SIGUSR1 reached the thread getpid and gettid name; a stranger is errno", + miss); + return 0; +} + +/* The raw call, so the answers are the kernel's and not musl's own checks. */ +static long altstack(const stack_t *set, stack_t *old) { + return syscall(SYS_sigaltstack, set, old) == -1 ? -errno : 0; +} + +static char alt[16384] __attribute__((aligned(16))); +static volatile uintptr_t alt_here, alt_saved_rsp, alt_uc_sp; +static volatile long alt_inside_flags = -1, alt_inside_set, alt_uc_flags = -1; + +static int on_alt(uintptr_t p) { + return p > (uintptr_t)alt && p <= (uintptr_t)alt + sizeof alt; +} + +static void on_usr2(int sig, siginfo_t *info, void *ctx) { + (void)sig; + (void)info; + char here; + alt_here = (uintptr_t)&here; + stack_t now, other = {.ss_sp = alt, .ss_size = sizeof alt, .ss_flags = 0}; + alt_inside_flags = altstack(0, &now) == 0 ? now.ss_flags : -1; + alt_inside_set = altstack(&other, 0); + ucontext_t *uc = ctx; + alt_uc_flags = uc->uc_stack.ss_flags; + alt_uc_sp = (uintptr_t)uc->uc_stack.ss_sp; + alt_saved_rsp = (uintptr_t)uc->uc_mcontext.gregs[REG_RSP]; +} + +static int alternate_stack(void) { + stack_t got, small = {.ss_sp = alt, .ss_size = 1024, .ss_flags = 0}; + stack_t bad = {.ss_sp = alt, .ss_size = sizeof alt, .ss_flags = 4}; + stack_t set = {.ss_sp = alt, .ss_size = sizeof alt, .ss_flags = 0}; + stack_t off = {.ss_flags = SS_DISABLE}; + long none = altstack(0, &got) == 0 ? got.ss_flags : -1; + long nomem = altstack(&small, 0), inval = altstack(&bad, 0); + if (none != SS_DISABLE || nomem != -ENOMEM || inval != -EINVAL) { + return fail("sigaltstack before one is set", none * 100 - nomem, inval); + } + long rc = altstack(&set, 0); + long flags = altstack(0, &got) == 0 ? got.ss_flags : -1; + if (rc != 0 || flags != 0 || got.ss_sp != alt || got.ss_size != sizeof alt) { + return fail("sigaltstack set and read back", rc, flags); + } + struct sigaction sa; + memset(&sa, 0, sizeof sa); + sa.sa_sigaction = on_usr2; + sa.sa_flags = SA_SIGINFO | SA_ONSTACK; + sigaction(SIGUSR2, &sa, 0); + syscall(SYS_tgkill, getpid(), gettid(), SIGUSR2); + getpid(); + long off_rc = altstack(&off, 0); + long after = altstack(0, &got) == 0 ? got.ss_flags : -1; + if (!on_alt(alt_here) || on_alt(alt_saved_rsp) || alt_uc_sp != (uintptr_t)alt) { + return fail("SA_ONSTACK handler on the alternate stack", on_alt(alt_here), + on_alt(alt_saved_rsp)); + } + if (alt_inside_flags != SS_ONSTACK || alt_inside_set != -EPERM || alt_uc_flags != 0) { + return fail("sigaltstack inside the handler", alt_inside_flags, alt_inside_set); + } + if (off_rc != 0 || after != SS_DISABLE) { + return fail("sigaltstack disabled", off_rc, after); + } + ok("altstack", + "ENOMEM, EINVAL, set, SA_ONSTACK handler on it with SS_ONSTACK and EPERM inside, " + "disabled; flags inside", + alt_inside_flags); + return 0; +} + +static volatile sig_atomic_t usr1_other; +static volatile int stop_yielding; + +static void on_usr1_other(int sig) { + (void)sig; + usr1_other = 1; +} + +static void *yielder(void *arg) { + (void)arg; + long start = now_ms(); + while (!usr1_other && !stop_yielding && now_ms() - start < 3000) { + sched_yield(); + } + return 0; +} + +/* musl's pthread_kill sends tkill to the tid clone wrote for the parent. */ +static int thread_signal(void) { + struct sigaction sa; + memset(&sa, 0, sizeof sa); + sa.sa_handler = on_usr1_other; + sigaction(SIGUSR1, &sa, 0); + pthread_t t; + pthread_create(&t, 0, yielder, 0); + int rc = pthread_kill(t, SIGUSR1); + stop_yielding = rc != 0; /* a kill that never went need not wait 3 s */ + pthread_join(t, 0); + if (rc != 0 || !usr1_other) { + return fail("pthread_kill to another thread", rc, usr1_other); + } + ok("pthread-kill", "pthread_kill reached another thread by its pthread tid; handled", + usr1_other); + return 0; +} + +int main(void) { + int (*const part[])(void) = { + timed_futex, broadcast, eventfd_semaphore, eventfd_blocking, epoll_timeout, + pipe_nonblock, pipe_full, edge, two_readers, poll_select, + close_forgets, timers, ioctls_fork, scheduler, thread_kill, + alternate_stack, thread_signal, + }; + const int count = sizeof part / sizeof part[0]; + long t0 = now_ms(); + int failed = 0; + // Every part runs, so one run names every part that fails; a hang still + // stops it, at the part it hangs in. + for (int i = 0; i < count; i++) { + failed += part[i](); + } + if (failed) { + printf("[C] cwait FAIL: %d parts failed, %d passed\n", failed, parts); + fflush(stdout); + return 1; + } + printf("[C] cwait PASS: %d parts in %ld ms\n", parts, now_ms() - t0); + fflush(stdout); + return 0; +} 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/c/threadfault.c b/userland/linux_guests/c/threadfault.c new file mode 100644 index 0000000000..839a10bd01 --- /dev/null +++ b/userland/linux_guests/c/threadfault.c @@ -0,0 +1,46 @@ +// A guest whose worker thread takes a real fault the runtime does not catch, +// while main waits. On Linux an unhandled fault in any thread ends the whole +// process; this proves NONOS does the same for a foreign guest, and that a +// clone thread runs its function at all. The worker runs on a stack mapped +// read-write outright, so the proof does not depend on the reserve-then- +// mprotect path a pthread stack uses. If the process were left alive, main +// would finish its wait and print the survived line, which the log must never +// show. +#define _GNU_SOURCE +#include +#include +#include +#include + +static int boom(void *arg) { + (void)arg; + volatile int *p = (volatile int *)0; + *p = 1; // write to the unmapped null page: a fault, not a caught signal + return 0; +} + +int main(void) { + fputs("[C] threadfault: main spawns a worker that will fault\n", stdout); + fflush(stdout); + long sz = 1 << 16; + void *stack = mmap(0, sz, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0); + if (stack == MAP_FAILED) { + fputs("[C] threadfault FAIL: no stack\n", stdout); + fflush(stdout); + return 1; + } + int flags = CLONE_VM | CLONE_THREAD | CLONE_SIGHAND | CLONE_FS | CLONE_FILES; + int tid = clone(boom, (char *)stack + sz, flags, 0); + if (tid < 0) { + fputs("[C] threadfault FAIL: no worker\n", stdout); + fflush(stdout); + return 1; + } + struct timespec ts = { 1, 0 }; + for (int i = 0; i < 30; i++) { + nanosleep(&ts, 0); + } + fputs("[C] threadfault SURVIVED: the process outlived a faulting thread\n", stdout); + fflush(stdout); + return 0; +} diff --git a/userland/linux_guests/go/conc/go.mod b/userland/linux_guests/go/conc/go.mod new file mode 100644 index 0000000000..1add7234e0 --- /dev/null +++ b/userland/linux_guests/go/conc/go.mod @@ -0,0 +1,3 @@ +module nonos/guest/conc + +go 1.24 diff --git a/userland/linux_guests/go/conc/main.go b/userland/linux_guests/go/conc/main.go new file mode 100644 index 0000000000..115034d970 --- /dev/null +++ b/userland/linux_guests/go/conc/main.go @@ -0,0 +1,41 @@ +// Concurrency without the netpoller: goroutines, channels and a wait group, +// which lean on thread creation (clone), futex-based parking and the +// scheduler. The sum is known, so a wrong answer is a broken runtime. +package main + +import ( + "fmt" + "os" + "runtime" + "sync" +) + +func main() { + const workers, each = 8, 10000 + runtime.GOMAXPROCS(4) + out := make(chan int, workers) + var wg sync.WaitGroup + for w := 0; w < workers; w++ { + wg.Add(1) + go func(base int) { + defer wg.Done() + sum := 0 + for i := 0; i < each; i++ { + sum += base + i + } + out <- sum + }(w * each) + } + go func() { wg.Wait(); close(out) }() + total := 0 + for s := range out { + total += s + } + want := (workers*each - 1) * (workers * each) / 2 + if total != want { + fmt.Printf("[GO] conc FAIL: total=%d want=%d\n", total, want) + os.Exit(1) + } + fmt.Printf("[GO] conc PASS: %d goroutines summed %d\n", workers, total) + os.Exit(0) +} diff --git a/userland/linux_guests/go/hello/go.mod b/userland/linux_guests/go/hello/go.mod new file mode 100644 index 0000000000..03ab9263b9 --- /dev/null +++ b/userland/linux_guests/go/hello/go.mod @@ -0,0 +1,3 @@ +module nonos/guest/hello + +go 1.24 diff --git a/userland/linux_guests/go/hello/main.go b/userland/linux_guests/go/hello/main.go new file mode 100644 index 0000000000..4e643f9e1e --- /dev/null +++ b/userland/linux_guests/go/hello/main.go @@ -0,0 +1,24 @@ +// A static Go binary, the cheapest tier-1 guest: no cgo, no dynamic loader. +// It exercises the runtime coming up (threads, memory, signals, GC) and +// prints a line the boot log can check. +package main + +import ( + "fmt" + "os" + "runtime" +) + +func main() { + // Touch the heap enough to force at least one GC cycle. + acc := 0 + for i := 0; i < 200000; i++ { + s := make([]byte, 32) + acc += len(s) + } + runtime.GC() + fmt.Printf("[GO] hello: %s GOMAXPROCS=%d NumCPU=%d touched=%d\n", + runtime.Version(), runtime.GOMAXPROCS(0), runtime.NumCPU(), acc) + fmt.Println("[GO] hello PASS") + os.Exit(0) +} diff --git a/userland/linux_guests/go/poll/go.mod b/userland/linux_guests/go/poll/go.mod new file mode 100644 index 0000000000..dc1f6a5fd5 --- /dev/null +++ b/userland/linux_guests/go/poll/go.mod @@ -0,0 +1,3 @@ +module nonos/guest/poll + +go 1.24 diff --git a/userland/linux_guests/go/poll/main.go b/userland/linux_guests/go/poll/main.go new file mode 100644 index 0000000000..250f9ba2dd --- /dev/null +++ b/userland/linux_guests/go/poll/main.go @@ -0,0 +1,75 @@ +// Go's network poller, which any timer or pollable file starts: epoll_create1, +// an eventfd, and epoll_pwait with a timeout. Three parts, each printed as it +// passes, so a hang names the part it hung in: +// +// - a 50 ms sleep ends on time, which needs epoll_pwait to wait its timeout; +// - a 10 ms timer set while the poller already waits on a 3 s one ends in +// well under 3 s, which needs a write to Go's eventfd from another thread +// to end that wait; +// - a pipe read through the poller gets every write and then end of file, +// which needs O_NONBLOCK kept, edge-triggered readiness, and a hang-up +// once the write end is closed. +package main + +import ( + "fmt" + "io" + "os" + "syscall" + "time" +) + +func fail(format string, args ...any) { + fmt.Printf("[GO] poll FAIL: "+format+"\n", args...) + os.Exit(1) +} + +func main() { + start := time.Now() + time.Sleep(50 * time.Millisecond) + slept := time.Since(start).Milliseconds() + if slept < 50 || slept > 1000 { + fail("a 50ms sleep took %dms", slept) + } + fmt.Printf("[GO] poll sleep ok: 50ms slept %dms\n", slept) + + long := make(chan struct{}) + short := make(chan int64, 1) + go func() { time.Sleep(3 * time.Second); close(long) }() + go func() { + // A raw nanosleep keeps this goroutine away from the poller while the + // long sleep puts the poller into its 3 s wait. + ts := syscall.Timespec{Nsec: 200 * 1000 * 1000} + syscall.Nanosleep(&ts, nil) + t0 := time.Now() + time.Sleep(10 * time.Millisecond) + short <- time.Since(t0).Milliseconds() + }() + quick := <-short + <-long + if quick > 1000 { + fail("a 10ms timer set under a 3s one took %dms", quick) + } + fmt.Printf("[GO] poll wake ok: a 10ms timer set under a 3s one took %dms\n", quick) + + r, w, err := os.Pipe() + if err != nil { + fail("pipe: %v", err) + } + go func() { + for i := 0; i < 3; i++ { + time.Sleep(100 * time.Millisecond) + fmt.Fprintf(w, "m%d;", i) + } + w.Close() + }() + t0 := time.Now() + got, err := io.ReadAll(r) + took := time.Since(t0).Milliseconds() + if err != nil || string(got) != "m0;m1;m2;" { + fail("pipe read %q, err %v", got, err) + } + fmt.Printf("[GO] poll pipe ok: read %q to end of file in %dms\n", got, took) + fmt.Printf("[GO] poll PASS: 3 parts, %dms in all\n", time.Since(start).Milliseconds()) + os.Exit(0) +} diff --git a/userland/linux_guests/go/preempt/go.mod b/userland/linux_guests/go/preempt/go.mod new file mode 100644 index 0000000000..dccb53acf9 --- /dev/null +++ b/userland/linux_guests/go/preempt/go.mod @@ -0,0 +1,3 @@ +module nonos/guest/preempt + +go 1.24 diff --git a/userland/linux_guests/go/preempt/main.go b/userland/linux_guests/go/preempt/main.go new file mode 100644 index 0000000000..728762e7c1 --- /dev/null +++ b/userland/linux_guests/go/preempt/main.go @@ -0,0 +1,34 @@ +// A goroutine that spins without a call can be stopped only by a signal: +// Go's sysmon sends SIGURG to its thread once it has run 10 ms, and the +// handler moves it off the CPU. With one P nothing else runs until then, so +// main sleeping 20 ms and then collecting garbage, which stops the world, +// both need that signal to land on a thread that is running, not parked. +package main + +import ( + "fmt" + "os" + "runtime" + "time" +) + +var spun uint64 + +func spin() { + for { + spun++ + } +} + +func main() { + runtime.GOMAXPROCS(1) + start := time.Now() + go spin() + time.Sleep(20 * time.Millisecond) + woke := time.Since(start).Milliseconds() + runtime.GC() + collected := time.Since(start).Milliseconds() + fmt.Printf("[GO] preempt PASS: main ran again after %dms and collected by %dms beside a spinning goroutine\n", + woke, collected) + os.Exit(0) +} 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..6afb13d63b --- /dev/null +++ b/userland/linux_guests/src/bin/bounds.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 asking for more than any plan should give it. + +use std::process::ExitCode; + +use nonos_linux_guests::bounds_probe; +use nonos_linux_guests::report::Report; + +fn main() -> ExitCode { + let mut r = Report::new("bounds"); + bounds_probe::scan(&mut r); + r.finish() +} 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/holder.rs b/userland/linux_guests/src/bin/holder.rs new file mode 100644 index 0000000000..d4cf11cee6 --- /dev/null +++ b/userland/linux_guests/src/bin/holder.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 . + +//! 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()]); + } + 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]; + 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..2a422efded --- /dev/null +++ b/userland/linux_guests/src/bin/native.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 reaching for the NØNOS native ABI. + +use std::process::ExitCode; + +use nonos_linux_guests::native_probe; +use nonos_linux_guests::report::Report; + +fn main() -> ExitCode { + let mut r = Report::new("native"); + native_probe::scan(&mut r); + 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..a3385c5966 --- /dev/null +++ b/userland/linux_guests/src/bin/reader.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 . + +//! 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::{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); + 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); + match r.finish() { + _ if clock_broken => ExitCode::FAILURE, + verdict => verdict, + } +} diff --git a/userland/linux_guests/src/bin/signal.rs b/userland/linux_guests/src/bin/signal.rs new file mode 100644 index 0000000000..b44cb3800a --- /dev/null +++ b/userland/linux_guests/src/bin/signal.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 . + +//! Prove signal delivery end to end: a handler installed, a signal raised at +//! this thread, the handler run and returned from. Exits 0 only on delivery. + +use std::process::ExitCode; + +use nonos_linux_guests::signal_probe; + +fn main() -> ExitCode { + match signal_probe::scan() { + true => ExitCode::SUCCESS, + false => ExitCode::FAILURE, + } +} diff --git a/userland/linux_guests/src/bin/suite.rs b/userland/linux_guests/src/bin/suite.rs new file mode 100644 index 0000000000..c33351ee3f --- /dev/null +++ b/userland/linux_guests/src/bin/suite.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 . + +//! 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, dyn_probe, exec_probe, fp_probe, fs_probe, life_probe, native_probe, proc_probe, + sep_probe, state_probe, +}; + +fn main() -> ExitCode { + let mut broken = false; + for (guest, scan) in [ + ("fingerprint", fp_probe::scan as fn(&mut Report)), + ("native", native_probe::scan), + ("bounds", bounds_probe::scan), + ("fs", fs_probe::scan), + ("proc", proc_self), + ("separation", sep_probe::scan), + ("exec", exec_probe::scan), + ("dynamic", dyn_probe::scan), + ("state", state_probe::scan), + ] { + let mut r = Report::new(guest); + 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)) +} + +/// /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/bin/window.rs b/userland/linux_guests/src/bin/window.rs new file mode 100644 index 0000000000..831a587cd0 --- /dev/null +++ b/userland/linux_guests/src/bin/window.rs @@ -0,0 +1,26 @@ +// 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 program with a window: the first real Wayland client on NØNOS. +//! +//! It opens a toplevel, draws into shared memory and keeps the window up. +//! Unlike the other guests it attacks nothing; it proves the path works. + +fn main() { + if !nonos_linux_guests::wl::window::run() { + std::process::exit(1); + } +} diff --git a/userland/linux_guests/src/bounds_probe.rs b/userland/linux_guests/src/bounds_probe.rs new file mode 100644 index 0000000000..9e3c985737 --- /dev/null +++ b/userland/linux_guests/src/bounds_probe.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 . + +//! 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, 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. +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", 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", 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. + 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 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 { + rc if (-4095..0).contains(&rc) => Seen::Refused(rc), + rc => Seen::Escaped(format!("{what} at {rc:#x}")), + } +} diff --git a/userland/linux_guests/src/child_wait.rs b/userland/linux_guests/src/child_wait.rs new file mode 100644 index 0000000000..cda803d3e9 --- /dev/null +++ b/userland/linux_guests/src/child_wait.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 . + +//! Waiting for a forked child, bounded, so a child that never runs is +//! reported instead of hanging the probe. + +use crate::sys::{call, NANOSLEEP}; + +const WAIT4: u64 = 61; +const WNOHANG: u64 = 1; + +// The child's exit status, or None if it did not finish within ten seconds. +pub fn wait(child: i64) -> Option { + let mut status = 0i32; + for _ in 0..100 { + let rc = call(WAIT4, [child as u64, &mut status as *mut i32 as u64, WNOHANG, 0, 0, 0]); + if rc == child { + return Some(((status >> 8) & 0xff) as u32); + } + let tenth = [0u64, 100_000_000]; + let _ = call(NANOSLEEP, [tenth.as_ptr() as u64, 0, 0, 0, 0, 0]); + } + None +} 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/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/exec_child.rs b/userland/linux_guests/src/exec_child.rs new file mode 100644 index 0000000000..4b8a5c87f5 --- /dev/null +++ b/userland/linux_guests/src/exec_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 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; +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 +/// 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]); + // 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/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/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/fs_links.rs b/userland/linux_guests/src/fs_links.rs new file mode 100644 index 0000000000..e3b7942935 --- /dev/null +++ b/userland/linux_guests/src/fs_links.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 . + +//! Leaving the tree through a symbolic link. + +use crate::fs_paths::opened; +use crate::fs_probe::{p, OUTSIDE, O_RDONLY}; +use crate::report::Report; +use crate::sys::{call, OPEN, SYMLINK}; + +/* + * A link may be made; what must not happen is reaching the outside file + * through one. One points at the root and one above it, and the file is + * opened through each: the second resolves where the first would. + */ +pub fn through_links(r: &mut Report, up: &str) { + let _ = call(SYMLINK, [p("/\0"), p("/tmp/root\0"), 0, 0, 0, 0]); + let _ = call(SYMLINK, [p(&format!("/{up}\0")), p("/tmp/above\0"), 0, 0, 0, 0]); + for via in [format!("/tmp/root/{up}{OUTSIDE}\0"), format!("/tmp/above/{OUTSIDE}\0")] { + let rc = call(OPEN, [p(&via), O_RDONLY, 0, 0, 0, 0]); + r.check("open through a symlink out of the root", opened(rc)); + } +} 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..c94e838573 --- /dev/null +++ b/userland/linux_guests/src/fs_probe.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 . + +//! 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}; + +/// 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)); + crate::fs_links::through_links(r, &up); + 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 new file mode 100644 index 0000000000..efbeb9cf53 --- /dev/null +++ b/userland/linux_guests/src/lib.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 . + +//! Shared pieces of the hostile guests. + +pub mod arg; +pub mod child_wait; +pub mod bounds_probe; +pub mod clock_probe; +pub mod signal_probe; +pub mod dyn_probe; +pub mod exec_child; +pub mod exec_probe; +pub mod fs_links; +pub mod fs_paths; +pub mod fp_probe; +pub mod fs_probe; +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 shared_name; +pub mod state_probe; +pub mod state_regs; +pub mod state_regs_sse; +pub mod sys; +pub mod vm_probe; +pub mod wl; 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()); +} 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/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/sep_child.rs b/userland/linux_guests/src/sep_child.rs new file mode 100644 index 0000000000..4622d4fe35 --- /dev/null +++ b/userland/linux_guests/src/sep_child.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 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 { + // 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 | 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 new file mode 100644 index 0000000000..c0d45e522b --- /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 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 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()), + }; + 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/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); + } +} diff --git a/userland/linux_guests/src/signal_probe.rs b/userland/linux_guests/src/signal_probe.rs new file mode 100644 index 0000000000..9bedf79696 --- /dev/null +++ b/userland/linux_guests/src/signal_probe.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 . + +//! Signal delivery, from the guest's side: install a handler with its own +//! restorer, raise a signal at this thread, and see the handler run and +//! return. This is the one probe that wants success, not a refusal: it is +//! how a program's timeouts, its Ctrl+C, and a Go runtime's preemption work. + +use core::sync::atomic::{AtomicU32, Ordering}; + +use crate::sys::{call, out, GETPID}; + +const RT_SIGACTION: u64 = 13; +const KILL: u64 = 62; +const SIGUSR1: u64 = 10; +const SA_RESTORER: u64 = 0x0400_0000; +const SA_RESTART: u64 = 0x1000_0000; + +static RAN: AtomicU32 = AtomicU32::new(0); + +// The restorer a handler returns through: rt_sigreturn, nothing else. +core::arch::global_asm!(".globl nonos_sigrestore", "nonos_sigrestore:", "mov rax, 15", "syscall"); +extern "C" { + fn nonos_sigrestore(); +} + +extern "C" fn handler(_sig: i32) { + RAN.store(1, Ordering::SeqCst); + // A handler making a syscall traps and is answered like any other: it must + // return to the handler, not somewhere else. + out(b"[GUEST] signal handler ran\n"); +} + +/// True when the handler ran and control returned past the raise. +pub fn scan() -> bool { + let act: [u64; 4] = [ + handler as *const () as u64, + SA_RESTORER | SA_RESTART, + nonos_sigrestore as *const () as u64, + 0, + ]; + if call(RT_SIGACTION, [SIGUSR1, act.as_ptr() as u64, 0, 8, 0, 0]) < 0 { + out(b"[GUEST] signal FAIL: rt_sigaction refused\n"); + return false; + } + let pid = call(GETPID, [0; 6]) as u64; + if call(KILL, [pid, SIGUSR1, 0, 0, 0, 0]) < 0 { + out(b"[GUEST] signal FAIL: raise refused\n"); + return false; + } + let ran = RAN.load(Ordering::SeqCst) == 1; + out(match ran { + true => b"[GUEST] signal PASS: handler ran and returned\n".as_slice(), + false => b"[GUEST] signal FAIL: handler did not run\n".as_slice(), + }); + ran +} diff --git a/userland/linux_guests/src/state_probe.rs b/userland/linux_guests/src/state_probe.rs new file mode 100644 index 0000000000..ebce326ae8 --- /dev/null +++ b/userland/linux_guests/src/state_probe.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 . + +//! Whether a thread's vector registers survive the switches the kernel makes +//! while it waits. Not an isolation attempt, a correctness one: two processes +//! each fill sixteen registers with their own byte and yield to each other. +//! A register that comes back holding the sibling's byte is a switch that did +//! not save it, and it is reported the way an escape is. + +use crate::child_wait::wait; +use crate::report::{Report, Seen}; +use crate::state_regs::{avx_usable, round_ymm}; +use crate::state_regs_sse::round_xmm; +use crate::sys::call; + +const FORK: u64 = 57; +const EXIT_GROUP: u64 = 231; +const ROUNDS: u32 = 300; + +pub fn scan(r: &mut Report) { + let avx = avx_usable(); + let what = if avx { "a sibling's bytes in ymm0-15" } else { "a sibling's bytes in xmm0-15" }; + let child = call(FORK, [0; 6]); + if child == 0 { + let lost = rounds(0xC3, avx).min(254); + let _ = call(EXIT_GROUP, [lost as u64, 0, 0, 0, 0, 0]); + } + if child < 0 { + r.check(what, Seen::Escaped(format!("fork failed, errno {}", -child))); + return; + } + let mine = rounds(0x3C, avx); + let theirs = wait(child); + match (mine, theirs) { + (0, Some(0)) => r.check(what, Seen::Refused(0)), + (m, t) => r.check(what, Seen::Escaped(format!("parent {m} of {ROUNDS}, child {t:?}"))), + } +} + +// Rounds whose read-back was not the byte this process wrote. +fn rounds(byte: u8, avx: bool) -> u32 { + let pattern = [byte; 32]; + let width = if avx { 512 } else { 256 }; + (0..ROUNDS) + .filter(|_| { + let mut out = [0u8; 512]; + match avx { + // SAFETY: `avx_usable` confirmed AVX and its XCR0 state. + true => unsafe { round_ymm(&pattern, &mut out) }, + false => round_xmm(&pattern, &mut out), + } + out[..width].iter().any(|b| *b != byte) + }) + .count() as u32 +} diff --git a/userland/linux_guests/src/state_regs.rs b/userland/linux_guests/src/state_regs.rs new file mode 100644 index 0000000000..7ccb5bf16c --- /dev/null +++ b/userland/linux_guests/src/state_regs.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 . + +//! One round: fill the sixteen vector registers, make a syscall that lets +//! another process run, read them back. All in one asm block, so nothing but +//! the kernel touches them between the fill and the read. + +use core::arch::asm; + +pub const SCHED_YIELD: u64 = 24; + +/// CPUID says AVX and OSXSAVE, and XCR0 has SSE and AVX state on. +pub fn avx_usable() -> bool { + let ecx = core::arch::x86_64::__cpuid(1).ecx; + if ecx & (1 << 27) == 0 || ecx & (1 << 28) == 0 { + return false; + } + let (lo, _hi): (u32, u32); + // SAFETY: OSXSAVE is set, checked above, so XGETBV is enabled. + unsafe { asm!("xgetbv", in("ecx") 0u32, out("eax") lo, out("edx") _hi, options(nostack)) }; + lo & 0x6 == 0x6 +} + +/// 16 registers of 32 bytes each, back from ymm0..ymm15. +#[target_feature(enable = "avx")] +pub unsafe fn round_ymm(pattern: &[u8; 32], out: &mut [u8; 512]) { + // SAFETY: `pattern` is 32 readable bytes, `out` 512 writable ones; r12 + // and r13 survive the syscall, which only clobbers rax, rcx and r11. + unsafe { + asm!( + ".irp r,0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15", + "vmovdqu ymm\\r, [r13]", + ".endr", + "mov eax, {nr}", + "syscall", + ".irp r,0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15", + "vmovdqu [r12], ymm\\r", + "add r12, 32", + ".endr", + nr = const SCHED_YIELD, + inout("r12") out.as_mut_ptr() => _, + in("r13") pattern.as_ptr(), + clobber_abi("C"), + options(nostack), + ); + } +} diff --git a/userland/linux_guests/src/state_regs_sse.rs b/userland/linux_guests/src/state_regs_sse.rs new file mode 100644 index 0000000000..0f88338cb5 --- /dev/null +++ b/userland/linux_guests/src/state_regs_sse.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 SSE-only round, for a part or an XCR0 without AVX state. + +use core::arch::asm; + +use super::state_regs::SCHED_YIELD; + +/// The SSE-only version: xmm0..xmm15, 16 bytes each. +pub fn round_xmm(pattern: &[u8; 32], out: &mut [u8; 512]) { + // SAFETY: `pattern` and `out` are as in `round_ymm`; r12 and r13 survive + // the syscall. Only the first 256 bytes of `out` are written. + unsafe { + asm!( + ".irp r,0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15", + "movdqu xmm\\r, [r13]", + ".endr", + "mov eax, {nr}", + "syscall", + ".irp r,0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15", + "movdqu [r12], xmm\\r", + "add r12, 16", + ".endr", + nr = const SCHED_YIELD, + inout("r12") out.as_mut_ptr() => _, + in("r13") pattern.as_ptr(), + clobber_abi("C"), + options(nostack), + ); + } +} diff --git a/userland/linux_guests/src/sys.rs b/userland/linux_guests/src/sys.rs new file mode 100644 index 0000000000..cb78bf9021 --- /dev/null +++ b/userland/linux_guests/src/sys.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 . + +//! 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 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; + +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..437babf06c --- /dev/null +++ b/userland/linux_guests/src/vm_probe.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 . + +//! 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}; + +/// 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 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. + 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) +} diff --git a/userland/linux_guests/src/wl/conn.rs b/userland/linux_guests/src/wl/conn.rs new file mode 100644 index 0000000000..669726969a --- /dev/null +++ b/userland/linux_guests/src/wl/conn.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 . + +//! The display socket: connect, send, send with a descriptor, receive. + +use crate::sys::{call, READ, WRITE}; + +const SOCKET: u64 = 41; +const CONNECT: u64 = 42; +const SENDMSG: u64 = 46; +const AF_UNIX: u64 = 1; +const SOCK_STREAM: u64 = 1; + +pub struct Conn { + fd: u64, + rx: Vec, +} + +impl Conn { + pub fn connect(path: &[u8]) -> Option { + let fd = call(SOCKET, [AF_UNIX, SOCK_STREAM, 0, 0, 0, 0]); + let mut addr = [0u8; 110]; + addr[..2].copy_from_slice(&(AF_UNIX as u16).to_le_bytes()); + addr[2..2 + path.len()].copy_from_slice(path); + let rc = call(CONNECT, [fd as u64, addr.as_ptr() as u64, 110, 0, 0, 0]); + (fd >= 0 && rc == 0).then(|| Conn { fd: fd as u64, rx: Vec::new() }) + } + + pub fn send(&self, bytes: &[u8]) -> bool { + call(WRITE, [self.fd, bytes.as_ptr() as u64, bytes.len() as u64, 0, 0, 0]) as usize + == bytes.len() + } + + // SCM_RIGHTS: cmsg_len 20 (header 16 + one int), level and type 1, padded to 24. + pub fn send_fd(&self, bytes: &[u8], fd: i32) -> bool { + let iov = [bytes.as_ptr() as u64, bytes.len() as u64]; + let mut cmsg = [0u8; 24]; + cmsg[..8].copy_from_slice(&20u64.to_le_bytes()); + cmsg[8..12].copy_from_slice(&1i32.to_le_bytes()); + cmsg[12..16].copy_from_slice(&1i32.to_le_bytes()); + cmsg[16..20].copy_from_slice(&fd.to_le_bytes()); + let hdr = [0u64, 0, iov.as_ptr() as u64, 1, cmsg.as_ptr() as u64, 24, 0]; + call(SENDMSG, [self.fd, hdr.as_ptr() as u64, 0, 0, 0, 0]) as usize == bytes.len() + } + + /// The next whole event, reading more when the buffer holds only part. + pub fn event(&mut self) -> Option<(u32, u16, Vec)> { + loop { + if let Some((object, opcode, body, size)) = super::msg::split(&self.rx) { + let out = (object, opcode, body.to_vec()); + self.rx.drain(..size); + return Some(out); + } + let mut buf = [0u8; 4096]; + let n = call(READ, [self.fd, buf.as_mut_ptr() as u64, 4096, 0, 0, 0]); + if n <= 0 { + return None; + } + self.rx.extend_from_slice(&buf[..n as usize]); + } + } +} diff --git a/userland/linux_guests/src/wl/draw.rs b/userland/linux_guests/src/wl/draw.rs new file mode 100644 index 0000000000..0a132ff72f --- /dev/null +++ b/userland/linux_guests/src/wl/draw.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 . + +//! What the window shows: ink, a cyan frame and band, and a ring. + +const INK: u32 = 0xFF0A_0B0D; +const CYAN: u32 = 0xFF66_FFFF; +const TEAL: u32 = 0xFF2E_5C5C; + +pub fn paint(px: &mut [u32], w: usize, h: usize) { + let (cx, cy, r) = (w as i64 / 2, h as i64 / 2 + 16, (h.min(w) / 4) as i64); + for y in 0..h { + for x in 0..w { + let edge = x < 3 || y < 3 || x >= w - 3 || y >= h - 3; + let band = y < 40; + let (dx, dy) = (x as i64 - cx, y as i64 - cy); + let d2 = dx * dx + dy * dy; + let ring = d2 <= r * r && d2 >= (r - 10) * (r - 10); + // The slash of the Ø, a band along the diagonal inside the ring. + let slash = d2 <= r * r && (dx + dy).abs() <= 6; + px[y * w + x] = if edge || ring || slash { + CYAN + } else if band { + TEAL + } else { + INK + }; + } + } +} diff --git a/userland/linux_guests/src/wl/ids.rs b/userland/linux_guests/src/wl/ids.rs new file mode 100644 index 0000000000..860eebffda --- /dev/null +++ b/userland/linux_guests/src/wl/ids.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 . + +//! The object ids this client allocates, in the order it creates them. +//! wl_display is 1 by the protocol. + +pub const REG: u32 = 2; +pub const SYNC: u32 = 3; +pub const COMP: u32 = 4; +pub const SHM: u32 = 5; +pub const XDG: u32 = 6; +pub const SURF: u32 = 7; +pub const XSURF: u32 = 8; +pub const TOP: u32 = 9; +pub const POOL: u32 = 10; +pub const BUF: u32 = 11; diff --git a/userland/linux_guests/src/wl/mod.rs b/userland/linux_guests/src/wl/mod.rs new file mode 100644 index 0000000000..036806c878 --- /dev/null +++ b/userland/linux_guests/src/wl/mod.rs @@ -0,0 +1,26 @@ +// 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 Wayland client in raw syscalls and wire bytes, no libwayland: it speaks +//! exactly what a libwayland client puts on the socket. + +pub mod conn; +pub mod draw; +pub mod ids; +pub mod msg; +pub mod pixels; +pub mod window; +pub mod window_globals; diff --git a/userland/linux_guests/src/wl/msg.rs b/userland/linux_guests/src/wl/msg.rs new file mode 100644 index 0000000000..367f730e27 --- /dev/null +++ b/userland/linux_guests/src/wl/msg.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 request: object id, then size and opcode in one word, then the args. + +pub struct Msg { + buf: Vec, + opcode: u16, +} + +impl Msg { + pub fn new(object: u32, opcode: u16) -> Msg { + let mut buf = Vec::with_capacity(64); + buf.extend_from_slice(&object.to_le_bytes()); + buf.extend_from_slice(&[0; 4]); + Msg { buf, opcode } + } + + pub fn u32(mut self, v: u32) -> Msg { + self.buf.extend_from_slice(&v.to_le_bytes()); + self + } + + // Length with the terminator, the bytes, the NUL, padding to a word. + pub fn string(mut self, s: &[u8]) -> Msg { + self.buf.extend_from_slice(&(s.len() as u32 + 1).to_le_bytes()); + self.buf.extend_from_slice(s); + self.buf.push(0); + while self.buf.len() % 4 != 0 { + self.buf.push(0); + } + self + } + + pub fn bytes(mut self) -> Vec { + let word = ((self.buf.len() as u32) << 16) | self.opcode as u32; + self.buf[4..8].copy_from_slice(&word.to_le_bytes()); + self.buf + } +} + +/// An event's header: the object it is for, its opcode, and its body. +pub fn split(rx: &[u8]) -> Option<(u32, u16, &[u8], usize)> { + let head = rx.get(..8)?; + let object = u32::from_le_bytes([head[0], head[1], head[2], head[3]]); + let word = u32::from_le_bytes([head[4], head[5], head[6], head[7]]); + let size = (word >> 16) as usize; + if size < 8 { + return None; + } + let body = rx.get(8..size)?; + Some((object, word as u16, body, size)) +} + +pub fn word(body: &[u8], at: usize) -> u32 { + body.get(at..at + 4).map_or(0, |w| u32::from_le_bytes([w[0], w[1], w[2], w[3]])) +} diff --git a/userland/linux_guests/src/wl/pixels.rs b/userland/linux_guests/src/wl/pixels.rs new file mode 100644 index 0000000000..e558ba6c9b --- /dev/null +++ b/userland/linux_guests/src/wl/pixels.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 window's pixels: a memfd sized, mapped shared and painted. + +use super::draw::paint; +use crate::sys::{call, MMAP, PROT_RW}; + +// A memfd sized and mapped shared, painted, and handed back as the fd. +pub fn pixels(w: u32, h: u32) -> Option { + let fd = call(319, [b"window\0".as_ptr() as u64, 0, 0, 0, 0, 0]); + let size = (w * h * 4) as u64; + if fd < 0 || call(77, [fd as u64, size, 0, 0, 0, 0]) != 0 { + return None; + } + let at = call(MMAP, [0, size, PROT_RW, 0x01, fd as u64, 0]); + if at < 0 { + return None; + } + // SAFETY: `at` is a fresh shared mapping of `size` bytes, u32-aligned. + let px = unsafe { core::slice::from_raw_parts_mut(at as *mut u32, (w * h) as usize) }; + paint(px, w as usize, h as usize); + Some(fd as i32) +} diff --git a/userland/linux_guests/src/wl/window.rs b/userland/linux_guests/src/wl/window.rs new file mode 100644 index 0000000000..519ddc4c67 --- /dev/null +++ b/userland/linux_guests/src/wl/window.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 . + +//! Open one window, draw it, and keep it up while answering the compositor. + +use super::conn::Conn; +use super::ids::{BUF, COMP, POOL, REG, SHM, SURF, SYNC, TOP, XDG, XSURF}; +use super::msg::{word, Msg}; +use crate::sys::out; + +const W: u32 = 480; +const H: u32 = 320; + +pub fn run() -> bool { + let Some(mut c) = Conn::connect(b"/run/wayland-0") else { + out(b"[GUEST] window: no display socket\n"); + return false; + }; + c.send(&Msg::new(1, 1).u32(REG).bytes()); + c.send(&Msg::new(1, 0).u32(SYNC).bytes()); + let names = super::window_globals::read(&mut c, REG, SYNC); + let Some([comp, shm, xdg]) = names else { + out(b"[GUEST] window: registry lacks compositor, shm or xdg_wm_base\n"); + return false; + }; + let bind = |name, iface: &[u8], v, id| Msg::new(REG, 0).u32(name).string(iface).u32(v).u32(id); + c.send(&bind(comp, b"wl_compositor", 4, COMP).bytes()); + c.send(&bind(shm, b"wl_shm", 1, SHM).bytes()); + c.send(&bind(xdg, b"xdg_wm_base", 2, XDG).bytes()); + c.send(&Msg::new(COMP, 0).u32(SURF).bytes()); + c.send(&Msg::new(XDG, 2).u32(XSURF).u32(SURF).bytes()); + c.send(&Msg::new(XSURF, 1).u32(TOP).bytes()); + c.send(&Msg::new(TOP, 2).string("Hello from Linux on NØNOS".as_bytes()).bytes()); + c.send(&Msg::new(TOP, 3).string(b"nonos.window").bytes()); + c.send(&Msg::new(SURF, 6).bytes()); + if !super::window_globals::configured(&mut c, XDG, XSURF) { + out(b"[GUEST] window: never configured\n"); + return false; + } + let Some(fd) = super::pixels::pixels(W, H) else { + out(b"[GUEST] window: no shared memory\n"); + return false; + }; + let size = W * H * 4; + c.send_fd(&Msg::new(SHM, 0).u32(POOL).u32(size).bytes(), fd); + c.send(&Msg::new(POOL, 0).u32(BUF).u32(0).u32(W).u32(H).u32(W * 4).u32(1).bytes()); + c.send(&Msg::new(SURF, 1).u32(BUF).u32(0).u32(0).bytes()); + c.send(&Msg::new(SURF, 2).u32(0).u32(0).u32(W).u32(H).bytes()); + c.send(&Msg::new(SURF, 6).bytes()); + out(b"[GUEST] window: drawn and committed, 480x320\n"); + // Stay up: answer every ping so the compositor does not call it hung. + while let Some((object, opcode, body)) = c.event() { + if object == XDG && opcode == 0 { + c.send(&Msg::new(XDG, 3).u32(word(&body, 0)).bytes()); + } + } + true +} diff --git a/userland/linux_guests/src/wl/window_globals.rs b/userland/linux_guests/src/wl/window_globals.rs new file mode 100644 index 0000000000..389786bf5f --- /dev/null +++ b/userland/linux_guests/src/wl/window_globals.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 two waits a window makes: for the globals, and for its first configure. + +use super::conn::Conn; +use super::msg::{word, Msg}; + +/// The registry's compositor, shm and xdg_wm_base names, bound by interface +/// name as a real client does, collected until the sync callback fires. +pub fn read(c: &mut Conn, reg: u32, sync: u32) -> Option<[u32; 3]> { + let mut found = [0u32; 3]; + while let Some((object, opcode, body)) = c.event() { + if object == sync && opcode == 0 { + break; + } + if object != reg || opcode != 0 { + continue; + } + let name = word(&body, 0); + let len = word(&body, 4) as usize; + let iface = body.get(8..8 + len.saturating_sub(1)).unwrap_or(&[]); + let slot = + [&b"wl_compositor"[..], b"wl_shm", b"xdg_wm_base"].iter().position(|i| *i == iface); + if let Some(i) = slot { + found[i] = name; + } + } + found.iter().all(|n| *n != 0).then_some(found) +} + +/// Answer pings until the xdg_surface's first configure, then ack it. +pub fn configured(c: &mut Conn, xdg: u32, xsurf: u32) -> bool { + while let Some((object, opcode, body)) = c.event() { + if object == xdg && opcode == 0 { + c.send(&Msg::new(xdg, 3).u32(word(&body, 0)).bytes()); + } else if object == xsurf && opcode == 0 { + c.send(&Msg::new(xsurf, 4).u32(word(&body, 0)).bytes()); + return true; + } + } + false +} 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..9c82b5ad09 --- /dev/null +++ b/userland/market_proofs/src/lib.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 market capsule's readiness gate, included and exercised. + +extern crate alloc; + +pub mod install_ready; + +#[path = "../../capsule_market/src/server/handlers/install_ready/find_release.rs"] +pub mod find_release; + +#[cfg(test)] +mod readiness_tests; + +#[cfg(test)] +mod release_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..989c016e38 --- /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; + +pub(crate) 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); +} diff --git a/userland/market_proofs/src/release_tests.rs b/userland/market_proofs/src/release_tests.rs new file mode 100644 index 0000000000..433d341735 --- /dev/null +++ b/userland/market_proofs/src/release_tests.rs @@ -0,0 +1,78 @@ +// 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 . + +//! Readiness and the release lookup resolve a request to the same release. +//! An empty id is the listing's default, its first release, in both: when +//! only readiness knew that, init was told a package was ready and then +//! found no release to pin, and refused every store install. + +use alloc::string::String; +use alloc::vec; + +use nonos_marketplace_abi::{MarketplaceEntry, MarketplaceIndex, PriceKind, PriceModel, TokenInfo}; + +use crate::find_release::find_release; +use crate::readiness_tests::release; + +fn index() -> MarketplaceIndex { + let (mut first, mut second) = (release("x86_64-linux", 0), release("x86_64-linux", 0)); + first.release_id = String::from("jq@1.7.1-r0"); + second.release_id = String::from("jq@1.6-r4"); + let entry = MarketplaceEntry { + listing_id: String::from("linux.jq"), + capsule_id: [0; 32], + name: String::from("jq"), + publisher_name: String::from("Alpine v3.20"), + publisher_pubkey: [0; 32], + publisher_eth_address: [0; 20], + description: String::new(), + price: PriceModel { kind: PriceKind::Free, amount_atomic: 0, period_seconds: 0 }, + token: TokenInfo { + symbol: String::new(), + decimals: 18, + chain_id: 1, + contract_address: vec![], + }, + releases: vec![first, second], + }; + MarketplaceIndex { + schema_version: 2, + operator_id: String::from("nonos.marketplace.v1"), + operator_pubkey: [0; 32], + published_at_ms: 0, + serial: 1, + entries: vec![entry], + index_signature: vec![], + } +} + +#[test] +fn an_empty_release_id_is_the_first_release() { + let got = find_release(&index(), "linux.jq", "").map(|(_, i, r)| (i, r.release_id.clone())); + assert_eq!(got, Some((0, String::from("jq@1.7.1-r0")))); +} + +#[test] +fn a_named_release_is_that_release() { + let got = find_release(&index(), "linux.jq", "jq@1.6-r4").map(|(_, i, _)| i); + assert_eq!(got, Some(1)); +} + +#[test] +fn an_unknown_listing_or_release_is_none() { + assert!(find_release(&index(), "linux.nope", "").is_none()); + assert!(find_release(&index(), "linux.jq", "jq@0").is_none()); +} 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/nonos_hash/src/lib.rs b/userland/nonos_hash/src/lib.rs index 8f507db397..4779659a40 100644 --- a/userland/nonos_hash/src/lib.rs +++ b/userland/nonos_hash/src/lib.rs @@ -21,10 +21,12 @@ mod hmac512; mod sha256; +mod sha256_stream; mod sha512; mod wipe; pub use hmac512::{hmac_sha512, HmacSha512}; pub use sha256::sha256; +pub use sha256_stream::Sha256; pub use sha512::{sha512, Sha512}; pub use wipe::wipe; diff --git a/userland/nonos_hash/src/sha256.rs b/userland/nonos_hash/src/sha256.rs index ebe48cfa55..4f0e5b6ad2 100644 --- a/userland/nonos_hash/src/sha256.rs +++ b/userland/nonos_hash/src/sha256.rs @@ -29,11 +29,12 @@ const K: [u32; 64] = [ 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2, ]; +pub(crate) const IV: [u32; 8] = [ + 0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19, +]; + pub fn sha256(data: &[u8]) -> [u8; 32] { - let mut h: [u32; 8] = [ - 0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, - 0x5be0cd19, - ]; + let mut h = IV; let mut full = data.chunks_exact(64); for chunk in full.by_ref() { @@ -66,7 +67,7 @@ pub fn sha256(data: &[u8]) -> [u8; 32] { out } -fn compress(h: &mut [u32; 8], block: &[u8; 64]) { +pub(crate) fn compress(h: &mut [u32; 8], block: &[u8; 64]) { let mut w = [0u32; 64]; for (word, chunk) in w.iter_mut().zip(block.chunks_exact(4)) { *word = u32::from_be_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]); diff --git a/userland/nonos_hash/src/sha256_stream.rs b/userland/nonos_hash/src/sha256_stream.rs new file mode 100644 index 0000000000..59b56dbd78 --- /dev/null +++ b/userland/nonos_hash/src/sha256_stream.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 . + +//! SHA-256 over input that arrives in pieces: a signature's digest covers the +//! signed file and then its own trailer, and copying a package to append a +//! few bytes is not worth the memory. Same compression as the one-shot form. + +use super::sha256::{compress, IV}; + +pub struct Sha256 { + h: [u32; 8], + buf: [u8; 64], + used: usize, + len: u64, +} + +impl Sha256 { + pub fn new() -> Self { + Sha256 { h: IV, buf: [0; 64], used: 0, len: 0 } + } + + pub fn update(&mut self, mut input: &[u8]) { + self.len = self.len.wrapping_add(input.len() as u64); + while !input.is_empty() { + let take = (64 - self.used).min(input.len()); + self.buf[self.used..self.used + take].copy_from_slice(&input[..take]); + self.used += take; + input = &input[take..]; + if self.used == 64 { + compress(&mut self.h, &self.buf); + self.used = 0; + } + } + } + + pub fn finalize(mut self) -> [u8; 32] { + let bits = self.len.wrapping_mul(8); + self.update(&[0x80]); + while self.used != 56 { + self.update(&[0]); + } + self.buf[56..].copy_from_slice(&bits.to_be_bytes()); + compress(&mut self.h, &self.buf); + let mut out = [0u8; 32]; + for (o, v) in out.chunks_exact_mut(4).zip(self.h) { + o.copy_from_slice(&v.to_be_bytes()); + } + out + } +} + +impl Default for Sha256 { + fn default() -> Self { + Self::new() + } +} diff --git a/userland/nonos_hash/tests/stream.rs b/userland/nonos_hash/tests/stream.rs new file mode 100644 index 0000000000..7d20c544c3 --- /dev/null +++ b/userland/nonos_hash/tests/stream.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 streaming SHA-256 agrees with the one-shot at every length and every +//! split, and both give the FIPS 180-4 answers. + +use nonos_hash::{sha256, Sha256}; + +#[test] +fn fips_answers() { + let abc = sha256(b"abc"); + assert_eq!(abc[..4], [0xba, 0x78, 0x16, 0xbf]); + assert_eq!(abc[28..], [0xf2, 0x00, 0x15, 0xad]); + let mut s = Sha256::new(); + s.update(b"ab"); + s.update(b"c"); + assert_eq!(s.finalize(), abc); +} + +#[test] +fn every_split_matches_the_one_shot() { + let data: Vec = (0..300u32).map(|i| (i * 31 + 7) as u8).collect(); + for len in 0..data.len() { + let want = sha256(&data[..len]); + for cut in 0..=len { + let mut s = Sha256::new(); + s.update(&data[..cut]); + s.update(&data[cut..len]); + assert_eq!(s.finalize(), want, "len {len} cut {cut}"); + } + } +} 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/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}; diff --git a/userland/openpgp/Cargo.lock b/userland/openpgp/Cargo.lock new file mode 100644 index 0000000000..2635f15ac7 --- /dev/null +++ b/userland/openpgp/Cargo.lock @@ -0,0 +1,113 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[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 = "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 = "nonos_ed25519" +version = "0.1.0" +dependencies = [ + "nonos_hash", + "spin", +] + +[[package]] +name = "nonos_hash" +version = "0.1.0" + +[[package]] +name = "nonos_openpgp" +version = "0.1.0" +dependencies = [ + "nonos_ed25519", + "nonos_hash", + "sha1", +] + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" + +[[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/openpgp/Cargo.toml b/userland/openpgp/Cargo.toml new file mode 100644 index 0000000000..9edc368ad7 --- /dev/null +++ b/userland/openpgp/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "nonos_openpgp" +version = "0.1.0" +edition = "2021" +authors = ["NONOS Contributors"] +license = "AGPL-3.0" +description = "OpenPGP v4 detached signature verification for NONOS userland" + +[lib] +name = "nonos_openpgp" +path = "src/lib.rs" + +[dependencies] +nonos_hash = { path = "../nonos_hash" } +sha1 = { version = "0.10", default-features = false } + +[dev-dependencies] +nonos_ed25519 = { path = "../nonos_ed25519" } diff --git a/userland/openpgp/src/armor.rs b/userland/openpgp/src/armor.rs new file mode 100644 index 0000000000..c8aa7f7173 --- /dev/null +++ b/userland/openpgp/src/armor.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 . + +//! ASCII armor (RFC 9580 6.2): the base64 between a BEGIN and an END line, +//! headers skipped, and the CRC-24 checked when one is given. + +use alloc::vec::Vec; + +use super::base64::decode; + +/// The binary in the first armored block of `text`. +pub fn dearmor(text: &[u8]) -> Option> { + let mut lines = text.split(|&b| b == b'\n').map(|l| l.strip_suffix(b"\r").unwrap_or(l)); + lines.find(|l| l.starts_with(b"-----BEGIN PGP "))?; + // Armor headers run to the first blank line. + for l in lines.by_ref() { + if l.iter().all(|b| b.is_ascii_whitespace()) { + break; + } + } + let (mut body, mut crc) = (Vec::new(), None); + for l in lines { + if l.starts_with(b"-----END PGP ") { + let data = decode(&body)?; + return match crc { + Some(c) => (crc24(&data) == c).then_some(data), + None => Some(data), + }; + } + match l.strip_prefix(b"=") { + Some(sum) => { + crc = Some( + decode(sum) + .filter(|s| s.len() == 3) + .map(|s| u32::from_be_bytes([0, s[0], s[1], s[2]]))?, + ) + } + None => body.extend(l.iter().filter(|b| !b.is_ascii_whitespace())), + } + } + None +} + +fn crc24(data: &[u8]) -> u32 { + let mut crc: u32 = 0xB7_04CE; + for &b in data { + crc ^= u32::from(b) << 16; + for _ in 0..8 { + crc <<= 1; + if crc & 0x100_0000 != 0 { + crc ^= 0x186_4CFB; + } + } + } + crc & 0xFF_FFFF +} diff --git a/userland/openpgp/src/base64.rs b/userland/openpgp/src/base64.rs new file mode 100644 index 0000000000..aba73ce40d --- /dev/null +++ b/userland/openpgp/src/base64.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 . + +//! Base64 (RFC 4648), padded, as armor writes it. Anything outside the +//! alphabet, or padding anywhere but the end, is refused. + +use alloc::vec::Vec; + +fn value(c: u8) -> Option { + Some(match c { + b'A'..=b'Z' => c - b'A', + b'a'..=b'z' => c - b'a' + 26, + b'0'..=b'9' => c - b'0' + 52, + b'+' => 62, + b'/' => 63, + _ => return None, + } as u32) +} + +pub fn decode(s: &[u8]) -> Option> { + if s.len() % 4 != 0 { + return None; + } + let mut out = Vec::with_capacity(s.len() / 4 * 3); + for (i, q) in s.chunks_exact(4).enumerate() { + let last = i == s.len() / 4 - 1; + let pad = q.iter().rev().take_while(|&&c| c == b'=').count(); + if pad > 2 || (pad > 0 && !last) { + return None; + } + let mut v = 0u32; + for &c in &q[..4 - pad] { + v = v << 6 | value(c)?; + } + v <<= 6 * pad as u32; + out.extend_from_slice(&v.to_be_bytes()[1..4 - pad]); + } + Some(out) +} diff --git a/userland/openpgp/src/digest.rs b/userland/openpgp/src/digest.rs new file mode 100644 index 0000000000..c915c837ed --- /dev/null +++ b/userland/openpgp/src/digest.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 a v4 signature signs: the document, the hashed part of the +//! signature packet, then 0x04 0xFF and that part's length. + +use nonos_hash::{Sha256, Sha512}; + +use super::refusal::Refusal; + +const MD5: u8 = 1; +const SHA1: u8 = 2; +pub const SHA256: u8 = 8; +pub const SHA512: u8 = 10; + +pub enum Digest { + Sha256([u8; 32]), + Sha512([u8; 64]), +} + +impl Digest { + pub fn bytes(&self) -> &[u8] { + match self { + Digest::Sha256(d) => d, + Digest::Sha512(d) => d, + } + } +} + +pub fn digest(hash: u8, data: &[u8], hashed: &[u8]) -> Result { + let len = u32::try_from(hashed.len()).map_err(|_| Refusal::Malformed)?; + let mut trailer = [0x04, 0xFF, 0, 0, 0, 0]; + trailer[2..].copy_from_slice(&len.to_be_bytes()); + match hash { + SHA256 => { + let mut h = Sha256::new(); + h.update(data); + h.update(hashed); + h.update(&trailer); + Ok(Digest::Sha256(h.finalize())) + } + SHA512 => { + let mut h = Sha512::new(); + h.update(data); + h.update(hashed); + h.update(&trailer); + Ok(Digest::Sha512(h.finalize())) + } + MD5 | SHA1 => Err(Refusal::WeakHash), + _ => Err(Refusal::UnknownHash), + } +} diff --git a/userland/openpgp/src/key.rs b/userland/openpgp/src/key.rs new file mode 100644 index 0000000000..85e541e97a --- /dev/null +++ b/userland/openpgp/src/key.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 . + +//! A v4 public key or subkey packet: its fingerprint and what it verifies +//! with. RSA, EdDSA over Ed25519, and native Ed25519; any other algorithm is +//! not a signing key here and is skipped by the keyring. + +use alloc::vec::Vec; + +use sha1::{Digest, Sha1}; + +use super::mpi::mpi; + +/// 1.3.6.1.4.1.11591.15.1, the curve legacy EdDSA keys name. +const ED25519_OID: [u8; 9] = [0x2B, 0x06, 0x01, 0x04, 0x01, 0xDA, 0x47, 0x0F, 0x01]; + +#[derive(Clone)] +pub enum Material { + Rsa { n: Vec, e: Vec }, + Ed25519([u8; 32]), +} + +#[derive(Clone)] +pub struct Key { + pub fingerprint: [u8; 20], + pub material: Material, +} + +pub fn key(body: &[u8]) -> Option { + if *body.first()? != 4 { + return None; + } + let rest = body.get(6..)?; + let material = match *body.get(5)? { + 1 | 3 => { + let (n, rest) = mpi(rest)?; + let (e, _) = mpi(rest)?; + Material::Rsa { n: n.to_vec(), e: e.to_vec() } + } + 22 => { + let len = usize::from(*rest.first()?); + if rest.get(1..1 + len)? != ED25519_OID { + return None; + } + let (point, _) = mpi(&rest[1 + len..])?; + match point { + [0x40, pk @ ..] => Material::Ed25519(pk.try_into().ok()?), + _ => return None, + } + } + 27 => Material::Ed25519(rest.get(..32)?.try_into().ok()?), + _ => return None, + }; + let len = u16::try_from(body.len()).ok()?; + let mut h = Sha1::new(); + h.update([0x99]); + h.update(len.to_be_bytes()); + h.update(body); + Some(Key { fingerprint: h.finalize().into(), material }) +} diff --git a/userland/openpgp/src/keyring.rs b/userland/openpgp/src/keyring.rs new file mode 100644 index 0000000000..30bcc7cb65 --- /dev/null +++ b/userland/openpgp/src/keyring.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 . + +//! A transferable public key, as `gpg --export` writes it: every key and +//! subkey packet in it. The whole file is what is pinned, so a subkey is +//! trusted because it is in the file, not because of a binding signature +//! read here. + +use alloc::vec::Vec; + +use super::key::{key, Key}; +use super::packet::next; +use super::subpacket::Found; + +const PUBLIC_KEY: u8 = 6; +const PUBLIC_SUBKEY: u8 = 14; + +/// None if the file does not frame, or holds no key this crate can use. +pub fn keys(ring: &[u8]) -> Option> { + let mut out = Vec::new(); + let mut rest = ring; + while !rest.is_empty() { + let (p, after) = next(rest)?; + if p.tag == PUBLIC_KEY || p.tag == PUBLIC_SUBKEY { + out.extend(key(p.body)); + } + rest = after; + } + (!out.is_empty()).then_some(out) +} + +/// The key a signature names: by fingerprint when it gives one, else by the +/// low eight bytes of it. Naming no key finds none. +pub fn issuer_key<'a>(ring: &'a [Key], found: &Found) -> Option<&'a Key> { + ring.iter().find(|k| match (found.fingerprint, found.key_id) { + (Some(f), _) => k.fingerprint == f, + (None, Some(id)) => k.fingerprint[12..] == id, + (None, None) => false, + }) +} diff --git a/userland/openpgp/src/lib.rs b/userland/openpgp/src/lib.rs new file mode 100644 index 0000000000..b03fd1405f --- /dev/null +++ b/userland/openpgp/src/lib.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 . + +//! OpenPGP detached signatures (RFC 4880, RFC 9580), verified against a +//! pinned keyring: the provenance a pacman or apt repository gives its files. +//! +//! This crate reads packets and computes what was signed; the arithmetic is +//! the caller's `Verifier`, so a machine keeps one RSA and one Ed25519. Only +//! v4 signatures of binary documents, over SHA-256 or SHA-512, are accepted. +//! Anything else is a `Refusal` with its reason, never a silent pass. + +#![no_std] + +extern crate alloc; + +mod armor; +mod base64; +mod digest; +mod key; +mod keyring; +mod mpi; +mod packet; +mod refusal; +mod sig; +mod subpacket; +mod verify; + +pub use armor::dearmor; +pub use key::{Key, Material}; +pub use keyring::keys; +pub use refusal::Refusal; +pub use verify::{verify, Verified, Verifier}; diff --git a/userland/openpgp/src/mpi.rs b/userland/openpgp/src/mpi.rs new file mode 100644 index 0000000000..55f8b67a84 --- /dev/null +++ b/userland/openpgp/src/mpi.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 . + +//! Multiprecision integers: a bit count, then the big-endian magnitude. A +//! count that disagrees with the magnitude's top bit is refused. + +use alloc::vec::Vec; + +use super::refusal::Refusal; + +/// Native Ed25519 writes its signature raw; every other algorithm as MPIs. +const ED25519: u8 = 27; + +pub fn mpi(d: &[u8]) -> Option<(&[u8], &[u8])> { + let bits = usize::from(u16::from_be_bytes([*d.first()?, *d.get(1)?])); + let n = bits.div_ceil(8); + let v = d.get(2..2 + n)?; + if let Some(&top) = v.first() { + if (n - 1) * 8 + (8 - top.leading_zeros() as usize) != bits { + return None; + } + } + Some((v, &d[2 + n..])) +} + +/// A signature's values: one raw 64-byte value for native Ed25519, else up +/// to two MPIs, with nothing after them. +pub fn values(algo: u8, mut rest: &[u8]) -> Result, Refusal> { + let mut out = Vec::new(); + if algo == ED25519 { + out.push(rest.get(..64).ok_or(Refusal::Malformed)?); + rest = &rest[64..]; + } + while !rest.is_empty() && out.len() < 2 { + let (v, after) = mpi(rest).ok_or(Refusal::Malformed)?; + out.push(v); + rest = after; + } + rest.is_empty().then_some(out).ok_or(Refusal::Malformed) +} + +/// An MPI right-aligned into its fixed width; one too wide is malformed. +pub fn fixed(out: &mut [u8], v: &[u8]) -> Result<(), Refusal> { + let pad = out.len().checked_sub(v.len()).ok_or(Refusal::Malformed)?; + out[pad..].copy_from_slice(v); + Ok(()) +} diff --git a/userland/openpgp/src/packet.rs b/userland/openpgp/src/packet.rs new file mode 100644 index 0000000000..855229534c --- /dev/null +++ b/userland/openpgp/src/packet.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 . + +//! Packet framing, old and new format. Partial and indeterminate lengths are +//! for streamed literal data, never for keys or signatures, and are refused. + +pub struct Packet<'a> { + pub tag: u8, + pub body: &'a [u8], +} + +fn be(d: &[u8]) -> usize { + d.iter().fold(0, |v, &b| v << 8 | usize::from(b)) +} + +/// The first packet of `d`, and what follows it. +pub fn next(d: &[u8]) -> Option<(Packet<'_>, &[u8])> { + let b = *d.first()?; + if b & 0x80 == 0 { + return None; + } + let (tag, len, head) = if b & 0x40 != 0 { + let (len, head) = match usize::from(*d.get(1)?) { + o @ 0..=191 => (o, 2), + o @ 192..=223 => (((o - 192) << 8) + usize::from(*d.get(2)?) + 192, 3), + 255 => (be(d.get(2..6)?), 6), + _ => return None, + }; + (b & 0x3F, len, head) + } else { + let head = match b & 3 { + 0 => 2, + 1 => 3, + 2 => 5, + _ => return None, + }; + ((b >> 2) & 0x0F, be(d.get(1..head)?), head) + }; + let end = head.checked_add(len)?; + Some((Packet { tag, body: d.get(head..end)? }, &d[end..])) +} diff --git a/userland/openpgp/src/refusal.rs b/userland/openpgp/src/refusal.rs new file mode 100644 index 0000000000..448dc6610c --- /dev/null +++ b/userland/openpgp/src/refusal.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 . + +//! Why a signature was not accepted, each named so a log says which. + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Refusal { + Malformed, + Version, + NotBinary, + WeakHash, + UnknownHash, + Algorithm, + Critical, + UnknownKey, + QuickCheck, + BadSignature, +} + +impl Refusal { + pub fn why(self) -> &'static str { + match self { + Refusal::Malformed => "the signature does not parse", + Refusal::Version => "not a version 4 signature", + Refusal::NotBinary => "not a signature over a binary document", + Refusal::WeakHash => "made with MD5 or SHA-1, which are refused", + Refusal::UnknownHash => "a digest other than SHA-256 or SHA-512", + Refusal::Algorithm => "an algorithm the key does not have", + Refusal::Critical => "a critical subpacket this verifier does not know", + Refusal::UnknownKey => "made by no key in the pinned keyring", + Refusal::QuickCheck => "the digest does not match the signature's check bytes", + Refusal::BadSignature => "the signature does not verify", + } + } +} diff --git a/userland/openpgp/src/sig.rs b/userland/openpgp/src/sig.rs new file mode 100644 index 0000000000..23c73df22d --- /dev/null +++ b/userland/openpgp/src/sig.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 v4 signature packet (RFC 9580 5.2.3). + +use alloc::vec::Vec; + +use super::mpi::values; +use super::packet::next; +use super::refusal::Refusal; +use super::subpacket::{read, Found}; + +const SIGNATURE: u8 = 2; + +pub struct Signature<'a> { + pub kind: u8, + pub algo: u8, + pub hash: u8, + /// Version through the end of the hashed area: what the digest covers. + pub hashed: &'a [u8], + pub issuer: Found, + pub left16: [u8; 2], + pub values: Vec<&'a [u8]>, +} + +/// A detached signature file holds exactly one signature packet. +pub fn signature(file: &[u8]) -> Result, Refusal> { + let (p, rest) = next(file).ok_or(Refusal::Malformed)?; + if p.tag != SIGNATURE || !rest.is_empty() { + return Err(Refusal::Malformed); + } + let b = p.body; + if b.first() != Some(&4) { + return Err(Refusal::Version); + } + let len = |at: usize| b.get(at..at + 2).map(|x| usize::from(u16::from_be_bytes([x[0], x[1]]))); + let hlen = len(4).ok_or(Refusal::Malformed)?; + let hashed_end = 6 + hlen; + let ulen = len(hashed_end).ok_or(Refusal::Malformed)?; + let unhashed_end = hashed_end + 2 + ulen; + let mut issuer = Found::default(); + read(b.get(6..hashed_end).ok_or(Refusal::Malformed)?, true, &mut issuer)?; + read(b.get(hashed_end + 2..unhashed_end).ok_or(Refusal::Malformed)?, false, &mut issuer)?; + let left = b.get(unhashed_end..unhashed_end + 2).ok_or(Refusal::Malformed)?; + let values = values(b[2], &b[unhashed_end + 2..])?; + Ok(Signature { + kind: b[1], + algo: b[2], + hash: b[3], + hashed: &b[..hashed_end], + issuer, + left16: [left[0], left[1]], + values, + }) +} diff --git a/userland/openpgp/src/subpacket.rs b/userland/openpgp/src/subpacket.rs new file mode 100644 index 0000000000..8d3e15b101 --- /dev/null +++ b/userland/openpgp/src/subpacket.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 . + +//! Signature subpackets: who made it, and whether anything critical is +//! present that this verifier cannot honour. + +use super::refusal::Refusal; + +const CREATED: u8 = 2; +const EXPIRES: u8 = 3; +const ISSUER: u8 = 16; +const SIGNER_UID: u8 = 28; +const ISSUER_FPR: u8 = 33; + +#[derive(Default)] +pub struct Found { + pub fingerprint: Option<[u8; 20]>, + pub key_id: Option<[u8; 8]>, +} + +/// Walk one area. In the hashed area a critical subpacket of a type not +/// known here voids the signature, as RFC 9580 5.2.3.7 requires. +pub fn read(mut area: &[u8], hashed: bool, found: &mut Found) -> Result<(), Refusal> { + while !area.is_empty() { + let (len, head) = match usize::from(area[0]) { + o @ 0..=191 => (o, 1), + o @ 192..=254 => { + (((o - 192) << 8) + usize::from(*area.get(1).ok_or(Refusal::Malformed)?) + 192, 2) + } + _ => (be(area.get(1..5).ok_or(Refusal::Malformed)?), 5), + }; + let body = area.get(head..head + len).ok_or(Refusal::Malformed)?; + let (&kind, value) = body.split_first().ok_or(Refusal::Malformed)?; + match (kind & 0x7F, value) { + (ISSUER_FPR, [4, fpr @ ..]) => found.fingerprint = fpr.try_into().ok(), + (ISSUER, id) => found.key_id = found.key_id.or(id.try_into().ok()), + (CREATED | EXPIRES | SIGNER_UID | ISSUER_FPR, _) => {} + _ if hashed && kind & 0x80 != 0 => return Err(Refusal::Critical), + _ => {} + } + area = &area[head + len..]; + } + Ok(()) +} + +fn be(d: &[u8]) -> usize { + d.iter().fold(0, |v, &b| v << 8 | usize::from(b)) +} diff --git a/userland/openpgp/src/verify.rs b/userland/openpgp/src/verify.rs new file mode 100644 index 0000000000..953b35060e --- /dev/null +++ b/userland/openpgp/src/verify.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 detached signature, checked against a pinned keyring. + +use super::digest::digest; +use super::key::{Key, Material}; +use super::keyring::issuer_key; +use super::mpi::fixed as fill; +use super::refusal::Refusal; +use super::sig::signature; + +/// The arithmetic, supplied by the caller. `digest` is what was signed; for +/// RSA it goes inside a PKCS#1 v1.5 DigestInfo for `hash`. +pub trait Verifier { + fn rsa(&self, n: &[u8], e: &[u8], sig: &[u8], hash: u8, digest: &[u8]) -> bool; + fn ed25519(&self, key: &[u8; 32], sig: &[u8; 64], digest: &[u8]) -> bool; +} + +pub struct Verified { + pub fingerprint: [u8; 20], + pub hash: u8, +} + +pub fn verify( + v: &impl Verifier, + ring: &[Key], + sig: &[u8], + data: &[u8], +) -> Result { + let s = signature(sig)?; + if s.kind != 0x00 { + return Err(Refusal::NotBinary); + } + let key = issuer_key(ring, &s.issuer).ok_or(Refusal::UnknownKey)?; + let d = digest(s.hash, data, s.hashed)?; + if d.bytes()[..2] != s.left16 { + return Err(Refusal::QuickCheck); + } + let good = match (&key.material, s.algo, s.values.as_slice()) { + (Material::Rsa { n, e }, 1 | 3, [m]) => v.rsa(n, e, m, s.hash, d.bytes()), + (Material::Ed25519(pk), 22, [r, sv]) => { + let mut rs = [0u8; 64]; + fill(&mut rs[..32], r)?; + fill(&mut rs[32..], sv)?; + v.ed25519(pk, &rs, d.bytes()) + } + (Material::Ed25519(pk), 27, [raw]) => { + v.ed25519(pk, (*raw).try_into().map_err(|_| Refusal::Malformed)?, d.bytes()) + } + _ => return Err(Refusal::Algorithm), + }; + match good { + true => Ok(Verified { fingerprint: key.fingerprint, hash: s.hash }), + false => Err(Refusal::BadSignature), + } +} diff --git a/userland/openpgp/tests/armor.rs b/userland/openpgp/tests/armor.rs new file mode 100644 index 0000000000..83faf3c942 --- /dev/null +++ b/userland/openpgp/tests/armor.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 . + +//! Armored keys and signatures, as a Debian repository publishes them, read +//! and verified; a damaged checksum line is refused. + +#[path = "support/bignum.rs"] +mod bignum; +#[path = "support/fixture.rs"] +mod fixture; +#[path = "support/host.rs"] +mod host; +#[path = "support/modpow.rs"] +mod modpow; + +use fixture::{data, file, ring}; +use host::Host; +use nonos_openpgp::{dearmor, keys, verify}; + +#[test] +fn an_armored_key_is_the_exported_key() { + let ring_asc = keys(&dearmor(&file("rsa.asc")).expect("armor")).expect("keys"); + let fprs = |r: &[nonos_openpgp::Key]| r.iter().map(|k| k.fingerprint).collect::>(); + assert_eq!(fprs(&ring_asc), fprs(&ring("rsa"))); +} + +#[test] +fn an_armored_signature_verifies() { + let sig = dearmor(&file("rsa-sha256.asc")).expect("armor"); + assert!(verify(&Host, &ring("rsa"), &sig, &data()).is_ok()); +} + +#[test] +fn a_damaged_checksum_line_is_refused() { + let text = String::from_utf8(file("rsa-sha256.asc")).expect("ascii"); + let line = text.lines().find(|l| l.starts_with('=')).expect("a checksum line"); + let flipped = format!("={}", if &line[1..2] == "A" { "B" } else { "A" }) + &line[2..]; + assert!(dearmor(text.replace(line, &flipped).as_bytes()).is_none()); +} + +#[test] +fn text_without_armor_is_nothing() { + assert!(dearmor(b"just text\n").is_none()); + assert!(dearmor(b"-----BEGIN PGP SIGNATURE-----\n\nAAAA\n").is_none(), "no END line"); +} diff --git a/userland/openpgp/tests/mutate.rs b/userland/openpgp/tests/mutate.rs new file mode 100644 index 0000000000..fe5e294a44 --- /dev/null +++ b/userland/openpgp/tests/mutate.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 . + +//! Seeded mutation fuzzing of the packet, key and signature parsers, not +//! coverage-guided (no libFuzzer is vendored). Every damaged input must +//! return, in a debug build, without a panic or an overflow. + +#[path = "support/bignum.rs"] +mod bignum; +#[path = "support/host.rs"] +mod host; +#[path = "support/modpow.rs"] +mod modpow; + +use host::Host; +use nonos_openpgp::{keys, verify}; + +const ROUNDS: usize = 3000; + +struct Rng(u64); + +impl Rng { + fn next(&mut self) -> u64 { + self.0 ^= self.0 << 13; + self.0 ^= self.0 >> 7; + self.0 ^= self.0 << 17; + self.0 + } + + fn damage(&mut self, v: &mut Vec) { + let at = (self.next() % v.len().max(1) as u64) as usize; + match self.next() % 4 { + 0 if at < v.len() => v[at] ^= 1 << (self.next() % 8), + 1 if at < v.len() => v[at] = self.next() as u8, + 2 => v.truncate(at), + _ => v.insert(at.min(v.len()), self.next() as u8), + } + } +} + +fn file(name: &str) -> Vec { + let path = format!("{}/tests/vectors/{name}", env!("CARGO_MANIFEST_DIR")); + std::fs::read(&path).unwrap_or_else(|e| panic!("{path}: {e}")) +} + +#[test] +fn damaged_keyrings_and_signatures_never_panic() { + let data: Vec = (0..70000u32).map(|i| ((i * 131 + 17) % 251) as u8).collect(); + let mut r = Rng(0x0DD5_EED5); + for (name, sig) in [("ed", "ed-sha512.sig"), ("sub", "sub-sha512.sig")] { + let (pubkey, signature) = (file(&format!("{name}.pub")), file(sig)); + let ring = keys(&pubkey).unwrap_or_default(); + for _ in 0..ROUNDS { + let mut k = pubkey.clone(); + r.damage(&mut k); + let _ = keys(&k); + let mut s = signature.clone(); + r.damage(&mut s); + let _ = verify(&Host, &ring, &s, &data); + } + } +} diff --git a/userland/openpgp/tests/mutate_armor.rs b/userland/openpgp/tests/mutate_armor.rs new file mode 100644 index 0000000000..ab6c162ed4 --- /dev/null +++ b/userland/openpgp/tests/mutate_armor.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 . + +//! Seeded mutation fuzzing of the armor reader, not coverage-guided: every +//! damaged armored key or signature returns, in a debug build, without a +//! panic or an overflow. + +use nonos_openpgp::{dearmor, keys}; + +#[test] +fn damaged_armor_never_panics() { + let mut s = 0xA2_40u64; + let mut next = move || { + s ^= s << 13; + s ^= s >> 7; + s ^= s << 17; + s + }; + for name in ["rsa.asc", "rsa-sha256.asc"] { + let path = format!("{}/tests/vectors/{name}", env!("CARGO_MANIFEST_DIR")); + let clean = std::fs::read(&path).unwrap_or_else(|e| panic!("{path}: {e}")); + for _ in 0..4000 { + let mut v = clean.clone(); + let at = (next() % v.len() as u64) as usize; + match next() % 3 { + 0 => v[at] = next() as u8, + 1 => v.truncate(at), + _ => v.insert(at, b"=\n-A"[(next() % 4) as usize]), + } + if let Some(bin) = dearmor(&v) { + let _ = keys(&bin); + } + } + } +} diff --git a/userland/openpgp/tests/support/bignum.rs b/userland/openpgp/tests/support/bignum.rs new file mode 100644 index 0000000000..c129588deb --- /dev/null +++ b/userland/openpgp/tests/support/bignum.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 . + +//! Just enough unsigned arithmetic to check an RSA signature in a test: +//! `base^e mod n` on big-endian bytes. Slow and simple on purpose, so it is +//! not a second implementation of anything the product relies on. + +pub type Limbs = Vec; + +pub fn from_be(b: &[u8]) -> Limbs { + b.rchunks(4).map(|c| c.iter().fold(0u32, |v, &x| v << 8 | u32::from(x))).collect() +} + +pub fn bit(a: &Limbs, i: usize) -> bool { + a.get(i / 32).is_some_and(|w| (w >> (i % 32)) & 1 == 1) +} + +pub fn ge(a: &Limbs, n: &Limbs) -> bool { + for i in (0..a.len().max(n.len())).rev() { + let (x, y) = (a.get(i).copied().unwrap_or(0), n.get(i).copied().unwrap_or(0)); + if x != y { + return x > y; + } + } + true +} + +pub fn sub(a: &mut Limbs, n: &Limbs) { + let mut borrow = 0i64; + for (i, w) in a.iter_mut().enumerate() { + let v = i64::from(*w) - i64::from(n.get(i).copied().unwrap_or(0)) - borrow; + borrow = i64::from(v < 0); + *w = v.rem_euclid(1 << 32) as u32; + } +} diff --git a/userland/openpgp/tests/support/fixture.rs b/userland/openpgp/tests/support/fixture.rs new file mode 100644 index 0000000000..3cf33a3310 --- /dev/null +++ b/userland/openpgp/tests/support/fixture.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 . + +//! The vectors tools/nonos-openpgp-vectors wrote, and the file they sign. + +use nonos_openpgp::{keys, Key}; + +pub fn file(name: &str) -> Vec { + let path = format!("{}/tests/vectors/{name}", env!("CARGO_MANIFEST_DIR")); + std::fs::read(&path).unwrap_or_else(|e| panic!("{path}: {e}")) +} + +/// The signed file, as tools/nonos-openpgp-vectors made it. +pub fn data() -> Vec { + (0..70000u32).map(|i| ((i * 131 + 17) % 251) as u8).collect() +} + +pub fn ring(name: &str) -> Vec { + keys(&file(&format!("{name}.pub"))).unwrap_or_else(|| panic!("{name}.pub: no keys")) +} diff --git a/userland/openpgp/tests/support/host.rs b/userland/openpgp/tests/support/host.rs new file mode 100644 index 0000000000..c73d4b37be --- /dev/null +++ b/userland/openpgp/tests/support/host.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 test's `Verifier`: Ed25519 through nonos_ed25519, RSA by raising the +//! signature to e and comparing the whole PKCS#1 v1.5 block. + +use nonos_ed25519::{verify, Signature}; +use nonos_openpgp::Verifier; + +use crate::modpow::modpow; + +const SHA256_INFO: [u8; 19] = [ + 0x30, 0x31, 0x30, 0x0d, 0x06, 0x09, 0x60, 0x86, 0x48, 0x01, 0x65, 0x03, 0x04, 0x02, 0x01, 0x05, + 0x00, 0x04, 0x20, +]; +const SHA512_INFO: [u8; 19] = [ + 0x30, 0x51, 0x30, 0x0d, 0x06, 0x09, 0x60, 0x86, 0x48, 0x01, 0x65, 0x03, 0x04, 0x02, 0x03, 0x05, + 0x00, 0x04, 0x40, +]; + +pub struct Host; + +impl Verifier for Host { + fn rsa(&self, n: &[u8], e: &[u8], sig: &[u8], hash: u8, digest: &[u8]) -> bool { + let info: &[u8] = match hash { + 8 => &SHA256_INFO, + 10 => &SHA512_INFO, + _ => return false, + }; + let m = modpow(sig, e, n); + let em = &m[m.len() - n.len()..]; + let pad = n.len() - 3 - info.len() - digest.len(); + let mut want = vec![0x00, 0x01]; + want.extend(std::iter::repeat_n(0xFF, pad)); + want.push(0x00); + want.extend_from_slice(info); + want.extend_from_slice(digest); + m[..m.len() - n.len()].iter().all(|&b| b == 0) && em == want.as_slice() + } + + fn ed25519(&self, key: &[u8; 32], sig: &[u8; 64], digest: &[u8]) -> bool { + verify(key, digest, &Signature::from_bytes(sig)) + } +} diff --git a/userland/openpgp/tests/support/modpow.rs b/userland/openpgp/tests/support/modpow.rs new file mode 100644 index 0000000000..a8bf77be31 --- /dev/null +++ b/userland/openpgp/tests/support/modpow.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 . + +//! `base^e mod n` by square and multiply, for the test's RSA check. + +use crate::bignum::{bit, from_be, ge, sub, Limbs}; + +/// (a * b) mod n, by shifting the product in one bit at a time. +fn mulmod(a: &Limbs, b: &Limbs, n: &Limbs) -> Limbs { + let mut r: Limbs = vec![0; n.len() + 1]; + for i in (0..a.len() * 32).rev() { + let mut carry = 0u32; + for w in r.iter_mut() { + let next = *w >> 31; + *w = *w << 1 | carry; + carry = next; + } + if ge(&r, n) { + sub(&mut r, n); + } + if bit(a, i) { + let mut c = 0u64; + for (j, w) in r.iter_mut().enumerate() { + let v = u64::from(*w) + u64::from(b.get(j).copied().unwrap_or(0)) + c; + *w = v as u32; + c = v >> 32; + } + while ge(&r, n) { + sub(&mut r, n); + } + } + } + r +} + +pub fn modpow(base: &[u8], e: &[u8], n: &[u8]) -> Vec { + let (n, e, b) = (from_be(n), from_be(e), from_be(base)); + let mut r: Limbs = vec![1]; + for i in (0..e.len() * 32).rev() { + r = mulmod(&r, &r, &n); + if bit(&e, i) { + r = mulmod(&r, &b, &n); + } + } + r.iter().rev().flat_map(|w| w.to_be_bytes()).collect() +} diff --git a/userland/openpgp/tests/vectors/ed-sha256.sig b/userland/openpgp/tests/vectors/ed-sha256.sig new file mode 100644 index 0000000000..dde0fc6626 Binary files /dev/null and b/userland/openpgp/tests/vectors/ed-sha256.sig differ diff --git a/userland/openpgp/tests/vectors/ed-sha512.sig b/userland/openpgp/tests/vectors/ed-sha512.sig new file mode 100644 index 0000000000..17e11606ec Binary files /dev/null and b/userland/openpgp/tests/vectors/ed-sha512.sig differ diff --git a/userland/openpgp/tests/vectors/ed.pub b/userland/openpgp/tests/vectors/ed.pub new file mode 100644 index 0000000000..421a4f1876 Binary files /dev/null and b/userland/openpgp/tests/vectors/ed.pub differ diff --git a/userland/openpgp/tests/vectors/fingerprints.txt b/userland/openpgp/tests/vectors/fingerprints.txt new file mode 100644 index 0000000000..118892d5f7 --- /dev/null +++ b/userland/openpgp/tests/vectors/fingerprints.txt @@ -0,0 +1,3 @@ +ed 7AEB7F2940DD365E02DDB7FCBE2D24F09DD5DC5D +rsa 8DAD27E58FBE3FF8D3A43DF994277EC23798BFAA +sub 9668BDA05C3CEE6FE8AC6744998990573DF109E2 ABA19EE8225129A5AB480751DFFA9D4BC41D8E4E diff --git a/userland/openpgp/tests/vectors/rsa-sha256.asc b/userland/openpgp/tests/vectors/rsa-sha256.asc new file mode 100644 index 0000000000..1c6c0a6095 --- /dev/null +++ b/userland/openpgp/tests/vectors/rsa-sha256.asc @@ -0,0 +1,14 @@ +-----BEGIN PGP SIGNATURE----- + +iQHGBAABCAAwFiEEja0n5Y++P/jTpD35lCd+wjeYv6oFAmq5UjsSHHJzYUBub25v +cy5pbnZhbGlkAAoJEJQnfsI3mL+qUacL/0degU7d3u2BOlVQjy1X9dxHlnopyomz +brW47Vk9YiT2n4T+Rm9I0oAcO3iUSP0ha7BCYpupGKMLNmZyI56EZ3kOrt/G3L/P +q9WBgpWYoGaB9H655EcG/VQwBUNPKQkBafdjtqNgla/835iIQeJX7D+1N6wHOVcv +Is8ICcyac9BCY0PASJJ2Qs0LobNyxQwqGSjm/3wym6luph9chDp91EFMKAWXdYT7 +S6uaoVwqZ1PJ/c8A8ROBxJ6WhkS+P+mu4gu1gidTNEOurgzaqYbNUxE5rgXr6Kow +uA5MRVQ/9sOPGu+EmPpZEKNYi9dZe674OaDlGUWHy+PTwWWJOVVU+aoGvfm2MOf9 +3zinLpO9aUH1LlckIV4/moEBQZJ6Txywt1RCiAPMppu+2oD67gXiR40iN3iwQhnm +NR/0JRI8WGLVt68qhlqRgXcmWZ4nT7zYws7z1RFBSCwajT2EVd+LwJS+Fp/J9Aum +WAJbanHXstFzgzBX2LVnueSJEuGZNHGP5g== +=Xl0l +-----END PGP SIGNATURE----- diff --git a/userland/openpgp/tests/vectors/rsa-sha256.sig b/userland/openpgp/tests/vectors/rsa-sha256.sig new file mode 100644 index 0000000000..4193a31598 Binary files /dev/null and b/userland/openpgp/tests/vectors/rsa-sha256.sig differ diff --git a/userland/openpgp/tests/vectors/rsa-sha512.sig b/userland/openpgp/tests/vectors/rsa-sha512.sig new file mode 100644 index 0000000000..7b5900fe13 Binary files /dev/null and b/userland/openpgp/tests/vectors/rsa-sha512.sig differ diff --git a/userland/openpgp/tests/vectors/rsa.asc b/userland/openpgp/tests/vectors/rsa.asc new file mode 100644 index 0000000000..a9f3c1ab7d --- /dev/null +++ b/userland/openpgp/tests/vectors/rsa.asc @@ -0,0 +1,23 @@ +-----BEGIN PGP PUBLIC KEY BLOCK----- + +mQGNBGq5UjsBDAC+0gUGvBRGp5SJJbE+RZJrW2Q9vjgomMdSpYUiBwBbD2nsbWKV +AiVOOaACMhEgJw5FA19xLLkPMGKNJxHJjjN+x/rrsw/B8YteRwb/04wbRvEfisno +VRWZxLzeY36lnTZUfoC9bhyj65mPJGwnRoqIlLcqOsC6T+854Hl9YOcGYhwO44xL +vVLl0EducHDOR3AAeQz43nAIHLwUwngGZ96/7P3V32k90/cU+BaNsCWgxdiqhule +k3gpPW8B9mMeUJToWMlavipXQeXCyE+r4d4kW+SvHLYYaQk4TA18lC9NGwKHKwIj +BOD1ym4fe0fdPb4fLBn8d6/HM0MfbfHwaBgji514QMn2K06lJlCj2ct9SHf66n+w +kOGr3FphH08JKcsixdVlg7DYYUmaeFciJ2k1d3ZdzYLfGjXZi1qgsl7Snl6OBkFI +wLjz+jF+HwRndFLGmM2kd0hBwwG6fiGcOoMmpWB+p4PCuLghe+n9HzecV/pRQGZU +x7vedRhoQHEKR1cAEQEAAbQiTk9OT1MgdGVzdCByc2EgPHJzYUBub25vcy5pbnZh +bGlkPokB0QQTAQoAOxYhBI2tJ+WPvj/406Q9+ZQnfsI3mL+qBQJquVI7AhsDBQsJ +CAcCAiICBhUKCQgLAgQWAgMBAh4HAheAAAoJEJQnfsI3mL+q1Y4L/05/3QAuMDOR +kSCJ2aMExkkCXcOEK7aGB2+SGiiySkqLhtAz5Z4Nl74R34cBQ6C+bJAVPn2PsDe+ +HveMGbZDN3IB8KPdBt6WF6hwSjdv3o/2Ele88nJ/F8yjC9hMxaNoquwC6GtsLn46 +EXoR5K/OMZu6+n1IubQgMkDKtZW7ENLaRE64lGvcksLjwEWn+uyFC9SjxMgZAyOb +wRWLmaEnDOsXFhKAuWCpT/7xMDi9K4HRfYKwAkzRUlUg3n+QSIO6HRjj2E+rGD73 +n8/gximDeun5HBaY+UGAfKAcrBdlxN4ZHj3rlIao7rg0FYJFA7d4qAKyU4THEKJf +iQo2dKiSOjvnMdMYuXchioVfoURZM3bxSbE4zCCWLPuOUQKxaFaWrlW5WO483Ayb +J2h1Y4TIVuQRvQlqy2g2Y4SOEd/rZPM14P/zxAWGDjAfrmnPzfPrmz7EhzEcTDgP +LV6NkDNjbkOVRQ/81QxlDFhQWSlkcTQtTbmrrAHQ4sL8tSHvqr9dYQ== +=0j72 +-----END PGP PUBLIC KEY BLOCK----- diff --git a/userland/openpgp/tests/vectors/rsa.pub b/userland/openpgp/tests/vectors/rsa.pub new file mode 100644 index 0000000000..705f6ce2e7 Binary files /dev/null and b/userland/openpgp/tests/vectors/rsa.pub differ diff --git a/userland/openpgp/tests/vectors/sub-sha512.sig b/userland/openpgp/tests/vectors/sub-sha512.sig new file mode 100644 index 0000000000..057ea63c20 Binary files /dev/null and b/userland/openpgp/tests/vectors/sub-sha512.sig differ diff --git a/userland/openpgp/tests/vectors/sub.pub b/userland/openpgp/tests/vectors/sub.pub new file mode 100644 index 0000000000..558a9399da Binary files /dev/null and b/userland/openpgp/tests/vectors/sub.pub differ diff --git a/userland/openpgp/tests/verify_good.rs b/userland/openpgp/tests/verify_good.rs new file mode 100644 index 0000000000..8797b9d12c --- /dev/null +++ b/userland/openpgp/tests/verify_good.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 . + +//! GnuPG's signatures verify, against the keys GnuPG says it made. + +#[path = "support/bignum.rs"] +mod bignum; +#[path = "support/fixture.rs"] +mod fixture; +#[path = "support/host.rs"] +mod host; +#[path = "support/modpow.rs"] +mod modpow; + +use fixture::{data, file, ring}; +use host::Host; +use nonos_openpgp::verify; + +fn hex(b: &[u8]) -> String { + b.iter().map(|x| format!("{x:02X}")).collect() +} + +#[test] +fn fingerprints_match_what_gnupg_reported() { + for line in String::from_utf8(file("fingerprints.txt")).unwrap_or_default().lines() { + let mut words = line.split(' '); + let name = words.next().unwrap_or_default(); + let ours: Vec = ring(name).iter().map(|k| hex(&k.fingerprint)).collect(); + assert_eq!(ours, words.map(String::from).collect::>(), "{name}"); + } +} + +#[test] +fn every_gnupg_signature_verifies() { + for (name, hash) in [("ed", 10), ("ed", 8), ("rsa", 8), ("rsa", 10), ("sub", 10)] { + let sig = file(&format!("{name}-sha{}.sig", if hash == 8 { 256 } else { 512 })); + let got = verify(&Host, &ring(name), &sig, &data()); + let ok = got.unwrap_or_else(|r| panic!("{name}/{hash}: {}", r.why())); + assert_eq!(ok.hash, hash); + } +} + +#[test] +fn a_subkey_signature_names_the_subkey() { + let r = ring("sub"); + let ok = verify(&Host, &r, &file("sub-sha512.sig"), &data()).map_err(|e| e.why()); + assert_eq!(ok.map(|v| v.fingerprint), Ok(r[1].fingerprint)); +} diff --git a/userland/openpgp/tests/verify_refused.rs b/userland/openpgp/tests/verify_refused.rs new file mode 100644 index 0000000000..7a0c13ebbf --- /dev/null +++ b/userland/openpgp/tests/verify_refused.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 . + +//! Every way of getting a signature wrong is refused, with its own reason. + +#[path = "support/bignum.rs"] +mod bignum; +#[path = "support/fixture.rs"] +mod fixture; +#[path = "support/host.rs"] +mod host; +#[path = "support/modpow.rs"] +mod modpow; + +use fixture::{data, file, ring}; +use host::Host; +use nonos_openpgp::{verify, Refusal}; + +#[test] +fn one_changed_byte_of_the_file_is_refused() { + let mut d = data(); + d[40000] ^= 1; + for (name, sig) in [("ed", "ed-sha512.sig"), ("rsa", "rsa-sha256.sig")] { + let got = verify(&Host, &ring(name), &file(sig), &d).err(); + assert_eq!(got, Some(Refusal::QuickCheck), "{name}"); + } +} + +/// The check bytes still match, so only the arithmetic can catch this. +#[test] +fn a_damaged_signature_value_fails_the_arithmetic() { + for (name, sig) in [("ed", "ed-sha512.sig"), ("rsa", "rsa-sha256.sig")] { + let mut s = file(sig); + let last = s.len() - 1; + s[last] ^= 0x01; + let got = verify(&Host, &ring(name), &s, &data()).err(); + assert_eq!(got, Some(Refusal::BadSignature), "{name}"); + } +} + +#[test] +fn another_keyring_does_not_vouch() { + let got = verify(&Host, &ring("rsa"), &file("ed-sha512.sig"), &data()).err(); + assert_eq!(got, Some(Refusal::UnknownKey)); +} 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", 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 +} 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..0a6228b9db --- /dev/null +++ b/userland/stark_proofs/src/public_leaf_tests.rs @@ -0,0 +1,73 @@ +// 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, verify_public_trailer_digest, 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)); +} + +// 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)); +} diff --git a/userland/toolkit/image_tests/.gitignore b/userland/toolkit/image_tests/.gitignore new file mode 100644 index 0000000000..2f7896d1d1 --- /dev/null +++ b/userland/toolkit/image_tests/.gitignore @@ -0,0 +1 @@ +target/ diff --git a/userland/toolkit/image_tests/Cargo.lock b/userland/toolkit/image_tests/Cargo.lock new file mode 100644 index 0000000000..7fdc3fc168 --- /dev/null +++ b/userland/toolkit/image_tests/Cargo.lock @@ -0,0 +1,100 @@ +# 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 = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "nonos_toolkit" +version = "0.3.0" +dependencies = [ + "ab_glyph", + "nonos_userland_libc", + "spin", +] + +[[package]] +name = "nonos_userland_libc" +version = "0.3.0" + +[[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 = "toolkit_image_tests" +version = "0.1.0" +dependencies = [ + "nonos_toolkit", +] + +[[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/toolkit/image_tests/Cargo.toml b/userland/toolkit/image_tests/Cargo.toml new file mode 100644 index 0000000000..f048737364 --- /dev/null +++ b/userland/toolkit/image_tests/Cargo.toml @@ -0,0 +1,21 @@ +# NONOS userland: toolkit image tests +# +# Host-only harness for the toolkit's image decoders. A sibling crate so +# `cargo test` never builds the toolkit bin, which needs the no_std runtime. + +[package] +name = "toolkit_image_tests" +version = "0.1.0" +edition = "2021" +publish = false +license = "AGPL-3.0" +description = "Host-side decoder fidelity tests for the NONOS toolkit" + +[lib] +name = "toolkit_image_tests" +path = "src/lib.rs" + +[dependencies] +nonos_toolkit = { path = "..", default-features = false } + +[workspace] diff --git a/userland/toolkit/image_tests/src/lib.rs b/userland/toolkit/image_tests/src/lib.rs new file mode 100644 index 0000000000..eea2eae5c5 --- /dev/null +++ b/userland/toolkit/image_tests/src/lib.rs @@ -0,0 +1 @@ +// Tests live in `tests/`; this crate only exists to host them. diff --git a/userland/toolkit/image_tests/tests/fixtures/edge16.jpg b/userland/toolkit/image_tests/tests/fixtures/edge16.jpg new file mode 100644 index 0000000000..b4f6cc1435 Binary files /dev/null and b/userland/toolkit/image_tests/tests/fixtures/edge16.jpg differ diff --git a/userland/toolkit/image_tests/tests/fixtures/edge16.rgb b/userland/toolkit/image_tests/tests/fixtures/edge16.rgb new file mode 100644 index 0000000000..b6a3534946 Binary files /dev/null and b/userland/toolkit/image_tests/tests/fixtures/edge16.rgb differ diff --git a/userland/toolkit/image_tests/tests/jpeg_fidelity.rs b/userland/toolkit/image_tests/tests/jpeg_fidelity.rs new file mode 100644 index 0000000000..0fd6dfeadb --- /dev/null +++ b/userland/toolkit/image_tests/tests/jpeg_fidelity.rs @@ -0,0 +1,21 @@ +// A sharp-edged baseline JPEG must decode to within a few levels of a reference decoder. +// Reading the quantisation table in the wrong order rings at every edge. + +const JPEG: &[u8] = include_bytes!("fixtures/edge16.jpg"); +// The same file decoded by Pillow (libjpeg), RGB rows. +const REFERENCE: &[u8] = include_bytes!("fixtures/edge16.rgb"); + +#[test] +fn edge_matches_reference() { + let mut px = [0u32; 256]; + let size = nonos_toolkit::image::jpeg::decode_jpeg_argb8888(JPEG, &mut px); + assert!(size.is_ok()); + let mut worst = 0i32; + for (p, want) in px.iter().zip(REFERENCE.chunks(3)) { + let got = [(p >> 16) as u8, (p >> 8) as u8, *p as u8]; + for c in 0..3 { + worst = worst.max((got[c] as i32 - want[c] as i32).abs()); + } + } + assert!(worst <= 4, "worst channel error {worst}"); +} diff --git a/userland/toolkit/image_tests/tests/scale_cover.rs b/userland/toolkit/image_tests/tests/scale_cover.rs new file mode 100644 index 0000000000..dae39b439b --- /dev/null +++ b/userland/toolkit/image_tests/tests/scale_cover.rs @@ -0,0 +1,76 @@ +// The wallpaper scaler: exact at 1:1, flat colours stay flat, shrinking +// averages by area, enlarging interpolates, and a different aspect crops. +use nonos_toolkit::image::scale::scale_cover; + +fn run(src: &[u32], sw: u32, sh: u32, dw: u32, dh: u32) -> Vec { + let mut out = vec![0u32; (dw * dh) as usize]; + let ok = scale_cover(src, sw, sh, dw, dh, |y, row| { + out[(y * dw) as usize..((y + 1) * dw) as usize].copy_from_slice(row); + }); + assert!(ok); + out +} + +fn grey(v: u32) -> u32 { + 0xFF00_0000 | (v << 16) | (v << 8) | v +} + +#[test] +fn identity_is_exact() { + let src: Vec = (0..64u32 * 36).map(|i| grey((i * 7) % 256)).collect(); + assert_eq!(run(&src, 64, 36, 64, 36), src); +} + +#[test] +fn flat_stays_flat_at_every_size() { + let src = vec![0xFF12_3456u32; 192 * 108]; + for (dw, dh) in [(128, 72), (137, 77), (384, 216), (160, 100), (100, 160)] { + assert!(run(&src, 192, 108, dw, dh).iter().all(|&p| p == 0xFF12_3456)); + } +} + +#[test] +fn halving_averages_each_pair() { + // Columns alternate 0 and 200: every destination pixel covers one of each. + let src: Vec = (0..8u32 * 4).map(|i| grey(if i % 2 == 0 { 0 } else { 200 })).collect(); + assert!(run(&src, 8, 4, 4, 2).iter().all(|&p| p == grey(100))); +} + +#[test] +fn shrinking_by_one_and_a_half_weights_by_coverage() { + // 3 cells to 2: the first covers cell 0 and half of cell 1. + // 3 by 3 to 2 by 2 keeps the aspect; every row is the same. + let line = [grey(0), grey(90), grey(240)]; + let src: Vec = line.iter().cycle().take(9).copied().collect(); + // (0 + 90 / 2) / 1.5 = 30, (90 / 2 + 240) / 1.5 = 190. + assert_eq!(run(&src, 3, 3, 2, 2), vec![grey(30), grey(190), grey(30), grey(190)]); +} + +#[test] +fn doubling_interpolates_between_centres() { + let src = [grey(0), grey(200)]; + // Destination centres sit at 0.25 and 0.75 of the gap between cells. + assert_eq!(run(&src, 2, 1, 4, 1), vec![grey(0), grey(50), grey(150), grey(200)]); +} + +#[test] +fn other_aspect_crops_evenly_without_stretch() { + // 16:9 source with a 2-pixel white stripe in the middle columns. + let (sw, sh) = (32u32, 18u32); + let src: Vec = + (0..sw * sh).map(|i| grey(if (15..17).contains(&(i % sw)) { 255 } else { 0 })).collect(); + // 1:1 target: height is kept, 7 columns cut from each side. + let out = run(&src, sw, sh, 18, 18); + for y in 0..18 { + let row = &out[y * 18..(y + 1) * 18]; + assert_eq!(row[8], grey(255)); + assert_eq!(row[9], grey(255)); + assert_eq!(row[7], grey(0)); + assert_eq!(row[10], grey(0)); + } +} + +#[test] +fn short_source_is_refused() { + assert!(!scale_cover(&[0u32; 10], 4, 4, 2, 2, |_, _| {})); +} diff --git a/userland/toolkit/src/icons/all.rs b/userland/toolkit/src/icons/all.rs index 58753ce2b5..568e975a46 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; 48] = [ + pub const ALL: [IconId; 49] = [ 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 07e05d703d..86a0800a33 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 74ef86d123..b1d10e508a 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 be13056109..d9a518f355 100644 --- a/userland/toolkit/src/icons/table.rs +++ b/userland/toolkit/src/icons/table.rs @@ -17,7 +17,7 @@ /// 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]; 48] = [ +pub(super) const MASKS: [&[u8]; 49] = [ include_bytes!("../../../assets/icons/about.a8"), include_bytes!("../../../assets/icons/audio_player.a8"), include_bytes!("../../../assets/icons/browser.a8"), @@ -31,6 +31,7 @@ pub(super) const MASKS: [&[u8]; 48] = [ 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/src/image/jpeg/dqt.rs b/userland/toolkit/src/image/jpeg/dqt.rs index b36a1bacf9..c144be083b 100644 --- a/userland/toolkit/src/image/jpeg/dqt.rs +++ b/userland/toolkit/src/image/jpeg/dqt.rs @@ -14,6 +14,7 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . +use crate::image::jpeg::zigzag::ZIGZAG; use crate::image::types::DecodeError; pub const MAX_QT: usize = 4; @@ -51,20 +52,13 @@ pub fn parse_dqt(seg: &[u8], tables: &mut [QuantTable; MAX_QT]) -> Result<(), De return Err(DecodeError::Truncated); } let mut t = QuantTable::new(); - if pq == 0 { - let mut i = 0usize; - while i < 64 { - t.values[i] = seg[p + i] as u16; - i += 1; - } - } else { - let mut i = 0usize; - while i < 64 { - let hi = seg[p + i * 2] as u16; - let lo = seg[p + i * 2 + 1] as u16; - t.values[i] = (hi << 8) | lo; - i += 1; - } + // The file lists the table in zigzag order; keep it in natural order. + for (i, &zi) in ZIGZAG.iter().enumerate() { + t.values[zi] = if pq == 0 { + seg[p + i] as u16 + } else { + ((seg[p + i * 2] as u16) << 8) | seg[p + i * 2 + 1] as u16 + }; } t.present = true; tables[tq] = t; diff --git a/userland/toolkit/src/image/mod.rs b/userland/toolkit/src/image/mod.rs index f979140324..07a86fc472 100644 --- a/userland/toolkit/src/image/mod.rs +++ b/userland/toolkit/src/image/mod.rs @@ -3,4 +3,5 @@ pub mod gif; pub mod jpeg; pub mod lz4_raw; pub mod png; +pub mod scale; pub mod types; diff --git a/userland/toolkit/src/image/scale/area.rs b/userland/toolkit/src/image/scale/area.rs new file mode 100644 index 0000000000..f05c3a4ce7 --- /dev/null +++ b/userland/toolkit/src/image/scale/area.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 . + +use alloc::vec; +use alloc::vec::Vec; + +use super::axis::Axis; +use super::normalize::normalize; + +const ONE: u64 = 1 << 16; + +// Box filter with fractional coverage of the first and last cells. +pub fn area(src: u32, off: u64, span: u64, dst: u32, taps: usize) -> Axis { + let mut start = Vec::with_capacity(dst as usize); + let mut weight = vec![0u16; dst as usize * taps]; + let mut raw = vec![0u64; taps]; + let last_cell = src as u64 - 1; + for i in 0..dst as u64 { + let lo = off + (i * span) / dst as u64; + let hi = (off + ((i + 1) * span) / dst as u64).max(lo + 1); + let first = (lo / ONE).min(last_cell); + for (k, r) in raw.iter_mut().enumerate() { + let cell = first + k as u64; + let c0 = cell * ONE; + let a = lo.max(c0); + let b = hi.min(c0 + ONE); + *r = if cell <= last_cell && b > a { b - a } else { 0 }; + } + let at = i as usize * taps; + normalize(&raw, &mut weight[at..at + taps]); + start.push(first as u32); + } + Axis { start, weight, taps } +} diff --git a/userland/toolkit/src/image/scale/axis.rs b/userland/toolkit/src/image/scale/axis.rs new file mode 100644 index 0000000000..a8bc451457 --- /dev/null +++ b/userland/toolkit/src/image/scale/axis.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 alloc::vec::Vec; + +use super::{area, linear}; + +// Fixed-point unit of one tap weight; every destination's weights sum to it. +pub const UNIT: u32 = 1 << 12; + +// Filter taps along one axis: destination `i` reads `taps` source cells +// starting at `start[i]`, weighted by `weight[i * taps ..]`. +pub struct Axis { + pub start: Vec, + pub weight: Vec, + pub taps: usize, +} + +// `off` and `span` are the source window in 16.16 pixels, inside `src`. +pub fn axis(src: u32, off: u64, span: u64, dst: u32) -> Option { + if src == 0 || dst == 0 || span == 0 || off + span > (src as u64) << 16 { + return None; + } + if span > (dst as u64) << 16 { + // Shrinking: average every source cell a destination pixel covers. + let taps = (span / dst as u64 >> 16) as usize + 2; + Some(area::area(src, off, span, dst, taps)) + } else { + // Enlarging or 1:1: interpolate between the two nearest centres. + Some(linear::linear(src, off, span, dst)) + } +} diff --git a/userland/toolkit/src/image/scale/cover.rs b/userland/toolkit/src/image/scale/cover.rs new file mode 100644 index 0000000000..348c167f79 --- /dev/null +++ b/userland/toolkit/src/image/scale/cover.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 . + +use alloc::vec; + +use super::axis::axis; +use super::window::cover_window; + +// Scale `src` (ARGB rows, `sw` by `sh`) to fill `dw` by `dh` at its own +// aspect, cropping the overflow evenly. Rows go to `put_row` top to bottom. +pub fn scale_cover( + src: &[u32], + sw: u32, + sh: u32, + dw: u32, + dh: u32, + mut put_row: impl FnMut(u32, &[u32]), +) -> bool { + let need = (sw as usize).checked_mul(sh as usize); + if need.is_none_or(|n| n == 0 || src.len() < n) || dw == 0 || dh == 0 { + return false; + } + let (x0, y0, cw, ch) = cover_window(sw, sh, dw, dh); + let (Some(ax), Some(ay)) = (axis(sw, x0, cw, dw), axis(sh, y0, ch, dh)) else { + return false; + }; + let mut acc = vec![0u64; dw as usize * 3]; + let mut row = vec![0u32; dw as usize]; + for dy in 0..dh as usize { + acc.iter_mut().for_each(|a| *a = 0); + for ty in 0..ay.taps { + let wy = ay.weight[dy * ay.taps + ty] as u64; + let sy = ay.start[dy] as usize + ty; + if wy == 0 || sy >= sh as usize { + continue; + } + let line = &src[sy * sw as usize..(sy + 1) * sw as usize]; + super::cover_row::accumulate(line, &ax, wy, &mut acc); + } + for (px, a) in row.iter_mut().zip(acc.chunks(3)) { + // Two 12-bit weights multiplied: 24 fractional bits, rounded. + let ch = |v: u64| ((v + (1 << 23)) >> 24).min(255) as u32; + *px = 0xFF00_0000 | (ch(a[0]) << 16) | (ch(a[1]) << 8) | ch(a[2]); + } + put_row(dy as u32, &row); + } + true +} diff --git a/userland/toolkit/src/image/scale/cover_row.rs b/userland/toolkit/src/image/scale/cover_row.rs new file mode 100644 index 0000000000..4f1f8ee1b4 --- /dev/null +++ b/userland/toolkit/src/image/scale/cover_row.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::axis::Axis; + +// Add one source line, filtered horizontally and weighted by `wy`, into +// the destination row's per-channel accumulators. +pub fn accumulate(line: &[u32], ax: &Axis, wy: u64, acc: &mut [u64]) { + for (dx, out) in acc.chunks_mut(3).enumerate() { + let (mut r, mut g, mut b) = (0u32, 0u32, 0u32); + let first = ax.start[dx] as usize; + let weights = &ax.weight[dx * ax.taps..(dx + 1) * ax.taps]; + for (k, &w) in weights.iter().enumerate() { + let Some(&p) = line.get(first + k) else { + break; + }; + let w = w as u32; + r += w * ((p >> 16) & 0xFF); + g += w * ((p >> 8) & 0xFF); + b += w * (p & 0xFF); + } + out[0] += r as u64 * wy; + out[1] += g as u64 * wy; + out[2] += b as u64 * wy; + } +} diff --git a/userland/toolkit/src/image/scale/linear.rs b/userland/toolkit/src/image/scale/linear.rs new file mode 100644 index 0000000000..0ef6dcff8b --- /dev/null +++ b/userland/toolkit/src/image/scale/linear.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 . + +use alloc::vec::Vec; + +use super::axis::{Axis, UNIT}; + +const ONE: u64 = 1 << 16; + +// Bilinear taps between the two source centres around each destination centre. +pub fn linear(src: u32, off: u64, span: u64, dst: u32) -> Axis { + let mut start = Vec::with_capacity(dst as usize); + let mut weight = Vec::with_capacity(dst as usize * 2); + // Centres of the first and last source cells inside the window. + let lo = (off / ONE) * ONE; + let hi = ((off + span - 1) / ONE).min(src as u64 - 1) * ONE; + for i in 0..dst as u64 { + // Destination centre in source space, less half a cell to reach + // the coordinate system of cell centres. + let c = off + ((2 * i + 1) * span) / (2 * dst as u64); + let s = c.saturating_sub(ONE / 2).clamp(lo, hi); + let base = s / ONE; + let frac = ((s % ONE) * UNIT as u64 / ONE) as u16; + let next_ok = base * ONE < hi; + start.push(base as u32); + if next_ok { + weight.push(UNIT as u16 - frac); + weight.push(frac); + } else { + weight.push(UNIT as u16); + weight.push(0); + } + } + Axis { start, weight, taps: 2 } +} diff --git a/userland/toolkit/src/image/scale/mod.rs b/userland/toolkit/src/image/scale/mod.rs new file mode 100644 index 0000000000..6aa2348f48 --- /dev/null +++ b/userland/toolkit/src/image/scale/mod.rs @@ -0,0 +1,26 @@ +// 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 . + +pub mod area; +pub mod axis; +pub mod cover; +pub mod cover_row; +pub mod linear; +pub mod normalize; +pub mod window; + +pub use axis::Axis; +pub use cover::scale_cover; diff --git a/userland/toolkit/src/image/scale/normalize.rs b/userland/toolkit/src/image/scale/normalize.rs new file mode 100644 index 0000000000..3ebfde1771 --- /dev/null +++ b/userland/toolkit/src/image/scale/normalize.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::axis::UNIT; + +// Scale raw weights so they sum to exactly UNIT: a flat colour stays flat. +pub fn normalize(raw: &[u64], out: &mut [u16]) { + let total: u64 = raw.iter().sum(); + if total == 0 { + if let Some(first) = out.first_mut() { + *first = UNIT as u16; + } + return; + } + let mut given = 0u32; + let mut largest = 0usize; + for (k, (&r, w)) in raw.iter().zip(out.iter_mut()).enumerate() { + *w = ((r * UNIT as u64) / total) as u16; + given += *w as u32; + if r > raw[largest] { + largest = k; + } + } + // Rounding down leaves a remainder; the heaviest tap absorbs it. + out[largest] += (UNIT - given) as u16; +} diff --git a/userland/toolkit/src/image/scale/window.rs b/userland/toolkit/src/image/scale/window.rs new file mode 100644 index 0000000000..81895fef84 --- /dev/null +++ b/userland/toolkit/src/image/scale/window.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 part of the source that fills the destination at its own aspect, +// centred, in 16.16 source pixels: (x0, y0, width, height). +pub fn cover_window(sw: u32, sh: u32, dw: u32, dh: u32) -> (u64, u64, u64, u64) { + let (sw, sh, dw, dh) = (sw as u64, sh as u64, dw as u64, dh as u64); + let full_w = sw << 16; + let full_h = sh << 16; + if sw * dh > sh * dw { + // Source is wider: keep its height, crop the sides. + let cw = (full_h * dw) / dh; + ((full_w - cw) / 2, 0, cw, full_h) + } else { + let ch = (full_w * dh) / dw; + (0, (full_h - ch) / 2, full_w, ch) + } +} diff --git a/userland/toolkit/tests/host/icon_table.rs b/userland/toolkit/tests/host/icon_table.rs index 2fdbdbef2e..fbd40ea804 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() != 49 { + println!("expected 49 icons, got {}", all.len()); fail += 1; } for (i, a) in all.iter().enumerate() { diff --git a/userland/virtio_gpu_proofs/src/tests/modern_tests.rs b/userland/virtio_gpu_proofs/src/tests/modern_tests.rs index 3005ad6074..d6140b0e4a 100644 --- a/userland/virtio_gpu_proofs/src/tests/modern_tests.rs +++ b/userland/virtio_gpu_proofs/src/tests/modern_tests.rs @@ -18,7 +18,8 @@ use crate::constants::{ FEATURE_PAGE_HIGH, MOD_DEVICE_STATUS, MOD_DRIVER_FEATURE, MOD_DRIVER_FEATURE_SELECT, - VIRTIO_F_VERSION_1_HIGH, VIRTIO_GPU_F_EDID, VIRTIO_GPU_F_VIRGL, VIRTIO_GPU_MODERN, + VIRTIO_F_ACCESS_PLATFORM_HIGH, VIRTIO_F_VERSION_1_HIGH, VIRTIO_GPU_F_EDID, VIRTIO_GPU_F_VIRGL, + VIRTIO_GPU_MODERN, }; use crate::init::bring_up; use crate::tests::model::{modern_regs, modern_window, QUEUE_SIZE, REGION_PHYS}; @@ -34,6 +35,19 @@ fn a_part_offering_both_features_ends_live_with_both_accepted() { assert_eq!(bar.wrote8(MOD_DEVICE_STATUS), LIVE); assert!(out.virgl && out.edid); assert_eq!(bar.wrote32(MOD_DRIVER_FEATURE_SELECT), FEATURE_PAGE_HIGH, "high page acked last"); + // The model shows one feature word for both pages, so bits 0 and 1 in the + // high page are VERSION_1 and ACCESS_PLATFORM: both offered, both taken. + assert_eq!( + bar.wrote32(MOD_DRIVER_FEATURE), + VIRTIO_F_VERSION_1_HIGH | VIRTIO_F_ACCESS_PLATFORM_HIGH + ); +} + +#[test] +fn access_platform_is_taken_only_when_offered() { + // Bit 0 alone: the high page offers VERSION_1 and not ACCESS_PLATFORM. + let bar = modern_window(VIRTIO_GPU_F_VIRGL, QUEUE_SIZE); + bring_up(modern_regs(&bar), REGION_PHYS, VIRTIO_GPU_MODERN).expect("live"); assert_eq!(bar.wrote32(MOD_DRIVER_FEATURE), VIRTIO_F_VERSION_1_HIGH); } diff --git a/userland/xz/Cargo.lock b/userland/xz/Cargo.lock new file mode 100644 index 0000000000..49c2d85041 --- /dev/null +++ b/userland/xz/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_hash" +version = "0.1.0" + +[[package]] +name = "nonos_xz" +version = "0.1.0" +dependencies = [ + "nonos_hash", +] diff --git a/userland/xz/Cargo.toml b/userland/xz/Cargo.toml new file mode 100644 index 0000000000..4d9aabbf58 --- /dev/null +++ b/userland/xz/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "nonos_xz" +version = "0.1.0" +edition = "2021" +authors = ["NONOS Contributors"] +license = "AGPL-3.0" +description = "xz and LZMA2 decoding for NONOS userland" + +[lib] +name = "nonos_xz" +path = "src/lib.rs" + +[dependencies] +nonos_hash = { path = "../nonos_hash" } diff --git a/userland/xz/src/block.rs b/userland/xz/src/block.rs new file mode 100644 index 0000000000..984a7c87fe --- /dev/null +++ b/userland/xz/src/block.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 . + +//! One block: header, LZMA2 data, padding to four bytes, and the check. + +use alloc::vec::Vec; + +use super::block_header::header; +use super::check::Check; +use super::lzma2::lzma2; + +/// What the index must say about a block: its unpadded and output sizes. +pub struct Sizes { + pub unpadded: u64, + pub uncompressed: u64, +} + +/// Decode a block into `out`; the bytes it took, and its sizes. +pub fn block(d: &[u8], check: Check, out: &mut Vec) -> Option<(usize, Sizes)> { + let h = header(d)?; + let start = out.len(); + let used = lzma2(d.get(h.size..)?, h.dict, out)?; + let produced = (out.len() - start) as u64; + if h.compressed.is_some_and(|c| c != used as u64) + || h.uncompressed.is_some_and(|u| u != produced) + { + return None; + } + let mut at = h.size + used; + while at % 4 != 0 { + if *d.get(at)? != 0 { + return None; + } + at += 1; + } + let stored = d.get(at..at + check.size())?; + if !check.holds(&out[start..], stored) { + return None; + } + let unpadded = (h.size + used + check.size()) as u64; + Some((at + check.size(), Sizes { unpadded, uncompressed: produced })) +} diff --git a/userland/xz/src/block_header.rs b/userland/xz/src/block_header.rs new file mode 100644 index 0000000000..72b891d56d --- /dev/null +++ b/userland/xz/src/block_header.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 . + +//! A block header (xz file format 3.1): its sizes, if declared, and the one +//! filter this decoder reads, LZMA2, with its dictionary size. + +use super::crc32::matches; +use super::varint::varint; + +const LZMA2: u64 = 0x21; + +pub struct Header { + pub size: usize, + pub compressed: Option, + pub uncompressed: Option, + pub dict: u32, +} + +pub fn header(d: &[u8]) -> Option
{ + let size = (usize::from(*d.first()?) + 1) * 4; + let h = d.get(..size)?; + if !matches(&h[..size - 4], &h[size - 4..]) { + return None; + } + let flags = h[1]; + // One filter only, and the reserved bits clear. + if flags & 0x3F != 0 { + return None; + } + let mut at = 2; + let mut field = |present: bool| -> Option> { + if !present { + return Some(None); + } + let (v, n) = varint(h.get(at..size - 4)?)?; + at += n; + Some(Some(v)) + }; + let compressed = field(flags & 0x40 != 0)?; + let uncompressed = field(flags & 0x80 != 0)?; + let (id, n) = varint(h.get(at..size - 4)?)?; + let (props, m) = varint(h.get(at + n..size - 4)?)?; + at += n + m; + if id != LZMA2 || props != 1 { + return None; + } + let bits = *h.get(at)?; + if bits > 40 { + return None; + } + let dict = match bits { + 40 => u32::MAX, + b => (2 | u32::from(b & 1)) << (b / 2 + 11), + }; + // What is left before the CRC is padding, and must be zero. + h[at + 1..size - 4].iter().all(|&b| b == 0).then_some(Header { + size, + compressed, + uncompressed, + dict, + }) +} diff --git a/userland/xz/src/check.rs b/userland/xz/src/check.rs new file mode 100644 index 0000000000..270cdb4610 --- /dev/null +++ b/userland/xz/src/check.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 . + +//! A block's integrity check: none, CRC-32, CRC-64 or SHA-256, as the +//! stream flags name it. Reserved check types are refused. + +use super::crc32::crc32; +use super::crc64::crc64; + +#[derive(Clone, Copy)] +pub enum Check { + None, + Crc32, + Crc64, + Sha256, +} + +impl Check { + pub fn from_flag(id: u8) -> Option { + match id { + 0x00 => Some(Check::None), + 0x01 => Some(Check::Crc32), + 0x04 => Some(Check::Crc64), + 0x0A => Some(Check::Sha256), + _ => None, + } + } + + pub fn size(self) -> usize { + match self { + Check::None => 0, + Check::Crc32 => 4, + Check::Crc64 => 8, + Check::Sha256 => 32, + } + } + + /// `stored` is exactly `size()` bytes, as the block wrote it. + pub fn holds(self, data: &[u8], stored: &[u8]) -> bool { + match self { + Check::None => stored.is_empty(), + Check::Crc32 => stored == crc32(data).to_le_bytes(), + Check::Crc64 => stored == crc64(data).to_le_bytes(), + Check::Sha256 => stored == nonos_hash::sha256(data), + } + } +} diff --git a/userland/xz/src/crc32.rs b/userland/xz/src/crc32.rs new file mode 100644 index 0000000000..d31a2ab583 --- /dev/null +++ b/userland/xz/src/crc32.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 . + +//! CRC-32 (IEEE, reflected, 0xEDB88320), which guards every header. + +const TABLE: [u32; 256] = table(); + +const fn table() -> [u32; 256] { + let mut t = [0u32; 256]; + let mut n = 0; + while n < 256 { + let mut c = n as u32; + let mut k = 0; + while k < 8 { + c = if c & 1 != 0 { 0xEDB8_8320 ^ (c >> 1) } else { c >> 1 }; + k += 1; + } + t[n] = c; + n += 1; + } + t +} + +pub fn crc32(data: &[u8]) -> u32 { + !data.iter().fold(!0u32, |c, &b| TABLE[((c ^ b as u32) & 0xFF) as usize] ^ (c >> 8)) +} + +/// A little-endian CRC-32 stored after `data`. +pub fn matches(data: &[u8], stored: &[u8]) -> bool { + stored.len() == 4 && crc32(data).to_le_bytes() == stored +} diff --git a/userland/xz/src/crc64.rs b/userland/xz/src/crc64.rs new file mode 100644 index 0000000000..b90470657b --- /dev/null +++ b/userland/xz/src/crc64.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 . + +//! CRC-64/XZ (ECMA-182, reflected, 0xC96C5795D7870F42): xz's default check. + +const TABLE: [u64; 256] = table(); + +const fn table() -> [u64; 256] { + let mut t = [0u64; 256]; + let mut n = 0; + while n < 256 { + let mut c = n as u64; + let mut k = 0; + while k < 8 { + c = if c & 1 != 0 { 0xC96C_5795_D787_0F42 ^ (c >> 1) } else { c >> 1 }; + k += 1; + } + t[n] = c; + n += 1; + } + t +} + +pub fn crc64(data: &[u8]) -> u64 { + !data.iter().fold(!0u64, |c, &b| TABLE[((c ^ b as u64) & 0xFF) as usize] ^ (c >> 8)) +} diff --git a/userland/xz/src/index.rs b/userland/xz/src/index.rs new file mode 100644 index 0000000000..c30a5755b8 --- /dev/null +++ b/userland/xz/src/index.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 index at a stream's end: one record per block, which must agree with +//! what was decoded, then padding and a CRC-32. + +use super::block::Sizes; +use super::crc32::matches; +use super::varint::varint; + +/// The bytes the index took, if it describes exactly `blocks`. +pub fn index(d: &[u8], blocks: &[Sizes]) -> Option { + if *d.first()? != 0 { + return None; + } + let (count, n) = varint(d.get(1..)?)?; + if count != blocks.len() as u64 { + return None; + } + let mut at = 1 + n; + for b in blocks { + let (unpadded, n) = varint(d.get(at..)?)?; + let (uncompressed, m) = varint(d.get(at + n..)?)?; + if unpadded != b.unpadded || uncompressed != b.uncompressed { + return None; + } + at += n + m; + } + while at % 4 != 0 { + if *d.get(at)? != 0 { + return None; + } + at += 1; + } + matches(&d[..at], d.get(at..at + 4)?).then_some(at + 4) +} diff --git a/userland/xz/src/lib.rs b/userland/xz/src/lib.rs new file mode 100644 index 0000000000..ffb7785e28 --- /dev/null +++ b/userland/xz/src/lib.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 . + +//! xz decoding: the .xz container (streams, blocks, index, padding) around +//! LZMA2, with every check type xz writes. Decode only; the one filter read +//! is LZMA2, and a block that names another is refused, never skipped. +//! +//! Every size is bounded before it allocates and every failure is `None`. + +#![no_std] + +extern crate alloc; + +mod block; +mod block_header; +mod check; +mod crc32; +mod crc64; +mod index; +mod limits; +mod lzma2; +mod lzma2_chunk; +mod lzma_copy; +mod lzma_decode; +mod lzma_dist; +mod lzma_len; +mod lzma_literal; +mod lzma_model; +mod lzma_model_new; +mod lzma_rep; +mod range; +mod range_tree; +mod stream; +mod varint; + +pub use limits::MAX_OUT; +pub use stream::decompress; diff --git a/userland/xz/src/limits.rs b/userland/xz/src/limits.rs new file mode 100644 index 0000000000..0d89cde194 --- /dev/null +++ b/userland/xz/src/limits.rs @@ -0,0 +1,20 @@ +// 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 bounds every read is held to. + +/// The most one call may produce, across all streams. +pub const MAX_OUT: usize = 256 * 1024 * 1024; diff --git a/userland/xz/src/lzma2.rs b/userland/xz/src/lzma2.rs new file mode 100644 index 0000000000..61d26bc200 --- /dev/null +++ b/userland/xz/src/lzma2.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 . + +//! LZMA2: chunks that are stored, or LZMA with a declared reset of the +//! dictionary, the state, or the properties, ended by a zero control byte. + +use alloc::vec::Vec; + +use super::limits::MAX_OUT; +use super::lzma2_chunk::{lzma, State}; + +/// Decode into `out`; the bytes the LZMA2 data took. +pub fn lzma2(d: &[u8], dict: u32, out: &mut Vec) -> Option { + let mut st = State { start: out.len(), fresh: false, model: None, dict }; + let mut at = 0usize; + loop { + let control = *d.get(at)?; + at = match control { + 0x00 => return Some(at + 1), + 0x01 | 0x02 => { + if control == 0x01 { + (st.start, st.fresh) = (out.len(), true); + } + let size = + usize::from(u16::from_be_bytes(d.get(at + 1..at + 3)?.try_into().ok()?)) + 1; + out.extend_from_slice(d.get(at + 3..at + 3 + size)?); + at + 3 + size + } + 0x80..=0xFF => lzma(d, at, &mut st, out)?, + _ => return None, + }; + // The first chunk must reset the dictionary; nothing before it is history. + if !st.fresh || out.len() > MAX_OUT { + return None; + } + } +} diff --git a/userland/xz/src/lzma2_chunk.rs b/userland/xz/src/lzma2_chunk.rs new file mode 100644 index 0000000000..95dde12dd4 --- /dev/null +++ b/userland/xz/src/lzma2_chunk.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 . + +//! One LZMA chunk inside LZMA2: its resets, then a fresh range coder over +//! exactly its packed bytes, which must end with the code at zero. + +use alloc::boxed::Box; +use alloc::vec::Vec; + +use super::lzma_decode::chunk; +use super::lzma_model::Model; +use super::range::Range; + +/// What carries from one chunk to the next. +pub struct State { + /// Where the dictionary was last reset. + pub start: usize, + pub fresh: bool, + pub model: Option>, + pub dict: u32, +} + +/// The chunk at `at`; the offset after it. +pub fn lzma(d: &[u8], at: usize, st: &mut State, out: &mut Vec) -> Option { + let head = d.get(at..at + 5)?; + let reset = (head[0] >> 5) & 3; + if reset == 3 { + (st.start, st.fresh) = (out.len(), true); + } + let unpacked = (usize::from(head[0] & 0x1F) << 16) + + usize::from(u16::from_be_bytes([head[1], head[2]])) + + 1; + let packed = usize::from(u16::from_be_bytes([head[3], head[4]])) + 1; + let mut at = at + 5; + if reset >= 2 { + st.model = Some(Box::new(Model::new(*d.get(at)?)?)); + at += 1; + } else if reset == 1 { + st.model.as_mut()?.reset(); + } + if !st.fresh { + return None; + } + let mut rc = Range::new(d.get(at..at + packed)?)?; + chunk(&mut rc, st.model.as_mut()?, out, st.start, unpacked, st.dict)?; + rc.finished().then_some(at + packed) +} diff --git a/userland/xz/src/lzma_copy.rs b/userland/xz/src/lzma_copy.rs new file mode 100644 index 0000000000..c6d3ec9f24 --- /dev/null +++ b/userland/xz/src/lzma_copy.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 . + +//! Copying a match out of the dictionary, which is the output since the +//! last reset. + +use alloc::vec::Vec; + +/// `len` bytes from `rep + 1` back, which must lie inside the dictionary. +pub fn copy(out: &mut Vec, start: usize, rep: u32, len: usize, dict: u32) -> Option<()> { + let back = (rep as usize).checked_add(1)?; + if back > out.len() - start || rep >= dict { + return None; + } + for _ in 0..len { + out.push(out[out.len() - back]); + } + Some(()) +} diff --git a/userland/xz/src/lzma_decode.rs b/userland/xz/src/lzma_decode.rs new file mode 100644 index 0000000000..be3a7de7c8 --- /dev/null +++ b/userland/xz/src/lzma_decode.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 . + +//! One LZMA chunk: literals, matches and repeated matches until exactly +//! `unpacked` bytes have been produced (after Igor Pavlov's LzmaSpec.cpp). + +use alloc::vec::Vec; + +use super::lzma_copy::copy; +use super::lzma_dist::distance; +use super::lzma_literal::literal; +use super::lzma_model::Model; +use super::lzma_rep::{rep, Rep}; +use super::range::Range; + +const REP: usize = 0; + +pub fn chunk( + rc: &mut Range, + m: &mut Model, + out: &mut Vec, + start: usize, + unpacked: usize, + dict: u32, +) -> Option<()> { + let end = out.len().checked_add(unpacked)?; + while out.len() < end { + let pos = (out.len() - start) & ((1 << m.pb) - 1); + let s = m.state; + if rc.bit(&mut m.is_match[(s << 4) + pos]) == 0 { + literal(rc, m, out, start)?; + continue; + } + let len = if rc.bit(&mut m.is_rep[REP][s]) == 1 { + if out.len() == start { + return None; + } + match rep(rc, m, s, pos) { + Rep::Short => { + copy(out, start, m.rep[0], 1, dict)?; + continue; + } + Rep::Len(len) => len, + } + } else { + m.rep = [0, m.rep[0], m.rep[1], m.rep[2]]; + let len = m.len.decode(rc, pos); + m.state = if s < 7 { 7 } else { 10 }; + m.rep[0] = distance(rc, m, len)?; + len + }; + let len = len as usize + 2; + if out.len() + len > end { + return None; + } + copy(out, start, m.rep[0], len, dict)?; + } + Some(()) +} diff --git a/userland/xz/src/lzma_dist.rs b/userland/xz/src/lzma_dist.rs new file mode 100644 index 0000000000..c173f2c008 --- /dev/null +++ b/userland/xz/src/lzma_dist.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 . + +//! Match distances: a six-bit slot per length state, then reverse-coded +//! bits for short ones, direct bits and four aligned bits for long ones. + +use super::lzma_model::Model; +use super::range::Range; + +const END_POS_MODEL: u32 = 14; + +/// The distance less one, or None for the end marker LZMA2 never has. +pub fn distance(rc: &mut Range, m: &mut Model, len: u32) -> Option { + let slot = rc.tree(&mut m.pos_slot[len.min(3) as usize], 6); + if slot < 4 { + return Some(slot); + } + let bits = (slot >> 1) - 1; + let mut dist = (2 | (slot & 1)) << bits; + if slot < END_POS_MODEL { + let base = (dist - slot) as usize; + dist += rc.reverse(&mut m.pos[base..], bits); + } else { + dist = dist.wrapping_add(rc.direct(bits - 4) << 4); + dist = dist.wrapping_add(rc.reverse(&mut m.align, 4)); + } + (dist != u32::MAX).then_some(dist) +} diff --git a/userland/xz/src/lzma_len.rs b/userland/xz/src/lzma_len.rs new file mode 100644 index 0000000000..fdeb630e79 --- /dev/null +++ b/userland/xz/src/lzma_len.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 . + +//! Match lengths: 0 to 7 and 8 to 15 per position state, 16 to 271 shared. + +use super::lzma_model::HALF; +use super::range::Range; + +pub struct Len { + choice: [u16; 2], + low: [[u16; 8]; 16], + mid: [[u16; 8]; 16], + high: [u16; 256], +} + +impl Len { + pub fn new() -> Len { + Len { choice: [HALF; 2], low: [[HALF; 8]; 16], mid: [[HALF; 8]; 16], high: [HALF; 256] } + } + + /// The length less the minimum of two. + pub fn decode(&mut self, rc: &mut Range, pos_state: usize) -> u32 { + if rc.bit(&mut self.choice[0]) == 0 { + return rc.tree(&mut self.low[pos_state], 3); + } + if rc.bit(&mut self.choice[1]) == 0 { + return 8 + rc.tree(&mut self.mid[pos_state], 3); + } + 16 + rc.tree(&mut self.high, 8) + } +} diff --git a/userland/xz/src/lzma_literal.rs b/userland/xz/src/lzma_literal.rs new file mode 100644 index 0000000000..c7312b1bea --- /dev/null +++ b/userland/xz/src/lzma_literal.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 . + +//! A literal: eight bits under the probabilities its position and the byte +//! before it choose, matched against the byte at rep0 after a match. + +use alloc::vec::Vec; + +use super::lzma_model::Model; +use super::range::Range; + +/// `start` is where the dictionary was last reset; positions count from it. +pub fn literal(rc: &mut Range, m: &mut Model, out: &mut Vec, start: usize) -> Option<()> { + let total = out.len() - start; + let prev = if total > 0 { usize::from(out[out.len() - 1]) } else { 0 }; + let state = ((total & ((1 << m.lp) - 1)) << m.lc) + (prev >> (8 - m.lc)); + let probs = m.literal.get_mut(0x300 * state..0x300 * (state + 1))?; + let mut sym = 1usize; + if m.state >= 7 { + let back = (m.rep[0] as usize).checked_add(1).filter(|&d| d <= total)?; + let mut byte = u32::from(out[out.len() - back]); + while sym < 0x100 { + let bit_m = (byte >> 7) & 1; + byte <<= 1; + let bit = rc.bit(&mut probs[((1 + bit_m as usize) << 8) + sym]); + sym = sym << 1 | bit as usize; + if bit != bit_m { + break; + } + } + } + while sym < 0x100 { + sym = sym << 1 | rc.bit(&mut probs[sym]) as usize; + } + out.push((sym - 0x100) as u8); + m.state = match m.state { + 0..=3 => 0, + 4..=9 => m.state - 3, + _ => m.state - 6, + }; + Some(()) +} diff --git a/userland/xz/src/lzma_model.rs b/userland/xz/src/lzma_model.rs new file mode 100644 index 0000000000..c2d5896a79 --- /dev/null +++ b/userland/xz/src/lzma_model.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 . + +//! LZMA's adaptive probabilities, and the state they are read in. + +use alloc::vec::Vec; + +use super::lzma_len::Len; + +pub const HALF: u16 = 1024; + +pub struct Model { + pub lc: u32, + pub lp: u32, + pub pb: u32, + pub state: usize, + pub rep: [u32; 4], + pub is_match: [u16; 192], + pub is_rep: [[u16; 12]; 4], + pub is_rep0_long: [u16; 192], + pub pos_slot: [[u16; 64]; 4], + pub pos: [u16; 115], + pub align: [u16; 16], + pub len: Len, + pub rep_len: Len, + pub literal: Vec, +} diff --git a/userland/xz/src/lzma_model_new.rs b/userland/xz/src/lzma_model_new.rs new file mode 100644 index 0000000000..52cbe37399 --- /dev/null +++ b/userland/xz/src/lzma_model_new.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 . + +//! Making a model from its properties byte, and resetting one. + +use alloc::vec; +use alloc::vec::Vec; + +use super::lzma_len::Len; +use super::lzma_model::{Model, HALF}; + +impl Model { + /// From the properties byte, `(pb * 5 + lp) * 9 + lc`; LZMA2 holds + /// lc + lp to four. + pub fn new(props: u8) -> Option { + let (lc, lp, pb) = (u32::from(props % 9), u32::from(props / 9 % 5), u32::from(props / 45)); + if pb > 4 || lc + lp > 4 { + return None; + } + Some(Model::fresh(lc, lp, pb, vec![HALF; 0x300 << (lc + lp)])) + } + + /// A state reset: every probability back to even, no history. + pub fn reset(&mut self) { + let mut literal = core::mem::take(&mut self.literal); + literal.fill(HALF); + *self = Model::fresh(self.lc, self.lp, self.pb, literal); + } + + fn fresh(lc: u32, lp: u32, pb: u32, literal: Vec) -> Model { + Model { + lc, + lp, + pb, + state: 0, + rep: [0; 4], + is_match: [HALF; 192], + is_rep: [[HALF; 12]; 4], + is_rep0_long: [HALF; 192], + pos_slot: [[HALF; 64]; 4], + pos: [HALF; 115], + align: [HALF; 16], + len: Len::new(), + rep_len: Len::new(), + literal, + } + } +} diff --git a/userland/xz/src/lzma_rep.rs b/userland/xz/src/lzma_rep.rs new file mode 100644 index 0000000000..8e3a41004a --- /dev/null +++ b/userland/xz/src/lzma_rep.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 . + +//! A repeated match: one of the last four distances again, or a single byte +//! from the last one. + +use super::lzma_model::Model; +use super::range::Range; + +const G0: usize = 1; +const G1: usize = 2; +const G2: usize = 3; + +pub enum Rep { + /// One byte from rep0, no length read. + Short, + /// A length, less two, from rep0 as it now stands. + Len(u32), +} + +pub fn rep(rc: &mut Range, m: &mut Model, s: usize, pos: usize) -> Rep { + if rc.bit(&mut m.is_rep[G0][s]) == 0 { + if rc.bit(&mut m.is_rep0_long[(s << 4) + pos]) == 0 { + m.state = if s < 7 { 9 } else { 11 }; + return Rep::Short; + } + } else { + let dist = if rc.bit(&mut m.is_rep[G1][s]) == 0 { + m.rep[1] + } else { + let d = match rc.bit(&mut m.is_rep[G2][s]) { + 0 => m.rep[2], + _ => { + let d = m.rep[3]; + m.rep[3] = m.rep[2]; + d + } + }; + m.rep[2] = m.rep[1]; + d + }; + m.rep[1] = m.rep[0]; + m.rep[0] = dist; + } + m.state = if s < 7 { 8 } else { 11 }; + Rep::Len(m.rep_len.decode(rc, pos)) +} diff --git a/userland/xz/src/range.rs b/userland/xz/src/range.rs new file mode 100644 index 0000000000..89bf836849 --- /dev/null +++ b/userland/xz/src/range.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 . + +//! LZMA's range decoder. A read past the chunk yields zero; `finished` +//! then fails, so an overrun is caught once, at the chunk's end. + +pub struct Range<'a> { + d: &'a [u8], + pos: usize, + pub(super) range: u32, + pub(super) code: u32, +} + +impl<'a> Range<'a> { + /// A chunk opens with a zero byte and four bytes of code. + pub fn new(d: &'a [u8]) -> Option { + let head = d.get(..5)?; + if head[0] != 0 { + return None; + } + let code = u32::from_be_bytes([head[1], head[2], head[3], head[4]]); + Some(Range { d, pos: 5, range: u32::MAX, code }) + } + + pub(super) fn normalize(&mut self) { + if self.range < 1 << 24 { + let b = self.d.get(self.pos).copied().unwrap_or(0); + self.pos += 1; + self.range <<= 8; + self.code = self.code << 8 | u32::from(b); + } + } + + pub fn bit(&mut self, p: &mut u16) -> u32 { + let bound = (self.range >> 11) * u32::from(*p); + let bit = if self.code < bound { + *p += (2048 - *p) >> 5; + self.range = bound; + 0 + } else { + *p -= *p >> 5; + self.code -= bound; + self.range -= bound; + 1 + }; + self.normalize(); + bit + } + + /// Every byte of the chunk read, none beyond, and the code run to zero. + pub fn finished(&self) -> bool { + self.code == 0 && self.pos == self.d.len() + } +} diff --git a/userland/xz/src/range_tree.rs b/userland/xz/src/range_tree.rs new file mode 100644 index 0000000000..515d8dd7ee --- /dev/null +++ b/userland/xz/src/range_tree.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 . + +//! Direct bits, and bit trees over the range decoder: most significant bit +//! first, and the reverse order the distance and alignment bits use. + +use super::range::Range; + +impl Range<'_> { + pub fn direct(&mut self, n: u32) -> u32 { + let mut v = 0u32; + for _ in 0..n { + self.range >>= 1; + self.code = self.code.wrapping_sub(self.range); + let t = 0u32.wrapping_sub(self.code >> 31); + self.code = self.code.wrapping_add(self.range & t); + self.normalize(); + v = (v << 1).wrapping_add(t.wrapping_add(1)); + } + v + } + + pub fn tree(&mut self, probs: &mut [u16], bits: u32) -> u32 { + let mut m = 1usize; + for _ in 0..bits { + m = (m << 1) + self.bit(&mut probs[m]) as usize; + } + m as u32 - (1 << bits) + } + + pub fn reverse(&mut self, probs: &mut [u16], bits: u32) -> u32 { + let (mut m, mut v) = (1usize, 0u32); + for i in 0..bits { + let bit = self.bit(&mut probs[m]); + m = (m << 1) + bit as usize; + v |= bit << i; + } + v + } +} diff --git a/userland/xz/src/stream.rs b/userland/xz/src/stream.rs new file mode 100644 index 0000000000..e429d38559 --- /dev/null +++ b/userland/xz/src/stream.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 . + +//! Streams, one after another with zero padding between: header, blocks, +//! index, footer. The footer must agree with the header and the index. + +use alloc::vec::Vec; + +use super::block::block; +use super::check::Check; +use super::crc32::matches; +use super::index::index; +use super::limits::MAX_OUT; + +const MAGIC: [u8; 6] = [0xFD, 0x37, 0x7A, 0x58, 0x5A, 0x00]; +const FOOTER_MAGIC: [u8; 2] = *b"YZ"; + +pub fn decompress(data: &[u8]) -> Option> { + let (mut out, mut at) = (Vec::new(), 0usize); + loop { + let h = data.get(at..at + 12)?; + let flags = [h[6], h[7]]; + if h[..6] != MAGIC || flags[0] != 0 || !matches(&flags, &h[8..12]) { + return None; + } + let check = Check::from_flag(flags[1])?; + let mut p = at + 12; + let mut blocks = Vec::new(); + while *data.get(p)? != 0 { + let (used, sizes) = block(&data[p..], check, &mut out)?; + blocks.push(sizes); + p += used; + if out.len() > MAX_OUT { + return None; + } + } + let size = index(&data[p..], &blocks)?; + p += size; + let f = data.get(p..p + 12)?; + let backward = (u64::from(u32::from_le_bytes([f[4], f[5], f[6], f[7]])) + 1) * 4; + if !matches(&f[4..10], &f[..4]) + || backward != size as u64 + || f[8..10] != flags + || f[10..] != FOOTER_MAGIC + { + return None; + } + at = p + 12; + while data.get(at..at + 4) == Some(&[0, 0, 0, 0]) { + at += 4; + } + if at == data.len() { + return Some(out); + } + } +} diff --git a/userland/xz/src/varint.rs b/userland/xz/src/varint.rs new file mode 100644 index 0000000000..b9c5e86b3d --- /dev/null +++ b/userland/xz/src/varint.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 . + +//! xz's multibyte integers: seven bits a byte, low first, at most nine +//! bytes, and never a trailing zero byte. + +pub fn varint(d: &[u8]) -> Option<(u64, usize)> { + let mut v = 0u64; + for (i, &b) in d.iter().enumerate().take(9) { + v |= u64::from(b & 0x7F) << (7 * i); + if b & 0x80 == 0 { + return (i == 0 || b != 0).then_some((v, i + 1)); + } + } + None +} diff --git a/userland/xz/tests/mutate.rs b/userland/xz/tests/mutate.rs new file mode 100644 index 0000000000..4614867652 --- /dev/null +++ b/userland/xz/tests/mutate.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 . + +//! Seeded mutation fuzzing of the xz container and LZMA2, not +//! coverage-guided (no libFuzzer is vendored). Every damaged input must +//! return, in a debug build, without a panic, an overflow or runaway output. + +#[path = "../../zstd/tests/support/damage.rs"] +mod damage; +#[path = "../../zstd/tests/support/rng.rs"] +mod rng; + +use damage::mutate; +use nonos_xz::{decompress, MAX_OUT}; +use rng::Rng; + +const ROUNDS: usize = 2000; +const SMALL: [&str; 6] = ["tiny", "text64k", "none", "concat", "lc4pb4", "dict4k"]; + +#[test] +fn hostile_bytes_never_panic() { + let mut r = Rng::new(0x5EED_0F_A2); + let mut accepted = 0; + for name in SMALL { + let path = format!("{}/tests/vectors/{name}.xz", env!("CARGO_MANIFEST_DIR")); + let clean = std::fs::read(&path).unwrap_or_else(|e| panic!("{path}: {e}")); + for _ in 0..ROUNDS { + let mut v = clean.clone(); + mutate(&mut r, &mut v); + if let Some(out) = decompress(&v) { + assert!(out.len() <= MAX_OUT); + accepted += 1; + } + } + } + println!("accepted {accepted} of {} mutants", SMALL.len() * ROUNDS); +} diff --git a/userland/xz/tests/vectors.rs b/userland/xz/tests/vectors.rs new file mode 100644 index 0000000000..efa5a2153f --- /dev/null +++ b/userland/xz/tests/vectors.rs @@ -0,0 +1,99 @@ +// 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 vector the reference xz wrote decodes to exactly its input, and the +//! ones that must not decode do not. + +#[path = "../../zstd/tests/support/kinds.rs"] +mod kinds; +#[path = "../../zstd/tests/support/rng.rs"] +mod rng; + +use kinds::{mixed, noise, runs, text}; +use nonos_xz::decompress; +use rng::Rng; + +const TABLE: [(&str, &str, u64, usize); 15] = [ + ("empty", "text", 1, 0), + ("tiny", "text", 2, 50), + ("text64k", "text", 3, 65536), + ("text300k", "text", 4, 300000), + ("noise140k", "noise", 5, 140000), + ("zeros300k", "zeros", 6, 300000), + ("runs100k", "runs", 7, 100000), + ("mixed256k", "mixed", 8, 262144), + ("none", "text", 9, 20000), + ("crc32", "text", 10, 20000), + ("sha256", "text", 11, 20000), + ("blocks", "mixed", 12, 200000), + ("lclppb", "text", 13, 100000), + ("lc4pb4", "runs", 14, 100000), + ("dict4k", "text", 15, 100000), +]; + +fn vector(name: &str) -> Vec { + let path = format!("{}/tests/vectors/{name}.xz", env!("CARGO_MANIFEST_DIR")); + std::fs::read(&path).unwrap_or_else(|e| panic!("{path}: {e}")) +} + +fn input(kind: &str, seed: u64, size: usize) -> Vec { + let r = &mut Rng::new(seed); + match kind { + "text" => text(r, size), + "noise" => noise(r, size), + "runs" => runs(r, size), + "mixed" => mixed(r, size), + _ => vec![0; size], + } +} + +#[test] +fn every_vector_round_trips() { + for (name, kind, seed, size) in TABLE { + let got = decompress(&vector(name)).unwrap_or_else(|| panic!("{name}: refused")); + assert!(got == input(kind, seed, size), "{name}: wrong bytes"); + } +} + +#[test] +fn streams_and_their_padding_concatenate() { + let mut want = text(&mut Rng::new(17), 10000); + want.extend(noise(&mut Rng::new(18), 5000)); + assert!(decompress(&vector("concat")) == Some(want)); +} + +#[test] +fn a_filter_other_than_lzma2_is_refused_not_skipped() { + assert!(decompress(&vector("bcj")).is_none()); +} + +#[test] +fn damage_is_caught_by_a_check_or_the_parse() { + for name in ["text64k", "crc32", "sha256", "none"] { + let mut v = vector(name); + let at = v.len() / 2; + v[at] ^= 0x10; + assert!(decompress(&v).is_none(), "{name}"); + } +} + +#[test] +fn truncation_is_refused_at_every_length() { + let v = vector("tiny"); + for n in 0..v.len() { + assert!(decompress(&v[..n]).is_none(), "accepted {n} of {} bytes", v.len()); + } +} diff --git a/userland/xz/tests/vectors/bcj.xz b/userland/xz/tests/vectors/bcj.xz new file mode 100644 index 0000000000..0ac0b509b8 Binary files /dev/null and b/userland/xz/tests/vectors/bcj.xz differ diff --git a/userland/xz/tests/vectors/blocks.xz b/userland/xz/tests/vectors/blocks.xz new file mode 100644 index 0000000000..6d778aead3 Binary files /dev/null and b/userland/xz/tests/vectors/blocks.xz differ diff --git a/userland/xz/tests/vectors/concat.xz b/userland/xz/tests/vectors/concat.xz new file mode 100644 index 0000000000..07b60b5818 Binary files /dev/null and b/userland/xz/tests/vectors/concat.xz differ diff --git a/userland/xz/tests/vectors/crc32.xz b/userland/xz/tests/vectors/crc32.xz new file mode 100644 index 0000000000..eebd741df5 Binary files /dev/null and b/userland/xz/tests/vectors/crc32.xz differ diff --git a/userland/xz/tests/vectors/dict4k.xz b/userland/xz/tests/vectors/dict4k.xz new file mode 100644 index 0000000000..2492e3ad32 Binary files /dev/null and b/userland/xz/tests/vectors/dict4k.xz differ diff --git a/userland/xz/tests/vectors/empty.xz b/userland/xz/tests/vectors/empty.xz new file mode 100644 index 0000000000..ea28d9e05f Binary files /dev/null and b/userland/xz/tests/vectors/empty.xz differ diff --git a/userland/xz/tests/vectors/lc4pb4.xz b/userland/xz/tests/vectors/lc4pb4.xz new file mode 100644 index 0000000000..004e7bcc3a Binary files /dev/null and b/userland/xz/tests/vectors/lc4pb4.xz differ diff --git a/userland/xz/tests/vectors/lclppb.xz b/userland/xz/tests/vectors/lclppb.xz new file mode 100644 index 0000000000..cb3db55bff Binary files /dev/null and b/userland/xz/tests/vectors/lclppb.xz differ diff --git a/userland/xz/tests/vectors/mixed256k.xz b/userland/xz/tests/vectors/mixed256k.xz new file mode 100644 index 0000000000..8715b157a5 Binary files /dev/null and b/userland/xz/tests/vectors/mixed256k.xz differ diff --git a/userland/xz/tests/vectors/noise140k.xz b/userland/xz/tests/vectors/noise140k.xz new file mode 100644 index 0000000000..94977e24e0 Binary files /dev/null and b/userland/xz/tests/vectors/noise140k.xz differ diff --git a/userland/xz/tests/vectors/none.xz b/userland/xz/tests/vectors/none.xz new file mode 100644 index 0000000000..0f3a1b0991 Binary files /dev/null and b/userland/xz/tests/vectors/none.xz differ diff --git a/userland/xz/tests/vectors/runs100k.xz b/userland/xz/tests/vectors/runs100k.xz new file mode 100644 index 0000000000..8a26c75d90 Binary files /dev/null and b/userland/xz/tests/vectors/runs100k.xz differ diff --git a/userland/xz/tests/vectors/sha256.xz b/userland/xz/tests/vectors/sha256.xz new file mode 100644 index 0000000000..bcd1375f8a Binary files /dev/null and b/userland/xz/tests/vectors/sha256.xz differ diff --git a/userland/xz/tests/vectors/text300k.xz b/userland/xz/tests/vectors/text300k.xz new file mode 100644 index 0000000000..c91a5e8e54 Binary files /dev/null and b/userland/xz/tests/vectors/text300k.xz differ diff --git a/userland/xz/tests/vectors/text64k.xz b/userland/xz/tests/vectors/text64k.xz new file mode 100644 index 0000000000..fd0eff88a9 Binary files /dev/null and b/userland/xz/tests/vectors/text64k.xz differ diff --git a/userland/xz/tests/vectors/tiny.xz b/userland/xz/tests/vectors/tiny.xz new file mode 100644 index 0000000000..76a3656fcc Binary files /dev/null and b/userland/xz/tests/vectors/tiny.xz differ diff --git a/userland/xz/tests/vectors/zeros300k.xz b/userland/xz/tests/vectors/zeros300k.xz new file mode 100644 index 0000000000..f0876bdd88 Binary files /dev/null and b/userland/xz/tests/vectors/zeros300k.xz differ diff --git a/userland/zstd/Cargo.lock b/userland/zstd/Cargo.lock new file mode 100644 index 0000000000..daf9b3ef61 --- /dev/null +++ b/userland/zstd/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_zstd" +version = "0.1.0" diff --git a/userland/zstd/Cargo.toml b/userland/zstd/Cargo.toml new file mode 100644 index 0000000000..e1d26616d6 --- /dev/null +++ b/userland/zstd/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "nonos_zstd" +version = "0.1.0" +edition = "2021" +authors = ["NONOS Contributors"] +license = "AGPL-3.0" +description = "Zstandard (RFC 8878) decoding for NONOS userland" + +[lib] +name = "nonos_zstd" +path = "src/lib.rs" + +[dependencies] diff --git a/userland/zstd/src/back.rs b/userland/zstd/src/back.rs new file mode 100644 index 0000000000..304edd1c8f --- /dev/null +++ b/userland/zstd/src/back.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 backward bit stream Huffman and FSE payloads are written as: read from +//! the last byte toward the first, starting below the final byte's highest set +//! bit. Reads past the start yield zeros and are remembered as overflow. + +pub struct Back<'a> { + d: &'a [u8], + /// Bits still unread, counting from the start of `d`; negative once overrun. + left: isize, +} + +impl<'a> Back<'a> { + /// None when the stream is empty or its last byte has no end mark. + pub fn new(d: &'a [u8]) -> Option { + let last = *d.last()?; + if last == 0 { + return None; + } + let mark = 7 - last.leading_zeros() as isize; + Some(Back { d, left: (d.len() as isize - 1) * 8 + mark }) + } + + fn bit(&self, at: isize) -> u64 { + if at < 0 { + return 0; + } + let at = at as usize; + self.d.get(at / 8).map_or(0, |b| u64::from((b >> (at % 8)) & 1)) + } + + /// The next `n` bits (n <= 56), most significant first, without consuming. + pub fn peek(&self, n: u32) -> u64 { + (1..=n as isize).fold(0, |v, i| (v << 1) | self.bit(self.left - i)) + } + + pub fn read(&mut self, n: u32) -> u64 { + let v = self.peek(n); + self.left -= n as isize; + v + } + + /// Every bit read, and none beyond. + pub fn done(&self) -> bool { + self.left == 0 + } + + pub fn overrun(&self) -> bool { + self.left < 0 + } +} diff --git a/userland/zstd/src/block.rs b/userland/zstd/src/block.rs new file mode 100644 index 0000000000..49e4f37878 --- /dev/null +++ b/userland/zstd/src/block.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 . + +//! A frame's blocks, up to and including the one marked last. + +use alloc::vec::Vec; + +use super::context::Context; +use super::limits::{BLOCK_MAX, MAX_OUT}; +use super::literals::literals; +use super::seq_header::header; +use super::seq_run::run; + +const HEADER: usize = 3; + +/// Decode every block into `out`; `start` is where this frame's output began. +/// Returns the bytes the blocks took. +pub fn blocks(d: &[u8], ctx: &mut Context, out: &mut Vec, start: usize) -> Option { + let mut at = 0usize; + loop { + let h = d.get(at..at + HEADER)?; + let word = u32::from(h[0]) | u32::from(h[1]) << 8 | u32::from(h[2]) << 16; + let (last, kind, size) = (word & 1 == 1, (word >> 1) & 3, (word >> 3) as usize); + at += HEADER; + if size > BLOCK_MAX { + return None; + } + match kind { + 0 => { + out.extend_from_slice(d.get(at..at + size)?); + at += size; + } + 1 => { + out.resize(out.len() + size, *d.get(at)?); + at += 1; + } + 2 => { + compressed(d.get(at..at + size)?, ctx, out, start)?; + at += size; + } + _ => return None, + } + if out.len() > MAX_OUT { + return None; + } + if last { + return Some(at); + } + } +} + +fn compressed(d: &[u8], ctx: &mut Context, out: &mut Vec, start: usize) -> Option<()> { + let (lits, used) = literals(d, ctx)?; + let rest = d.get(used..)?; + let (count, used) = header(rest, ctx)?; + let body = rest.get(used..)?; + if count > 0 { + return run(body, count, ctx, &lits, out, start); + } + // No sequences: the literals are the block, and nothing may follow them. + body.is_empty().then(|| out.extend_from_slice(&lits)) +} diff --git a/userland/zstd/src/context.rs b/userland/zstd/src/context.rs new file mode 100644 index 0000000000..72a135647d --- /dev/null +++ b/userland/zstd/src/context.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 . + +//! What one frame's blocks share: the last Huffman table, the last sequence +//! tables for Repeat mode, and the three repeat offsets. + +use super::fse_build::Fse; +use super::huff_build::Huff; + +pub struct Context { + pub huff: Option, + pub ll: Option, + pub of: Option, + pub ml: Option, + pub rep: [usize; 3], +} + +impl Context { + pub fn new() -> Self { + Context { huff: None, ll: None, of: None, ml: None, rep: [1, 4, 8] } + } +} diff --git a/userland/zstd/src/frame.rs b/userland/zstd/src/frame.rs new file mode 100644 index 0000000000..aca749f36b --- /dev/null +++ b/userland/zstd/src/frame.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 stream of frames: Zstandard frames decoded in turn, skippable frames +//! passed over. Each frame's content size and checksum are held to. + +use alloc::vec::Vec; + +use super::block::blocks; +use super::context::Context; +use super::frame_header::header; +use super::xxh64::xxh64; + +const MAGIC: u32 = 0xFD2F_B528; +const SKIPPABLE: u32 = 0x184D_2A50; + +pub fn decompress(data: &[u8]) -> Option> { + let word = |at: usize| { + let b = data.get(at..at.checked_add(4)?)?; + Some(u32::from_le_bytes([b[0], b[1], b[2], b[3]])) + }; + let (mut out, mut at) = (Vec::new(), 0usize); + if data.is_empty() { + return None; + } + while at < data.len() { + let magic = word(at)?; + if magic & 0xFFFF_FFF0 == SKIPPABLE { + let size = word(at + 4)? as usize; + at = at.checked_add(8)?.checked_add(size)?; + if at > data.len() { + return None; + } + continue; + } + if magic != MAGIC { + return None; + } + let h = header(data.get(at + 4..)?)?; + at += 4 + h.used; + let start = out.len(); + at += blocks(data.get(at..)?, &mut Context::new(), &mut out, start)?; + if h.content.is_some_and(|n| n != out.len() - start) { + return None; + } + if h.checksum { + if word(at)? != xxh64(&out[start..], 0) as u32 { + return None; + } + at += 4; + } + } + Some(out) +} diff --git a/userland/zstd/src/frame_header.rs b/userland/zstd/src/frame_header.rs new file mode 100644 index 0000000000..b9f6ab3f0c --- /dev/null +++ b/userland/zstd/src/frame_header.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 . + +//! A frame header (RFC 8878 3.1.1.1), after the magic number. + +use super::limits::MAX_OUT; + +pub struct Header { + pub used: usize, + pub content: Option, + pub checksum: bool, +} + +pub fn header(d: &[u8]) -> Option
{ + let fhd = *d.first()?; + let (fcs, single) = (fhd >> 6, fhd & 0x20 != 0); + // Bit 3 is reserved and must be zero. + if fhd & 0x08 != 0 { + return None; + } + let mut at = 1 + usize::from(!single); + let le = |from: usize, n: usize| { + let b = d.get(from..from + n)?; + Some(b.iter().rev().fold(0u64, |v, &x| v << 8 | u64::from(x))) + }; + let did = [0, 1, 2, 4][usize::from(fhd & 3)]; + // A dictionary is not something this decoder has; refuse, never guess. + if le(at, did)? != 0 { + return None; + } + at += did; + let size = match fcs { + 0 => usize::from(single), + 1 => 2, + 2 => 4, + _ => 8, + }; + let content = match size { + 0 => None, + 2 => Some(le(at, 2)? + 256), + n => Some(le(at, n)?), + }; + if content.is_some_and(|c| c > MAX_OUT as u64) { + return None; + } + Some(Header { + used: at + size, + content: content.map(|c| c as usize), + checksum: fhd & 0x04 != 0, + }) +} diff --git a/userland/zstd/src/fse_build.rs b/userland/zstd/src/fse_build.rs new file mode 100644 index 0000000000..11f2faf932 --- /dev/null +++ b/userland/zstd/src/fse_build.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 . + +//! Building an FSE decoding table from normalized counts (RFC 8878 4.1.1). + +use alloc::vec; +use alloc::vec::Vec; + +pub use super::fse_cell::{Cell, Fse}; + +pub fn build(log: u8, norm: &[i16]) -> Option { + let size = 1usize << log; + let mut cells = vec![Cell::default(); size]; + let mut next: Vec = vec![0; norm.len()]; + // The last cell not yet given to a "less than one" symbol. + let mut high = size as isize - 1; + for (s, &n) in norm.iter().enumerate() { + if n == -1 { + cells.get_mut(usize::try_from(high).ok()?)?.sym = s as u8; + high -= 1; + next[s] = 1; + } else { + next[s] = n.max(0) as u32; + } + } + if high < 0 && norm.iter().any(|&n| n > 0) { + return None; + } + let step = (size >> 1) + (size >> 3) + 3; + let mut pos = 0usize; + for (s, &n) in norm.iter().enumerate() { + for _ in 0..n.max(0) { + cells[pos].sym = s as u8; + pos = (pos + step) & (size - 1); + while pos as isize > high { + pos = (pos + step) & (size - 1); + } + } + } + if pos != 0 { + return None; + } + for c in cells.iter_mut() { + let state = next.get_mut(c.sym as usize)?; + let top = 31u32.checked_sub(state.leading_zeros())?; + let bits = u32::from(log).checked_sub(top)?; + c.bits = bits as u8; + c.base = ((*state << bits) as usize).checked_sub(size)? as u16; + *state += 1; + } + Some(Fse { log, cells }) +} diff --git a/userland/zstd/src/fse_cell.rs b/userland/zstd/src/fse_cell.rs new file mode 100644 index 0000000000..8559a085db --- /dev/null +++ b/userland/zstd/src/fse_cell.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 . + +//! An FSE decoding table: per state, the symbol, how many bits the next +//! state reads, and what those bits are added to. + +use alloc::vec; +use alloc::vec::Vec; + +#[derive(Clone, Copy, Default)] +pub struct Cell { + pub sym: u8, + pub bits: u8, + pub base: u16, +} + +#[derive(Clone)] +pub struct Fse { + pub log: u8, + pub cells: Vec, +} + +impl Fse { + /// A table that always yields `sym` and reads nothing: RLE mode. + pub fn single(sym: u8) -> Fse { + Fse { log: 0, cells: vec![Cell { sym, bits: 0, base: 0 }] } + } +} diff --git a/userland/zstd/src/fse_default.rs b/userland/zstd/src/fse_default.rs new file mode 100644 index 0000000000..5e47d512e0 --- /dev/null +++ b/userland/zstd/src/fse_default.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 predefined distributions of RFC 8878 3.1.1.3.2.2. + +use super::fse_build::{build, Fse}; + +const LL: [i16; 36] = [ + 4, 3, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 1, 1, 1, 2, 2, 2, 2, 2, 2, 2, 2, 2, 3, 2, 1, 1, 1, 1, 1, + -1, -1, -1, -1, +]; + +const ML: [i16; 53] = [ + 1, 4, 3, 2, 2, 2, 2, 2, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, -1, -1, -1, -1, -1, -1, -1, +]; + +const OF: [i16; 29] = + [1, 1, 1, 1, 1, 1, 2, 2, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, -1, -1, -1, -1, -1]; + +pub fn literal_lengths() -> Option { + build(6, &LL) +} + +pub fn match_lengths() -> Option { + build(6, &ML) +} + +pub fn offsets() -> Option { + build(5, &OF) +} diff --git a/userland/zstd/src/fse_read.rs b/userland/zstd/src/fse_read.rs new file mode 100644 index 0000000000..22fa02db5c --- /dev/null +++ b/userland/zstd/src/fse_read.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 . + +//! An FSE table description (RFC 8878 4.1.1): the accuracy log, then one +//! normalized count per symbol, -1 meaning "less than one". + +use alloc::vec::Vec; + +use super::fwd::Fwd; + +/// The accuracy log, the counts, and the bytes the description took. +pub fn counts(d: &[u8], max_log: u8, max_sym: usize) -> Option<(u8, Vec, usize)> { + let mut r = Fwd::new(d); + let log = r.read(4)? as u8 + 5; + if log > max_log { + return None; + } + let mut norm: Vec = Vec::new(); + let mut remaining: i32 = (1 << log) + 1; + let mut threshold: i32 = 1 << log; + let mut bits = u32::from(log) + 1; + let mut after_zero = false; + while remaining > 1 && norm.len() <= max_sym { + if after_zero { + let zeros = r.zero_run()?; + if norm.len() + zeros > max_sym { + return None; + } + norm.resize(norm.len() + zeros, 0); + } + let max = (2 * threshold - 1) - remaining; + let low = r.peek(bits - 1) as i32; + let value = if low < max { + r.skip(bits - 1)?; + low + } else { + let v = r.read(bits)? as i32; + if v >= threshold { + v - max + } else { + v + } + }; + let count = value - 1; + remaining -= count.abs(); + if remaining < 1 { + return None; + } + norm.push(count as i16); + after_zero = count == 0; + while remaining < threshold { + bits -= 1; + threshold >>= 1; + } + } + (remaining == 1).then(|| (log, norm, r.bytes())) +} diff --git a/userland/zstd/src/fwd.rs b/userland/zstd/src/fwd.rs new file mode 100644 index 0000000000..89300e4a18 --- /dev/null +++ b/userland/zstd/src/fwd.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 . + +//! A forward bit reader, least significant bit first, for FSE table +//! descriptions. It never reads past the slice it was given. + +pub struct Fwd<'a> { + d: &'a [u8], + pos: usize, +} + +impl<'a> Fwd<'a> { + pub fn new(d: &'a [u8]) -> Self { + Fwd { d, pos: 0 } + } + + /// The next `n` bits (n <= 24) without consuming them; absent bits read 0. + pub fn peek(&self, n: u32) -> u32 { + let mut v = 0u32; + for i in 0..n { + let at = self.pos + i as usize; + let bit = self.d.get(at / 8).map_or(0, |b| (b >> (at % 8)) & 1); + v |= (bit as u32) << i; + } + v + } + + /// Consume `n` bits; None if that runs past the end. + pub fn skip(&mut self, n: u32) -> Option<()> { + let end = self.pos.checked_add(n as usize)?; + (end <= self.d.len() * 8).then(|| self.pos = end) + } + + pub fn read(&mut self, n: u32) -> Option { + let v = self.peek(n); + self.skip(n).map(|_| v) + } + + /// After a zero count: 2-bit repeat fields, each adding that many zeros, + /// a 3 meaning another field follows. + pub fn zero_run(&mut self) -> Option { + let mut zeros = 0; + loop { + let rep = self.read(2)? as usize; + zeros += rep; + if rep != 3 { + return Some(zeros); + } + } + } + + /// Whole bytes consumed, rounding a partial byte up. + pub fn bytes(&self) -> usize { + self.pos.div_ceil(8) + } +} diff --git a/userland/zstd/src/huff_build.rs b/userland/zstd/src/huff_build.rs new file mode 100644 index 0000000000..c1bf54f0fb --- /dev/null +++ b/userland/zstd/src/huff_build.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 literal decoding table from weights. The last symbol's weight is what +//! brings the total to a power of two; codes are dealt in rising weight, then +//! rising symbol, so the table is indexed by the next `max` bits directly. + +use alloc::vec::Vec; + +use super::limits::HUFF_LOG; + +pub struct Huff { + pub max: u8, + /// Per index: the symbol, and how many bits its code really takes. + pub cells: Vec<(u8, u8)>, +} + +pub fn table(weights: &[u8]) -> Option { + let mut sum: u32 = 0; + for &w in weights { + if w > HUFF_LOG { + return None; + } + if w > 0 { + sum += 1 << (w - 1); + } + } + if sum == 0 { + return None; + } + let max = 32 - sum.leading_zeros(); + if max > u32::from(HUFF_LOG) { + return None; + } + let left = (1u32 << max) - sum; + if !left.is_power_of_two() { + return None; + } + let mut all = weights.to_vec(); + all.push((left.trailing_zeros() + 1) as u8); + let mut cells = Vec::with_capacity(1 << max); + for w in 1..=max { + for (sym, _) in all.iter().enumerate().filter(|(_, &x)| u32::from(x) == w) { + let entry = (sym as u8, (max + 1 - w) as u8); + cells.extend(core::iter::repeat_n(entry, 1 << (w - 1))); + } + } + (cells.len() == 1 << max).then_some(Huff { max: max as u8, cells }) +} diff --git a/userland/zstd/src/huff_stream.rs b/userland/zstd/src/huff_stream.rs new file mode 100644 index 0000000000..5f72dc8135 --- /dev/null +++ b/userland/zstd/src/huff_stream.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 . + +//! Huffman-coded literals, in one stream or in four behind a jump table. + +use alloc::vec::Vec; + +use super::back::Back; +use super::huff_build::Huff; + +const JUMP: usize = 6; + +pub fn decode(h: &Huff, d: &[u8], regen: usize, four: bool) -> Option> { + let mut out = Vec::with_capacity(regen); + if !four { + one(h, d, regen, &mut out)?; + return Some(out); + } + let size = |i: usize| usize::from(u16::from_le_bytes([d[i], d[i + 1]])); + d.get(..JUMP)?; + let (s1, s2, s3) = (size(0), size(2), size(4)); + let s4 = d.len().checked_sub(JUMP)?.checked_sub(s1 + s2 + s3)?; + let seg = regen.div_ceil(4); + let last = regen.checked_sub(3 * seg)?; + let mut at = JUMP; + for (len, n) in [(s1, seg), (s2, seg), (s3, seg), (s4, last)] { + one(h, d.get(at..at + len)?, n, &mut out)?; + at += len; + } + Some(out) +} + +/// A stream must yield exactly `n` symbols and end on its last bit. +fn one(h: &Huff, d: &[u8], n: usize, out: &mut Vec) -> Option<()> { + let mut bits = Back::new(d)?; + for _ in 0..n { + let &(sym, len) = h.cells.get(bits.peek(h.max.into()) as usize)?; + bits.read(len.into()); + if bits.overrun() { + return None; + } + out.push(sym); + } + bits.done().then_some(()) +} diff --git a/userland/zstd/src/huff_weights.rs b/userland/zstd/src/huff_weights.rs new file mode 100644 index 0000000000..98526358e7 --- /dev/null +++ b/userland/zstd/src/huff_weights.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 Huffman tree description (RFC 8878 4.2.1): the weights of every symbol +//! but the last, written directly as nibbles or compressed with FSE. + +use alloc::vec::Vec; + +use super::back::Back; +use super::fse_build::build; +use super::fse_read::counts; +use super::limits::{WEIGHT_LOG, WEIGHT_MAX}; + +/// At most 255 weights are written; the 256th is implied. +const MAX_WEIGHTS: usize = 255; + +/// The weights and the bytes the description took. +pub fn weights(d: &[u8]) -> Option<(Vec, usize)> { + let head = *d.first()?; + if head >= 128 { + let n = usize::from(head - 127); + let packed = d.get(1..1 + n.div_ceil(2))?; + let nibble = |i: usize| match i % 2 { + 0 => packed[i / 2] >> 4, + _ => packed[i / 2] & 0x0F, + }; + return Some(((0..n).map(nibble).collect(), 1 + n.div_ceil(2))); + } + let body = d.get(1..1 + usize::from(head))?; + Some((compressed(body)?, 1 + usize::from(head))) +} + +/// Two FSE states share one backward stream, taking turns; once a state update +/// runs past the start, the other state's symbol is the last. +fn compressed(body: &[u8]) -> Option> { + let (log, norm, used) = counts(body, WEIGHT_LOG, WEIGHT_MAX)?; + let table = build(log, &norm)?; + let mut bits = Back::new(body.get(used..)?)?; + let mut states = [bits.read(log.into()) as usize, bits.read(log.into()) as usize]; + if bits.overrun() { + return None; + } + let mut out = Vec::new(); + for turn in 0.. { + let (me, other) = (turn % 2, 1 - turn % 2); + let cell = *table.cells.get(states[me])?; + out.push(cell.sym); + states[me] = usize::from(cell.base) + bits.read(cell.bits.into()) as usize; + if bits.overrun() { + out.push(table.cells.get(states[other])?.sym); + break; + } + if out.len() > MAX_WEIGHTS { + return None; + } + } + (out.len() <= MAX_WEIGHTS).then_some(out) +} diff --git a/userland/zstd/src/lib.rs b/userland/zstd/src/lib.rs new file mode 100644 index 0000000000..aeffb3c6fe --- /dev/null +++ b/userland/zstd/src/lib.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 . + +//! Zstandard decoding, RFC 8878: every block type, Huffman and FSE, repeat +//! offsets and the content checksum. Decode only; no dictionaries. +//! +//! Every length it reads is bounded before it allocates, and every failure is +//! `None`: a hostile frame yields no output, never a panic. + +#![no_std] + +extern crate alloc; + +mod back; +mod block; +mod context; +mod frame; +mod frame_header; +mod fse_build; +mod fse_cell; +mod fse_default; +mod fse_read; +mod fwd; +mod huff_build; +mod huff_stream; +mod huff_weights; +mod limits; +mod literals; +mod seq_codes; +mod seq_header; +mod seq_rep; +mod seq_run; +mod xxh64; + +pub use frame::decompress; +pub use limits::MAX_OUT; +pub use xxh64::xxh64; diff --git a/userland/zstd/src/limits.rs b/userland/zstd/src/limits.rs new file mode 100644 index 0000000000..40ad1a80ec --- /dev/null +++ b/userland/zstd/src/limits.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 bounds every read is held to. + +/// The most one call may produce, across all frames. +pub const MAX_OUT: usize = 64 * 1024 * 1024; + +/// Block_Maximum_Size: no block regenerates more than this. +pub const BLOCK_MAX: usize = 128 * 1024; + +/// Largest accuracy each table may declare. +pub const LL_LOG: u8 = 9; +pub const ML_LOG: u8 = 9; +pub const OF_LOG: u8 = 8; +pub const WEIGHT_LOG: u8 = 6; + +/// Largest symbol each alphabet has. +pub const LL_MAX: usize = 35; +pub const ML_MAX: usize = 52; +pub const OF_MAX: usize = 31; +pub const WEIGHT_MAX: usize = 12; + +/// Longest Huffman code a literal may have. +pub const HUFF_LOG: u8 = 11; diff --git a/userland/zstd/src/literals.rs b/userland/zstd/src/literals.rs new file mode 100644 index 0000000000..24b0ec247a --- /dev/null +++ b/userland/zstd/src/literals.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 . + +//! The literals section of a compressed block (RFC 8878 3.1.1.3.1). + +use alloc::vec; +use alloc::vec::Vec; + +use super::context::Context; +use super::huff_build::table; +use super::huff_stream::decode; +use super::huff_weights::weights; +use super::limits::BLOCK_MAX; + +/// The literals, and the bytes the section took. +pub fn literals(d: &[u8], ctx: &mut Context) -> Option<(Vec, usize)> { + let b0 = *d.first()?; + let byte = |i: usize| d.get(i).map(|&b| usize::from(b)); + let (kind, format) = (b0 & 3, (b0 >> 2) & 3); + if kind < 2 { + let (regen, head) = match format { + 0 | 2 => (usize::from(b0 >> 3), 1), + 1 => (usize::from(b0 >> 4) | byte(1)? << 4, 2), + _ => (usize::from(b0 >> 4) | byte(1)? << 4 | byte(2)? << 12, 3), + }; + if regen > BLOCK_MAX { + return None; + } + return match kind { + 0 => Some((d.get(head..head + regen)?.to_vec(), head + regen)), + _ => Some((vec![*d.get(head)?; regen], head + 1)), + }; + } + let (head, width) = match format { + 0 | 1 => (3, 10), + 2 => (4, 14), + _ => (5, 18), + }; + let v = (0..head).try_fold(0u64, |v, i| Some(v | (byte(i)? as u64) << (8 * i)))?; + let mask = (1u64 << width) - 1; + let regen = ((v >> 4) & mask) as usize; + let comp = ((v >> (4 + width)) & mask) as usize; + if regen > BLOCK_MAX { + return None; + } + let body = d.get(head..head + comp)?; + // A fresh tree replaces the last; Treeless reuses it. + let tree = if kind == 2 { + let (w, used) = weights(body)?; + ctx.huff = Some(table(&w)?); + used + } else { + 0 + }; + let out = decode(ctx.huff.as_ref()?, body.get(tree..)?, regen, format != 0)?; + Some((out, head + comp)) +} diff --git a/userland/zstd/src/seq_codes.rs b/userland/zstd/src/seq_codes.rs new file mode 100644 index 0000000000..fb40c96871 --- /dev/null +++ b/userland/zstd/src/seq_codes.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 . + +//! Literal-length and match-length codes: a baseline and how many extra bits +//! follow it (RFC 8878 3.1.1.3.2.1.1). Past the direct codes each baseline is +//! the one before it plus the range its extra bits cover, so only the bit +//! counts are written down. + +const LL_BITS: [u8; 20] = [1, 1, 1, 1, 2, 2, 3, 3, 4, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16]; +const ML_BITS: [u8; 21] = [1, 1, 1, 1, 2, 2, 3, 3, 4, 4, 5, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16]; + +/// Codes below `direct` are their value plus `offset`; the rest walk `bits`. +fn code(code: u8, direct: u8, offset: u32, bits: &[u8]) -> Option<(u32, u8)> { + if code < direct { + return Some((u32::from(code) + offset, 0)); + } + let index = usize::from(code - direct); + let extra = *bits.get(index)?; + let base = bits[..index].iter().fold(u32::from(direct) + offset, |b, &n| b + (1 << n)); + Some((base, extra)) +} + +pub fn literal_length(c: u8) -> Option<(u32, u8)> { + code(c, 16, 0, &LL_BITS) +} + +pub fn match_length(c: u8) -> Option<(u32, u8)> { + code(c, 32, 3, &ML_BITS) +} diff --git a/userland/zstd/src/seq_header.rs b/userland/zstd/src/seq_header.rs new file mode 100644 index 0000000000..0b086135ff --- /dev/null +++ b/userland/zstd/src/seq_header.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 . + +//! The sequences section header: how many sequences, and the table each of +//! the three codes is read with (RFC 8878 3.1.1.3.2.1). + +use super::context::Context; +use super::fse_build::{build, Fse}; +use super::fse_default::{literal_lengths, match_lengths, offsets}; +use super::fse_read::counts; +use super::limits::{LL_LOG, LL_MAX, ML_LOG, ML_MAX, OF_LOG, OF_MAX}; + +/// The number of sequences, and the bytes the header took. +pub fn header(d: &[u8], ctx: &mut Context) -> Option<(usize, usize)> { + let byte = |i: usize| d.get(i).map(|&b| usize::from(b)); + let (count, mut at) = match byte(0)? { + 0 => return Some((0, 1)), + b @ 1..=127 => (b, 1), + b @ 128..=254 => (((b - 128) << 8) + byte(1)?, 2), + _ => (byte(1)? + (byte(2)? << 8) + 0x7F00, 3), + }; + let modes = byte(at)?; + at += 1; + if modes & 3 != 0 { + return None; + } + let specs = [ + (modes >> 6, LL_LOG, LL_MAX, 0), + (modes >> 4, OF_LOG, OF_MAX, 1), + (modes >> 2, ML_LOG, ML_MAX, 2), + ]; + for (mode, log, max, which) in specs { + let slot = match which { + 0 => &mut ctx.ll, + 1 => &mut ctx.of, + _ => &mut ctx.ml, + }; + let (table, used) = match mode & 3 { + 0 => ([literal_lengths, offsets, match_lengths][which]()?, 0), + 1 => match byte(at)? { + sym if sym <= max => (Fse::single(sym as u8), 1), + _ => return None, + }, + 2 => { + let (l, norm, used) = counts(d.get(at..)?, log, max)?; + (build(l, &norm)?, used) + } + // Repeat: the table the last block used, which must exist. + _ => (slot.clone()?, 0), + }; + *slot = Some(table); + at += used; + } + Some((count, at)) +} diff --git a/userland/zstd/src/seq_rep.rs b/userland/zstd/src/seq_rep.rs new file mode 100644 index 0000000000..ab732345e5 --- /dev/null +++ b/userland/zstd/src/seq_rep.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 . + +//! The three repeat offsets (RFC 8878 3.1.2.5). + +/// Offset_Value to an offset, updating the three repeat offsets. Values 1 to 3 +/// name a repeat, shifted by one when the sequence has no literals. +pub fn resolve(rep: &mut [usize; 3], value: usize, llen: usize) -> Option { + if value > 3 { + *rep = [value - 3, rep[0], rep[1]]; + return Some(rep[0]); + } + let idx = value - 1 + usize::from(llen == 0); + let offset = match idx { + 0 => return Some(rep[0]), + 1 => rep[1], + 2 => rep[2], + _ => rep[0].checked_sub(1).filter(|&o| o > 0)?, + }; + if idx != 1 { + rep[2] = rep[1]; + } + rep[1] = rep[0]; + rep[0] = offset; + Some(offset) +} diff --git a/userland/zstd/src/seq_run.rs b/userland/zstd/src/seq_run.rs new file mode 100644 index 0000000000..31991da5e3 --- /dev/null +++ b/userland/zstd/src/seq_run.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 . + +//! Decoding sequences and executing them: literals copied, then a match +//! copied from earlier in the frame (RFC 8878 3.1.1.3.2.2 and 3.1.1.4). + +use alloc::vec::Vec; + +use super::back::Back; +use super::context::Context; +use super::limits::BLOCK_MAX; +use super::seq_codes::{literal_length, match_length}; +use super::seq_rep::resolve; + +pub fn run( + d: &[u8], + n: usize, + ctx: &mut Context, + lits: &[u8], + out: &mut Vec, + start: usize, +) -> Option<()> { + let (ll, of, ml) = (ctx.ll.as_ref()?, ctx.of.as_ref()?, ctx.ml.as_ref()?); + let mut bits = Back::new(d)?; + let mut s = [ll.log, of.log, ml.log].map(|l| bits.read(l.into()) as usize); + let (mut rep, mut lit, block) = (ctx.rep, 0usize, out.len()); + for i in 0..n { + let (cl, co, cm) = (*ll.cells.get(s[0])?, *of.cells.get(s[1])?, *ml.cells.get(s[2])?); + let value = (1usize << co.sym) + bits.read(co.sym.into()) as usize; + let (base, extra) = match_length(cm.sym)?; + let mlen = base as usize + bits.read(extra.into()) as usize; + let (base, extra) = literal_length(cl.sym)?; + let llen = base as usize + bits.read(extra.into()) as usize; + let offset = resolve(&mut rep, value, llen)?; + if i + 1 < n { + s[0] = usize::from(cl.base) + bits.read(cl.bits.into()) as usize; + s[2] = usize::from(cm.base) + bits.read(cm.bits.into()) as usize; + s[1] = usize::from(co.base) + bits.read(co.bits.into()) as usize; + } + if out.len() - block + llen + mlen > BLOCK_MAX { + return None; + } + out.extend_from_slice(lits.get(lit..lit + llen)?); + lit += llen; + if offset > out.len() - start { + return None; + } + for _ in 0..mlen { + out.push(out[out.len() - offset]); + } + } + if !bits.done() { + return None; + } + let tail = lits.get(lit..)?; + if out.len() - block + tail.len() > BLOCK_MAX { + return None; + } + out.extend_from_slice(tail); + ctx.rep = rep; + Some(()) +} diff --git a/userland/zstd/src/xxh64.rs b/userland/zstd/src/xxh64.rs new file mode 100644 index 0000000000..5b8f0d33c2 --- /dev/null +++ b/userland/zstd/src/xxh64.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 . + +//! XXH64, whose low 32 bits are a frame's Content_Checksum. + +const P1: u64 = 0x9E37_79B1_85EB_CA87; +const P2: u64 = 0xC2B2_AE3D_27D4_EB4F; +const P3: u64 = 0x1656_67B1_9E37_79F9; +const P4: u64 = 0x85EB_CA77_C2B2_AE63; +const P5: u64 = 0x27D4_EB2F_1656_67C5; + +fn round(acc: u64, lane: u64) -> u64 { + acc.wrapping_add(lane.wrapping_mul(P2)).rotate_left(31).wrapping_mul(P1) +} + +fn merge(h: u64, v: u64) -> u64 { + (h ^ round(0, v)).wrapping_mul(P1).wrapping_add(P4) +} + +fn word(d: &[u8]) -> u64 { + d.iter().take(8).enumerate().fold(0, |a, (i, &b)| a | (b as u64) << (8 * i)) +} + +pub fn xxh64(data: &[u8], seed: u64) -> u64 { + let mut stripes = data.chunks_exact(32); + let mut h = if data.len() >= 32 { + let mut v = [ + seed.wrapping_add(P1).wrapping_add(P2), + seed.wrapping_add(P2), + seed, + seed.wrapping_sub(P1), + ]; + for s in stripes.by_ref() { + for (i, lane) in v.iter_mut().enumerate() { + *lane = round(*lane, word(&s[i * 8..])); + } + } + let h = v[0].rotate_left(1).wrapping_add(v[1].rotate_left(7)); + let h = h.wrapping_add(v[2].rotate_left(12)).wrapping_add(v[3].rotate_left(18)); + v.iter().fold(h, |h, &l| merge(h, l)) + } else { + seed.wrapping_add(P5) + }; + h = h.wrapping_add(data.len() as u64); + let rest = stripes.remainder(); + let mut eights = rest.chunks_exact(8); + for e in eights.by_ref() { + h = (h ^ round(0, word(e))).rotate_left(27).wrapping_mul(P1).wrapping_add(P4); + } + let mut fours = eights.remainder().chunks_exact(4); + for f in fours.by_ref() { + h = (h ^ word(f).wrapping_mul(P1)).rotate_left(23).wrapping_mul(P2).wrapping_add(P3); + } + for &b in fours.remainder() { + h = (h ^ (b as u64).wrapping_mul(P5)).rotate_left(11).wrapping_mul(P1); + } + h ^= h >> 33; + h = h.wrapping_mul(P2); + h ^= h >> 29; + h = h.wrapping_mul(P3); + h ^ (h >> 32) +} diff --git a/userland/zstd/tests/mutate.rs b/userland/zstd/tests/mutate.rs new file mode 100644 index 0000000000..f095733f45 --- /dev/null +++ b/userland/zstd/tests/mutate.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 . + +//! Mutation fuzzing, deterministic and seeded, run as an ordinary test. +//! +//! This is not coverage-guided: no libFuzzer is vendored in this tree. It +//! takes every vector, damages it the ways hostile bytes arrive (bit flips, +//! truncation, spliced noise, overwritten lengths), and asserts the decoder +//! returns without panicking, overflowing or producing past its bound. Debug +//! builds trap on arithmetic overflow, so this runs without --release. + +#[path = "support/damage.rs"] +mod damage; +#[path = "support/rng.rs"] +mod rng; + +use damage::mutate; +use nonos_zstd::{decompress, MAX_OUT}; +use rng::Rng; + +const ROUNDS: usize = 4000; +const SMALL: [&str; 6] = ["tiny", "text64k", "nocheck", "concat", "runs100k", "stream"]; + +#[test] +fn hostile_bytes_never_panic() { + let mut r = Rng::new(0x5EED); + let mut accepted = 0; + for name in SMALL { + let path = format!("{}/tests/vectors/{name}.zst", env!("CARGO_MANIFEST_DIR")); + let clean = std::fs::read(&path).unwrap_or_else(|e| panic!("{path}: {e}")); + for _ in 0..ROUNDS { + let mut v = clean.clone(); + mutate(&mut r, &mut v); + if let Some(out) = decompress(&v) { + assert!(out.len() <= MAX_OUT); + accepted += 1; + } + } + } + // Most damage must be caught; the count is printed so a drift is visible. + println!("accepted {accepted} of {} mutants", SMALL.len() * ROUNDS); +} + +#[test] +fn pure_noise_never_panics() { + let mut r = Rng::new(0xBAD); + for _ in 0..ROUNDS { + let mut v = 0xFD2F_B528u32.to_le_bytes().to_vec(); + let n = (r.next() % 256) as usize; + v.extend((0..n).map(|_| (r.next() >> 56) as u8)); + let _ = decompress(&v); + } +} diff --git a/userland/zstd/tests/support/damage.rs b/userland/zstd/tests/support/damage.rs new file mode 100644 index 0000000000..1be66ceab6 --- /dev/null +++ b/userland/zstd/tests/support/damage.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 . + +//! The ways hostile bytes arrive: flips, overwrites, truncation, insertion, +//! lengths set to all ones, and spans cut out. + +use crate::rng::Rng; + +pub fn mutate(r: &mut Rng, v: &mut Vec) { + let pick = |r: &mut Rng, n: usize| (r.next() % n.max(1) as u64) as usize; + for _ in 0..=pick(r, 4) { + let at = pick(r, v.len()); + match r.next() % 6 { + 0 if !v.is_empty() => v[at] ^= 1 << (r.next() % 8), + 1 if !v.is_empty() => v[at] = (r.next() >> 56) as u8, + 2 => v.truncate(at), + 3 => v.insert(at.min(v.len()), (r.next() >> 56) as u8), + 4 if at + 4 <= v.len() => v[at..at + 4].copy_from_slice(&[0xFF; 4]), + _ if at < v.len() => { + let len = pick(r, 16).min(v.len() - at); + v.drain(at..at + len); + } + _ => {} + } + } +} diff --git a/userland/zstd/tests/support/kinds.rs b/userland/zstd/tests/support/kinds.rs new file mode 100644 index 0000000000..2851942c0d --- /dev/null +++ b/userland/zstd/tests/support/kinds.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 inputs the vectors were made from, regenerated. This must match +//! tools/nonos-zstd-vectors; a drift shows as every vector failing at once. + +use crate::rng::Rng; + +const WORDS: [&[u8]; 32] = [ + b"the", b"store", b"frame", b"kernel", b"package", b"of", b"a", b"link", b"guest", b"proof", + b"and", b"block", b"window", b"signal", b"to", b"is", b"offset", b"literal", b"table", + b"stream", b"in", b"trust", b"capsule", b"by", b"byte", b"zero", b"match", b"code", b"state", + b"with", b"root", b"tar", +]; + +pub fn text(r: &mut Rng, n: usize) -> Vec { + let mut out = Vec::new(); + let mut count = 0; + while out.len() < n { + out.extend_from_slice(WORDS[(r.next() >> 59) as usize]); + count += 1; + out.push(if count % 12 == 0 { b'\n' } else { b' ' }); + } + out.truncate(n); + out +} + +pub fn noise(r: &mut Rng, n: usize) -> Vec { + (0..n).map(|_| (r.next() >> 56) as u8).collect() +} + +pub fn runs(r: &mut Rng, n: usize) -> Vec { + let mut out = Vec::new(); + while out.len() < n { + let byte = (r.next() >> 60) as u8; + let len = (r.next() >> 58) as usize + 1; + out.extend(std::iter::repeat_n(byte, len)); + } + out.truncate(n); + out +} + +pub fn mixed(r: &mut Rng, n: usize) -> Vec { + let mut out = Vec::new(); + while out.len() < n { + let part = if (out.len() / 4096) % 2 == 0 { text(r, 4096) } else { noise(r, 4096) }; + out.extend(part); + } + out.truncate(n); + out +} diff --git a/userland/zstd/tests/support/rng.rs b/userland/zstd/tests/support/rng.rs new file mode 100644 index 0000000000..976070f44d --- /dev/null +++ b/userland/zstd/tests/support/rng.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 . + +//! xorshift64*, the generator tools/nonos-zstd-vectors uses too. + +pub struct Rng(u64); + +impl Rng { + pub fn new(seed: u64) -> Self { + Rng(seed.max(1)) + } + + pub fn next(&mut self) -> u64 { + let mut s = self.0; + s ^= s >> 12; + s ^= s << 25; + s ^= s >> 27; + self.0 = s; + s.wrapping_mul(0x2545_F491_4F6C_DD1D) + } +} diff --git a/userland/zstd/tests/support/table.rs b/userland/zstd/tests/support/table.rs new file mode 100644 index 0000000000..ea913948c0 --- /dev/null +++ b/userland/zstd/tests/support/table.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 . + +//! Every vector, what made it, and the input regenerated. + +use crate::kinds::{mixed, noise, runs, text}; +use crate::rng::Rng; + +pub const TABLE: [(&str, &str, u64, usize); 12] = [ + ("empty", "text", 1, 0), + ("tiny", "text", 2, 50), + ("text64k", "text", 3, 65536), + ("text300k", "text", 4, 300000), + ("noise140k", "noise", 5, 140000), + ("zeros300k", "zeros", 6, 300000), + ("runs100k", "runs", 7, 100000), + ("mixed256k", "mixed", 8, 262144), + ("nocheck", "text", 9, 20000), + ("stream", "text", 10, 50000), + ("fast", "text", 11, 100000), + ("long", "text", 12, 400000), +]; + +pub fn input(kind: &str, seed: u64, size: usize) -> Vec { + let r = &mut Rng::new(seed); + match kind { + "text" => text(r, size), + "noise" => noise(r, size), + "runs" => runs(r, size), + "mixed" => mixed(r, size), + _ => vec![0; size], + } +} diff --git a/userland/zstd/tests/vectors.rs b/userland/zstd/tests/vectors.rs new file mode 100644 index 0000000000..53e9f9b89a --- /dev/null +++ b/userland/zstd/tests/vectors.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 . + +//! Every vector the reference encoder wrote decodes to exactly its input. + +#[path = "support/kinds.rs"] +mod kinds; +#[path = "support/rng.rs"] +mod rng; +#[path = "support/table.rs"] +mod table; + +use kinds::{noise, text}; +use nonos_zstd::{decompress, xxh64}; +use rng::Rng; +use table::{input, TABLE}; + +fn vector(name: &str) -> Vec { + let path = format!("{}/tests/vectors/{name}.zst", env!("CARGO_MANIFEST_DIR")); + std::fs::read(&path).unwrap_or_else(|e| panic!("{path}: {e}")) +} + +#[test] +fn every_vector_round_trips() { + for (name, kind, seed, size) in TABLE { + let got = decompress(&vector(name)).unwrap_or_else(|| panic!("{name}: refused")); + assert!(got == input(kind, seed, size), "{name}: wrong bytes"); + } +} + +#[test] +fn frames_and_a_skippable_frame_concatenate() { + let mut want = text(&mut Rng::new(13), 10000); + want.extend(noise(&mut Rng::new(14), 5000)); + assert!(decompress(&vector("concat")) == Some(want)); +} + +#[test] +fn a_flipped_payload_byte_fails_the_checksum_or_the_parse() { + let mut v = vector("text64k"); + let at = v.len() / 2; + v[at] ^= 0x10; + assert!(decompress(&v).is_none()); +} + +#[test] +fn truncation_is_refused_at_every_length() { + let v = vector("tiny"); + for n in 0..v.len() { + assert!(decompress(&v[..n]).is_none(), "accepted {n} of {} bytes", v.len()); + } +} + +#[test] +fn xxh64_known_answers() { + assert_eq!(xxh64(b"", 0), 0xEF46_DB37_51D8_E999); + assert_eq!(xxh64(b"abc", 0), 0x44BC_2CF5_AD77_0999); +} diff --git a/userland/zstd/tests/vectors/concat.zst b/userland/zstd/tests/vectors/concat.zst new file mode 100644 index 0000000000..55d2f9135a Binary files /dev/null and b/userland/zstd/tests/vectors/concat.zst differ diff --git a/userland/zstd/tests/vectors/empty.zst b/userland/zstd/tests/vectors/empty.zst new file mode 100644 index 0000000000..e58c09d56a Binary files /dev/null and b/userland/zstd/tests/vectors/empty.zst differ diff --git a/userland/zstd/tests/vectors/fast.zst b/userland/zstd/tests/vectors/fast.zst new file mode 100644 index 0000000000..4a04e821d6 Binary files /dev/null and b/userland/zstd/tests/vectors/fast.zst differ diff --git a/userland/zstd/tests/vectors/long.zst b/userland/zstd/tests/vectors/long.zst new file mode 100644 index 0000000000..7802114ef5 Binary files /dev/null and b/userland/zstd/tests/vectors/long.zst differ diff --git a/userland/zstd/tests/vectors/mixed256k.zst b/userland/zstd/tests/vectors/mixed256k.zst new file mode 100644 index 0000000000..c6baf1cf2d Binary files /dev/null and b/userland/zstd/tests/vectors/mixed256k.zst differ diff --git a/userland/zstd/tests/vectors/nocheck.zst b/userland/zstd/tests/vectors/nocheck.zst new file mode 100644 index 0000000000..d1985a68c8 Binary files /dev/null and b/userland/zstd/tests/vectors/nocheck.zst differ diff --git a/userland/zstd/tests/vectors/noise140k.zst b/userland/zstd/tests/vectors/noise140k.zst new file mode 100644 index 0000000000..cbf9b7b220 Binary files /dev/null and b/userland/zstd/tests/vectors/noise140k.zst differ diff --git a/userland/zstd/tests/vectors/runs100k.zst b/userland/zstd/tests/vectors/runs100k.zst new file mode 100644 index 0000000000..c506255333 Binary files /dev/null and b/userland/zstd/tests/vectors/runs100k.zst differ diff --git a/userland/zstd/tests/vectors/stream.zst b/userland/zstd/tests/vectors/stream.zst new file mode 100644 index 0000000000..fea09f38e8 Binary files /dev/null and b/userland/zstd/tests/vectors/stream.zst differ diff --git a/userland/zstd/tests/vectors/text300k.zst b/userland/zstd/tests/vectors/text300k.zst new file mode 100644 index 0000000000..a3bc52ae85 Binary files /dev/null and b/userland/zstd/tests/vectors/text300k.zst differ diff --git a/userland/zstd/tests/vectors/text64k.zst b/userland/zstd/tests/vectors/text64k.zst new file mode 100644 index 0000000000..57fedfe738 Binary files /dev/null and b/userland/zstd/tests/vectors/text64k.zst differ diff --git a/userland/zstd/tests/vectors/tiny.zst b/userland/zstd/tests/vectors/tiny.zst new file mode 100644 index 0000000000..1b10feadd1 Binary files /dev/null and b/userland/zstd/tests/vectors/tiny.zst differ diff --git a/userland/zstd/tests/vectors/zeros300k.zst b/userland/zstd/tests/vectors/zeros300k.zst new file mode 100644 index 0000000000..8210bf20b5 Binary files /dev/null and b/userland/zstd/tests/vectors/zeros300k.zst differ diff --git a/verification/ASSUMPTIONS.md b/verification/ASSUMPTIONS.md new file mode 100644 index 0000000000..f27204e89b --- /dev/null +++ b/verification/ASSUMPTIONS.md @@ -0,0 +1,89 @@ +# 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: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. | +| `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. | 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..dbc78ff866 --- /dev/null +++ b/verification/lean/Nonos/Stark/T2.lean @@ -0,0 +1,74 @@ +/- +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 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, 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 + +/-- 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 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" + } +] diff --git a/x86_64-nonos.json b/x86_64-nonos.json index d93a58d5ec..994d948c20 100644 --- a/x86_64-nonos.json +++ b/x86_64-nonos.json @@ -18,7 +18,8 @@ "frame-pointer": "always", "cpu": "x86-64", - "features": "+sse,+sse2,-sse3,-ssse3,-sse4.1,-sse4.2,-avx,-avx2,-mmx,-soft-float", + "features": "-mmx,-sse,-sse2,-sse3,-ssse3,-sse4.1,-sse4.2,-avx,-avx2,+soft-float", + "rustc-abi": "x86-softfloat", "linker-flavor": "ld.lld", "linker": "rust-lld",