diff --git a/.github/actions/nonos-setup/action.yml b/.github/actions/nonos-setup/action.yml index a92850dbf0..9cc2f4921c 100644 --- a/.github/actions/nonos-setup/action.yml +++ b/.github/actions/nonos-setup/action.yml @@ -89,10 +89,10 @@ runs: for t in "${tgts[@]}"; do t="$(echo "${t}" | xargs)" [ -z "${t}" ] && continue - # x86_64-nonos is a JSON build-std target, not a rustup target. + # The *-nonos targets are JSON build-std targets, not rustup targets. # Only `rustup target add` the registered tier-2/3 targets. case "${t}" in - x86_64-nonos|x86_64-nonos-user) echo "skip rustup target add ${t} (json build-std target)"; continue ;; + x86_64-nonos|x86_64-nonos-user|aarch64-nonos|aarch64-nonos-user) echo "skip rustup target add ${t} (json build-std target)"; continue ;; esac echo "rustup target add ${t}" rustup target add "${t}" diff --git a/.github/workflows/ci-boot-aarch64.yml b/.github/workflows/ci-boot-aarch64.yml new file mode 100644 index 0000000000..6a5da02c94 --- /dev/null +++ b/.github/workflows/ci-boot-aarch64.yml @@ -0,0 +1,93 @@ +name: ci-boot-aarch64 + +# Reusable aarch64 boot proof. Each cell builds one image through the make +# target a developer runs, boots it under QEMU virt with a GICv3 and `-cpu max`, +# and holds the serial log to that cell's claims with +# scripts/check_aarch64_boot.py: +# +# core the core profile reaches [KSEC] 4/4, Core ready, +# Entering userspace and [INIT] Starting, in that order, +# with no trap, boot refusal or panic line on the way +# trap-sp0 an exception in the SP_EL0 vector group, taken with the +# MMU off, prints one [TRAP] line whose ESR, saved SPSR, +# SP and ELR are the ones the architecture defines for the +# `brk #0x5350` the image carries +# trap-kernel-abort a kernel read of an unmapped address, taken with the MMU +# on, prints the trap contract's report with a same-EL data +# abort, a level 0 translation fault and the address read +# +# The runner is x86_64, so QEMU emulates the CPU (TCG). A core boot reaches +# [INIT] Starting in seconds, so the deadline only bounds a hung boot. + +on: + workflow_call: + +permissions: + contents: read + +defaults: + run: + shell: bash + +concurrency: + group: ci-boot-aarch64-${{ github.ref }} + cancel-in-progress: false + +jobs: + boot-aarch64: + name: boot-aarch64 (${{ matrix.cell }}) + runs-on: ubuntu-latest + timeout-minutes: 45 + strategy: + fail-fast: false + matrix: + include: + - cell: core + target: nonos-mk-arm + - cell: trap-sp0 + target: nonos-mk-arm-trap-sp0 + - cell: trap-kernel-abort + target: nonos-mk-arm-trap-kernel-abort + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v4.2.2 + with: + submodules: recursive + persist-credentials: false + - uses: ./.github/actions/nonos-setup + with: + targets: aarch64-nonos + module: boot-aarch64 + - name: Install QEMU for aarch64 + run: | + set -euo pipefail + sudo apt-get update + sudo apt-get install -y qemu-system-arm + qemu-system-aarch64 --version | head -1 + - name: Prove the asserter rejects what it must + run: python3 scripts/check_aarch64_boot.py --self-test + - name: Provision a scratch signing key + # The fork dev seed signs the manifest; this lane proves the boot path, + # not the trust chain, and the image never leaves the runner. + run: bash nonos-ci/setup-signing-key.sh + - name: Build the ${{ matrix.cell }} image + run: make ${{ matrix.target }} SIGNING_KEY=.keys/signing_key_v1.bin + - name: Check the image is a loadable AArch64 kernel + run: python3 scripts/check_kernel_elf.py --arch aarch64 --elf target/aarch64-nonos/release/nonos-kernel + # The log goes under runner.temp, which starts empty in every job, rather + # than under target/, which the setup action restores from cache: a log + # left by an earlier run must never be what gets uploaded. + - name: Boot the ${{ matrix.cell }} cell + run: | + set -euo pipefail + mkdir -p "${{ runner.temp }}/boot-aarch64" + python3 scripts/check_aarch64_boot.py --cell ${{ matrix.cell }} \ + --elf target/aarch64-nonos/release/nonos-kernel \ + --log "${{ runner.temp }}/boot-aarch64/${{ matrix.cell }}.log" --deadline 300 + - name: Upload the serial log + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: boot-aarch64-${{ matrix.cell }} + path: ${{ runner.temp }}/boot-aarch64/${{ matrix.cell }}.log + retention-days: 14 + if-no-files-found: error diff --git a/.github/workflows/ci-build-aarch64.yml b/.github/workflows/ci-build-aarch64.yml new file mode 100644 index 0000000000..b9bd5f62b5 --- /dev/null +++ b/.github/workflows/ci-build-aarch64.yml @@ -0,0 +1,50 @@ +name: ci-build-aarch64 + +# Reusable aarch64 compile proof. Builds the kernel for aarch64-nonos.json with +# the core profile through the same make target a developer runs, then checks +# the output is a loadable AArch64 image rather than only that cargo exited 0. +# +# What it checks: +# - the crate, the arch assembly and the link all succeed for aarch64 +# - the ELF is an AArch64 static executable whose entry is `_start` +# - the entry lies in an executable PT_LOAD and no PT_LOAD is W+X +# - the signed manifest and signature sections are present +# It does not boot the image. That is ci-boot-aarch64.yml's job. + +on: + workflow_call: + +permissions: + contents: read + +defaults: + run: + shell: bash + +concurrency: + group: ci-build-aarch64-${{ github.ref }} + cancel-in-progress: false + +jobs: + build-aarch64: + name: build-aarch64 + runs-on: ubuntu-latest + timeout-minutes: 45 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v4.2.2 + with: + submodules: recursive + persist-credentials: false + - uses: ./.github/actions/nonos-setup + with: + targets: aarch64-nonos + module: build-aarch64 + - name: Provision a scratch signing key + # The build embeds an Ed25519 signature over the kernel manifest, so it + # needs a seed. This lane proves compilation, not the trust chain; the + # fork dev seed is enough and never reaches a release. + run: bash nonos-ci/setup-signing-key.sh + - name: Build the aarch64 kernel (microkernel-core + nonos-arch-preview) + run: make nonos-mk-arm SIGNING_KEY=.keys/signing_key_v1.bin + - name: Check the image is a loadable AArch64 kernel + run: python3 scripts/check_kernel_elf.py --arch aarch64 --elf target/aarch64-nonos/release/nonos-kernel 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/ci.yml b/.github/workflows/ci.yml index 6376aae522..e0d23b716f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,6 +24,12 @@ jobs: build: uses: ./.github/workflows/ci-build.yml + build-aarch64: + uses: ./.github/workflows/ci-build-aarch64.yml + + boot-aarch64: + uses: ./.github/workflows/ci-boot-aarch64.yml + # The baked trust ledger must verify on every checkout, not only on a # release tag. The keystore re-enrollment shipped with a stale ledger # and nothing between merges and the tag ever looked, so the first diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml index 9ec49b5f8e..f7a4514f02 100644 --- a/.github/workflows/verify.yml +++ b/.github/workflows/verify.yml @@ -120,6 +120,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 @@ -353,6 +365,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..1d20b48ce7 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" @@ -34,6 +41,7 @@ ENODEV = -19 ENOTDIR = -20 EISDIR = -21 EINVAL = -22 +ENOTTY = -25 ETXTBSY = -26 ERANGE = -34 ENOSYS = -38 @@ -72,6 +80,9 @@ MFSP = 0x5053464D MFST = 0x5453464D MFWT = 0x5457464D MFRP = 0x5052464D +MFCX = 0x5843464D +MFSG = 0x4753464D +MFIN = 0x4E49464D MPMP = 0x504D504D MPCP = 0x5043504D MPPT = 0x5450504D @@ -84,6 +95,10 @@ MLSG = 0x47534C4D MLVF = 0x46564C4D MAIN = 0x4E49414D MDRO = 0x4F52444D +MLCG = 0x47434C4D +MLCR = 0x52434C4D +MAPL = 0x4C50414D +MAIS = 0x5349414D MIRW = 0x5752494D MIRY = 0x5952494D MKAR = 0x52414B4D @@ -105,6 +120,8 @@ MTMS = 0x534D544D MTRN = 0x4E52544D MTRT = 0x5452544D MTSP = 0x5053544D +MTTQ = 0x5154544D +MTTY = 0x5954544D MWAT = 0x5441574D CRND = 0x444E5243 CHSH = 0x48534843 @@ -622,6 +639,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 +672,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 +714,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 +723,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"] @@ -754,6 +813,18 @@ caps = ["IPC"] args = [{name="entry",type="u64",dir="in"},{name="stack",type="u64",dir="in"}] ret = {type="i64"} +[desc.MTTQ] +nr = 0x5154544D +caps = ["valid_token"] +args = [{name="fd",type="u64",dir="in"}] +ret = {type="i64"} + +[desc.MTTY] +nr = 0x5954544D +caps = ["IPC"] +args = [{name="pid",type="u64",dir="in"},{name="streams",type="u64",dir="in"},{name="cols",type="u64",dir="in"},{name="rows",type="u64",dir="in"}] +ret = {type="i64"} + [desc.MWAT] nr = 0x5441574D caps = ["IPC"] diff --git a/build.rs b/build.rs index 5d879e56cb..e4f66e1e69 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, @@ -580,11 +584,22 @@ fn c_target(arch: &str) -> Option<(&'static str, &'static [&'static str])> { /// The user target whose capsule binaries this kernel embeds. /// /// The build system passes `NONOS_USER_TARGET` so the capsules the kernel bakes -/// in are built for the same architecture it is. Defaults to the x86_64 user -/// target, which is what a plain `cargo build` with no make wrapper expects. +/// in are built for the same architecture it is. Without it the default follows +/// the kernel's own architecture, so a plain `cargo build` of an aarch64 kernel +/// never embeds x86_64 capsules. A value naming another architecture is refused: +/// the kernel would load binaries its CPU cannot run. fn user_target() -> String { println!("cargo:rerun-if-env-changed=NONOS_USER_TARGET"); - let target = env::var("NONOS_USER_TARGET").unwrap_or_else(|_| "x86_64-nonos-user".to_string()); + let arch = env::var("CARGO_CFG_TARGET_ARCH").unwrap_or_default(); + let default = match arch.as_str() { + "aarch64" => "aarch64-nonos-user", + "riscv64" => "riscv64-nonos-user", + _ => "x86_64-nonos-user", + }; + let target = env::var("NONOS_USER_TARGET").unwrap_or_else(|_| default.to_string()); + if matches!(arch.as_str(), "x86_64" | "aarch64" | "riscv64") && !target.starts_with(&arch) { + panic!("NONOS_USER_TARGET={target} does not match the kernel architecture {arch}"); + } /* * The embed sites are `include_bytes!`, which takes a literal, so the path * has to be assembled at compile time. Re-exporting the value as a rustc env 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/linker_aarch64.ld b/linker_aarch64.ld index 81f3a299bc..bfea23f0a3 100644 --- a/linker_aarch64.ld +++ b/linker_aarch64.ld @@ -20,14 +20,18 @@ SECTIONS { __kernel_image_start = .; .text ALIGN(0x1000) : { + __kernel_text_start = .; *(.text._start) *(.text .text.*) *(.ltext .ltext.*) + __kernel_text_end = .; } :text .rodata ALIGN(0x1000) : { + __kernel_rodata_start = .; *(.rodata .rodata.*) *(.lrodata .lrodata.*) + __kernel_rodata_end = .; } :rodata /* Writable data starts on a 2MB boundary so the boot identity map, which @@ -37,7 +41,9 @@ SECTIONS { __kernel_rw_start = .; .data ALIGN(0x1000) : { + __kernel_data_start = .; *(.data .data.*) + __kernel_data_end = .; } :data .nonos.manifest ALIGN(0x1000) : { @@ -54,10 +60,12 @@ SECTIONS { .bss ALIGN(0x1000) : { __bss_start = .; + __kernel_bss_start = .; *(.bss .bss.*) *(COMMON) . = ALIGN(8); __bss_end = .; + __kernel_bss_end = .; } :data /* 2MB so the boot identity map, which uses 2MB blocks, can mark the text 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..37499b4c7d 100644 --- a/mk/20-build.mk +++ b/mk/20-build.mk @@ -3,7 +3,7 @@ # STARK attestation for the kernel and every capsule. This is where make, # make qemu, and make from-config all resolve their real work. -.PHONY: nonos-mk-check-driver-ahci-keys nonos-mk-check-driver-e1000-keys nonos-mk-check-driver-hda-keys nonos-mk-check-driver-i2c-hid-keys nonos-mk-check-driver-i2c-pci-keys nonos-mk-check-driver-iwlwifi-keys nonos-mk-check-driver-nvme-keys nonos-mk-check-driver-rtl8139-keys nonos-mk-check-driver-rtl8169-keys nonos-mk-check-driver-rtl8821ce-keys nonos-mk-check-driver-usb-msc-keys nonos-mk-check-driver-virtio-gpu-keys nonos-mk-check-ps2-input-keys nonos-mk-check-ramfs-keys nonos-mk-check-virtio-blk-keys nonos-mk-check-virtio-net-keys nonos-mk-check-virtio-rng-keys nonos-mk-check-xhci-keys nonos-mk-crypto nonos-mk-driver-ahci nonos-mk-driver-ahci-sign nonos-mk-driver-e1000 nonos-mk-driver-e1000-sign nonos-mk-driver-hda nonos-mk-driver-hda-sign nonos-mk-driver-i2c-hid nonos-mk-driver-i2c-hid-sign nonos-mk-driver-i2c-pci nonos-mk-driver-i2c-pci-sign nonos-mk-driver-iwlwifi nonos-mk-driver-iwlwifi-sign nonos-mk-driver-nvme nonos-mk-driver-nvme-sign nonos-mk-driver-rtl8139 nonos-mk-driver-rtl8139-sign nonos-mk-driver-rtl8169 nonos-mk-driver-rtl8169-sign nonos-mk-driver-rtl8821ce nonos-mk-driver-rtl8821ce-sign nonos-mk-driver-usb-msc nonos-mk-driver-usb-msc-sign nonos-mk-driver-virtio-gpu nonos-mk-driver-virtio-gpu-sign nonos-mk-entropy nonos-mk-keyring nonos-mk-market nonos-mk-proof-io nonos-mk-proof-io-sign nonos-mk-ps2-input nonos-mk-ps2-input-sign nonos-mk-ramfs nonos-mk-ramfs-sign nonos-mk-vfs nonos-mk-virtio-blk nonos-mk-virtio-blk-sign nonos-mk-virtio-net nonos-mk-virtio-net-sign nonos-mk-virtio-rng nonos-mk-virtio-rng-sign nonos-mk-wallpaper nonos-mk-xhci nonos-mk-xhci-sign nonos-mk-all-capsules-attested nonos-mk-attest nonos-mk-attestation nonos-mk-attestation-receipt nonos-mk-bootloader nonos-mk-capsules nonos-mk-check nonos-mk-check-trust-keys nonos-mk-check-trust-manifest nonos-mk-core nonos-mk-core-attested nonos-mk-desktop-gui-prod nonos-mk-smp-prod nonos-mk-ensure-zk-keys nonos-mk-esp nonos-mk-from-config nonos-mk-host-trust-verify nonos-mk-libc nonos-mk-live-production-proof nonos-mk-marketplace-abi nonos-mk-marketplace-index-tool nonos-mk-menuconfig nonos-mk-sign nonos-mk-terminal-test nonos-mk-trust-policy nonos-mk-usb-img nonos-mk-userland-clean nonos-mk-verify-capsule-attest nonos-mk-verify-trust nonos-mk-zerostate nonos-mk-zk-report nonos-mk-zk-tools nonos-mk-zk-verify-live +.PHONY: nonos-mk-check-driver-ahci-keys nonos-mk-check-driver-e1000-keys nonos-mk-check-driver-hda-keys nonos-mk-check-driver-i2c-hid-keys nonos-mk-check-driver-i2c-pci-keys nonos-mk-check-driver-iwlwifi-keys nonos-mk-check-driver-nvme-keys nonos-mk-check-driver-rtl8139-keys nonos-mk-check-driver-rtl8169-keys nonos-mk-check-driver-rtl8821ce-keys nonos-mk-check-driver-usb-msc-keys nonos-mk-check-driver-virtio-gpu-keys nonos-mk-check-ps2-input-keys nonos-mk-check-ramfs-keys nonos-mk-check-virtio-blk-keys nonos-mk-check-virtio-net-keys nonos-mk-check-virtio-rng-keys nonos-mk-check-xhci-keys nonos-mk-crypto nonos-mk-driver-ahci nonos-mk-driver-ahci-sign nonos-mk-driver-e1000 nonos-mk-driver-e1000-sign nonos-mk-driver-hda nonos-mk-driver-hda-sign nonos-mk-driver-i2c-hid nonos-mk-driver-i2c-hid-sign nonos-mk-driver-i2c-pci nonos-mk-driver-i2c-pci-sign nonos-mk-driver-iwlwifi nonos-mk-driver-iwlwifi-sign nonos-mk-driver-nvme nonos-mk-driver-nvme-sign nonos-mk-driver-rtl8139 nonos-mk-driver-rtl8139-sign nonos-mk-driver-rtl8169 nonos-mk-driver-rtl8169-sign nonos-mk-driver-rtl8821ce nonos-mk-driver-rtl8821ce-sign nonos-mk-driver-usb-msc nonos-mk-driver-usb-msc-sign nonos-mk-driver-virtio-gpu nonos-mk-driver-virtio-gpu-sign nonos-mk-entropy nonos-mk-keyring nonos-mk-market nonos-mk-proof-io nonos-mk-proof-io-sign nonos-mk-ps2-input nonos-mk-ps2-input-sign nonos-mk-ramfs nonos-mk-ramfs-sign nonos-mk-vfs nonos-mk-virtio-blk nonos-mk-virtio-blk-sign nonos-mk-virtio-net nonos-mk-virtio-net-sign nonos-mk-virtio-rng nonos-mk-virtio-rng-sign nonos-mk-wallpaper nonos-mk-xhci nonos-mk-xhci-sign nonos-mk-all-capsules-attested nonos-mk-attest nonos-mk-attestation nonos-mk-attestation-receipt nonos-mk-bootloader nonos-mk-capsules nonos-mk-check nonos-mk-check-trust-keys nonos-mk-check-trust-manifest nonos-mk-core nonos-mk-core-attested nonos-mk-desktop-gui-prod nonos-mk-smp-prod nonos-mk-ethernet-prod nonos-mk-ensure-zk-keys nonos-mk-esp nonos-mk-from-config nonos-mk-host-trust-verify nonos-mk-libc nonos-mk-live-production-proof nonos-mk-marketplace-abi nonos-mk-marketplace-index-tool nonos-mk-menuconfig nonos-mk-sign nonos-mk-terminal-test nonos-mk-trust-policy nonos-mk-usb-img nonos-mk-userland-clean nonos-mk-verify-capsule-attest nonos-mk-verify-trust nonos-mk-zerostate nonos-mk-zk-report nonos-mk-zk-tools nonos-mk-zk-verify-live # ZK attestation: transparent enrolled-secret tools @@ -327,6 +327,11 @@ $(NONOS_STD_PAL_STAMP): $(NONOS_STD_PAL_SRCS) | $(TARGET_DIR)/.nonos-toolchain.s @echo "Applying NONOS std platform layer to rust-src..." @PATH="$(HOME)/.cargo/bin:$$PATH" RUSTUP_TOOLCHAIN=$(TOOLCHAIN) \ toolchain/nonos-std/apply.sh + @# -Zbuild-std fingerprints the sysroot crates by version, not by their + @# sources, so a target dir that built std before the layer changed keeps + @# linking the old std and the tool ships without the new behaviour. The + @# layer just changed, so every such cache is stale: drop them. + @rm -rf userland/upstream-src/*/target userland/capsule_std_proof/target @mkdir -p $(TARGET_DIR) @touch $@ @@ -559,6 +564,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 +624,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 +643,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 +805,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 +877,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 @@ -888,14 +915,45 @@ nonos-mk-check: nonos-mk-check-deps nonos-mk-ensure-signing-key # Build the aarch64 kernel. PATH puts the rustup shims first because a Homebrew # rustc on /usr/local/bin shadows them and then rejects the -Z flags with a # confusing "only accepted on the nightly compiler". -.PHONY: nonos-mk-arm nonos-mk-arm-bench nonos-mk-bench-micro nonos-mk-arm-run nonos-mk-arm-gui nonos-mk-arm-gui-capsules nonos-mk-arm-gui-run -nonos-mk-arm: nonos-mk-ensure-signing-key +# +# The compile reads only the Ed25519 seed, which build.rs uses to sign the +# embedded manifest. Nothing here signs with ML-DSA, so the target asks for the +# seed alone rather than minting an ML-DSA keypair it never reads. CI's +# build-aarch64 lane runs this target, so the lane builds what a developer does. +# +# Each aarch64 recipe names the aarch64 user target. This makefile exports +# x86_64-nonos-user for the whole userland pipeline, and build.rs refuses to +# embed capsules built for another architecture than the kernel's. +.PHONY: nonos-mk-arm nonos-mk-arm-trap-sp0 nonos-mk-arm-trap-kernel-abort nonos-mk-arm-bench nonos-mk-bench-micro nonos-mk-arm-run nonos-mk-arm-gui nonos-mk-arm-gui-capsules nonos-mk-arm-gui-run +nonos-mk-arm: | $(SIGNING_KEY) @echo "Building kernel (aarch64, microkernel-core + nonos-arch-preview)..." - @$(SDK_FLAGS) NONOS_SIGNING_KEY=$(KERNEL_SIGNING_KEY) \ + @$(SDK_FLAGS) NONOS_USER_TARGET=aarch64-nonos-user \ + NONOS_SIGNING_KEY=$(KERNEL_SIGNING_KEY) \ RUSTUP_TOOLCHAIN=$(TOOLCHAIN) PATH="$(HOME)/.cargo/bin:$$PATH" \ $(CARGO) build $(ARM_KERNEL_BUILD_FLAGS) \ --no-default-features --features microkernel-core$(_boot_comma)nonos-arch-preview +# The same image with one deliberate exception compiled in, for the aarch64 boot +# lane. Each stops at its exception by design, and scripts/check_aarch64_boot.py +# holds the line the kernel prints for it to what the architecture defines. +nonos-mk-arm-trap-sp0: | $(SIGNING_KEY) + @echo "Building kernel (aarch64, microkernel-core + nonos-trap-proof-sp0)..." + @$(SDK_FLAGS) NONOS_USER_TARGET=aarch64-nonos-user \ + NONOS_SIGNING_KEY=$(KERNEL_SIGNING_KEY) \ + RUSTUP_TOOLCHAIN=$(TOOLCHAIN) PATH="$(HOME)/.cargo/bin:$$PATH" \ + $(CARGO) build $(ARM_KERNEL_BUILD_FLAGS) \ + --no-default-features \ + --features microkernel-core$(_boot_comma)nonos-arch-preview$(_boot_comma)nonos-trap-proof-sp0 + +nonos-mk-arm-trap-kernel-abort: | $(SIGNING_KEY) + @echo "Building kernel (aarch64, microkernel-core + nonos-trap-proof-kernel-abort)..." + @$(SDK_FLAGS) NONOS_USER_TARGET=aarch64-nonos-user \ + NONOS_SIGNING_KEY=$(KERNEL_SIGNING_KEY) \ + RUSTUP_TOOLCHAIN=$(TOOLCHAIN) PATH="$(HOME)/.cargo/bin:$$PATH" \ + $(CARGO) build $(ARM_KERNEL_BUILD_FLAGS) \ + --no-default-features \ + --features microkernel-core$(_boot_comma)nonos-arch-preview$(_boot_comma)nonos-trap-proof-kernel-abort + # Boot the aarch64 kernel under QEMU. Every flag here is load bearing. # # gic-version=3 virt defaults to a GICv2, and the kernel drives a v3. Without @@ -919,7 +977,8 @@ ARM_QEMU_FLAGS := -M virt,gic-version=3 -cpu max -m 512 -nographic \ # belong to this image and this machine, not to a marketing table. nonos-mk-arm-bench: nonos-mk-ensure-signing-key @echo "Building kernel (aarch64, microkernel-core + nonos-bench-micro)..." - @$(SDK_FLAGS) NONOS_SIGNING_KEY=$(KERNEL_SIGNING_KEY) \ + @$(SDK_FLAGS) NONOS_USER_TARGET=aarch64-nonos-user \ + NONOS_SIGNING_KEY=$(KERNEL_SIGNING_KEY) \ RUSTUP_TOOLCHAIN=$(TOOLCHAIN) PATH="$(HOME)/.cargo/bin:$$PATH" \ $(CARGO) build $(ARM_KERNEL_BUILD_FLAGS) \ --no-default-features \ @@ -1002,7 +1061,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 +1203,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 +1224,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 @@ -1179,6 +1246,18 @@ nonos-mk-install-prod: $(DESKTOP_GUI_CAPSULE_ARTIFACTS) $(driver-nvme_ARTIFACTS) nonos-mk-check-deps nonos-mk-ensure-signing-key $(call nonos_kernel_build,microkernel-desktop-gui + nvme + install,microkernel-desktop-gui$(_boot_comma)nonos-stark-attest$(_boot_comma)nonos-capsule-driver-nvme) +# nonos-mk-ethernet-prod: the desktop profile with the wired NIC drivers in it. +# QEMU models the e1000 and the RTL8139, so each boots against its own device +# and has to take a lease through it; the RTL8169 has no QEMU model and is here +# to show a driver whose chip is absent exits and lets the boot go on. +ETHERNET_DRIVER_ARTIFACTS := $(driver-e1000_ARTIFACTS) $(driver-rtl8139_ARTIFACTS) \ + $(driver-rtl8169_ARTIFACTS) + +nonos-mk-ethernet-prod: $(DESKTOP_GUI_CAPSULE_ARTIFACTS) $(ETHERNET_DRIVER_ARTIFACTS) \ + nonos-mk-verify-desktop-gui-capsules \ + nonos-mk-check-deps nonos-mk-ensure-signing-key + $(call nonos_kernel_build,microkernel-desktop-gui + wired NICs,microkernel-desktop-gui$(_boot_comma)nonos-stark-attest$(_boot_comma)nonos-capsule-driver-e1000$(_boot_comma)nonos-capsule-driver-rtl8139$(_boot_comma)nonos-capsule-driver-rtl8169) + # nonos-mk-smp-prod: the desktop profile with the secondary CPUs turned on. # Same capsule set and the same attestation, so a difference between this boot # and the single-CPU one is the AP bring-up and nothing else. @@ -1224,6 +1303,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 +1316,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..98077209d7 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 are 6.8 MB of the 48 MiB the vfs loads. A guest-test image +# leaves them out, keeping that budget for its guests and outside programs. +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" \ @@ -448,9 +477,10 @@ nonos-mk-run-installed: nonos-mk-swtpm-start $(QEMU_OVMF_VARS_RW) -serial mon:stdio -vga none -display $(QEMU_DISPLAY) -no-reboot # The DMA-protection boot. Every other lane starts QEMU with no remapping -# hardware, so the kernel finds an empty DMAR and says so: +# hardware, so the kernel finds neither a DMAR unit nor an IVRS table and +# says so: # -# [VT-D] no remapping units in DMAR; DMA is unrestricted +# [IOMMU] no DMAR remapping unit and no IVRS table; IOMMU domains refused; DMA is unrestricted # # which means the IOMMU bring-up compiled into every image has never run. This # lane presents an intel-iommu so it does. TCG rather than hvf: the hypervisor @@ -467,7 +497,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 @@ -475,10 +505,12 @@ nonos-mk-run-iommu-serial-log: nonos-mk-desktop-gui-prod $(QEMU_BLK_IMG) $(QEMU_ # the requested cells ask for, so a single-CPU cell never pays for the # multiprocessor build. scripts/boot_matrix.py then boots each cell of # scripts/bootmatrix/cells.py BOOT_MATRIX_REPEAT times: q35 -# and i440fx, one to eight CPUs, with and without an IOMMU, and a kill during -# store traffic followed by a reboot of the same disk. A cell fails on any boot -# that misses readiness, reports a fault, brings up fewer CPUs than it was -# given, or says DMA is unrestricted with an IOMMU present. +# and i440fx, one to eight CPUs, with no IOMMU, an intel-iommu or an +# amd-iommu, and a kill during store traffic followed by a reboot of the same +# disk. A cell fails on any boot that misses readiness, reports a fault, brings +# up fewer CPUs than it was given, prints an [IOMMU] posture line other than +# the one its IOMMU calls for, or says DMA is unrestricted with an intel-iommu +# present. BOOT_MATRIX_DIR ?= $(TARGET_DIR)/boot-matrix BOOT_MATRIX_REPEAT ?= 5 BOOT_MATRIX_TIMEOUT ?= 300 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/boot_matrix.py b/scripts/boot_matrix.py index 53f3d18823..ae4b54fe3c 100644 --- a/scripts/boot_matrix.py +++ b/scripts/boot_matrix.py @@ -21,10 +21,12 @@ --blk-img target/qemu-virtio-blk.img --repeat 5 A cell passes when every one of its boots reaches readiness with nothing -fatal in the log, all its CPUs online, and DMA restricted when an IOMMU is -present. The exit status is the number of failing boots. `make -nonos-mk-boot-matrix` builds the images the selected cells need and runs this; -`--profiles` is how it asks which those are, and `--list` names every cell. +fatal in the log, all its CPUs online, and the IOMMU posture its QEMU device +calls for: VT-d enforcing under an intel-iommu, AMD-Vi named and refused under +an amd-iommu, none without either. The exit status is the number of failing +boots. `make nonos-mk-boot-matrix` builds the images the selected cells need +and runs this; `--profiles` is how it asks which those are, and `--list` +names every cell. """ import sys diff --git a/scripts/bootmatrix/cells.py b/scripts/bootmatrix/cells.py index 2f28eb4214..a14173444b 100644 --- a/scripts/bootmatrix/cells.py +++ b/scripts/bootmatrix/cells.py @@ -30,7 +30,8 @@ class Cell: profile: str machine: str cpus: int - iommu: bool = False + # The IOMMU QEMU presents: "" for none, "intel-iommu" or "amd-iommu". + iommu: str = "" # Kill QEMU once the store is serving, then boot the same disk again and # require it to come back clean. Crash consistency of the block store. kill: bool = False @@ -46,8 +47,9 @@ def wants_smp_proof(self): Cell("q35-smp8", "smp", "q35", 8), Cell("i440fx-up", "up", "pc", 1), Cell("i440fx-smp4", "smp", "pc", 4), - Cell("q35-iommu-up", "up", "q35", 1, iommu=True), - Cell("q35-iommu-smp4", "smp", "q35", 4, iommu=True), + Cell("q35-iommu-up", "up", "q35", 1, iommu="intel-iommu"), + Cell("q35-iommu-smp4", "smp", "q35", 4, iommu="intel-iommu"), + Cell("q35-amdvi-up", "up", "q35", 1, iommu="amd-iommu"), Cell("q35-kill-reboot", "up", "q35", 1, kill=True), ] diff --git a/scripts/bootmatrix/qemu.py b/scripts/bootmatrix/qemu.py index 78639fd8d0..3c9e60d5fc 100644 --- a/scripts/bootmatrix/qemu.py +++ b/scripts/bootmatrix/qemu.py @@ -23,7 +23,11 @@ import platform import shlex -IOMMU_OPTS = "intel-iommu,intremap=on,caching-mode=on" +# The -device argument for each kind of IOMMU a cell can ask for. +IOMMU_DEVICES = { + "intel-iommu": "intel-iommu,intremap=on,caching-mode=on", + "amd-iommu": "amd-iommu", +} def accelerator(cell, requested): @@ -56,7 +60,7 @@ def command(cell, paths, accel, serial_log, blk_copy, vars_copy): "-device", "virtio-blk-pci,drive=vd0", ] if cell.iommu: - argv += ["-device", IOMMU_OPTS] + argv += ["-device", IOMMU_DEVICES[cell.iommu]] argv += shlex.split(paths.extra) argv += ["-serial", f"file:{serial_log}", "-display", "none", "-no-reboot"] return argv diff --git a/scripts/bootmatrix/verdict.py b/scripts/bootmatrix/verdict.py index a2070d6f3d..0aae19ecdc 100644 --- a/scripts/bootmatrix/verdict.py +++ b/scripts/bootmatrix/verdict.py @@ -16,8 +16,10 @@ """What a serial log has to say for the boot to count. The markers are the kernel's own lines, the same ones nonos-verify reads. A -cell with more than one CPU must hear from every one of them; a cell with an -IOMMU must not hear that DMA is unrestricted. Every cell must see the ring-0 +cell with more than one CPU must hear from every one of them. Every cell must +carry the [IOMMU] posture line its QEMU device calls for; a cell with an +intel-iommu must also not hear that DMA is unrestricted, and a cell with an +amd-iommu must hear the AMD-Vi hardware named. Every cell must see the ring-0 restrictions read back on: SMEP, SMAP, NX and WP, from the kernel's own [CPU-PROT] line rather than from what the code asked the part for. """ @@ -30,6 +32,12 @@ FATAL = ("[FATAL]", "[PANIC]", "[TRAP GP]", "[TRAP UD]", "[ZK-ATTEST] FAIL", "[SMP-PROOF] FAIL") UNRESTRICTED = "DMA is unrestricted" REMAPPED = "[VT-D] enumerated devices identity mapped; others denied" +AMD_VI_NAMED = "[AMD-VI] IVRS present" +# Every posture line carries the unconfined count beside enforcing=. The line +# is printed again each time the count changes, so the last one is current. +POSTURE = re.compile(r"\[IOMMU\] (\S+) present, enforcing=(\d), unconfined grants=(\d+)") +# The posture each IOMMU QEMU can present must produce: (vendor, enforcing). +EXPECTED_POSTURE = {"": ("none", "0"), "intel-iommu": ("intel-vt-d", "1"), "amd-iommu": ("amd-vi", "0")} SMP_PROOF = re.compile(r"\[SMP-PROOF\] cpu_count=(\d+) (PASS|UP)") STORE_STATUS = re.compile(r"\[VFS\] serving, store status ([0-9a-f]{2})") CPU_PROT = re.compile(r"\[CPU-PROT\] smep=(\d) smap=(\d) umip=(\d) nx=(\d) wp=(\d)") @@ -67,9 +75,21 @@ def judge(cell, text, reached, ending): bad.append("no [STACK-GUARD] line") if guards and guards.group(1) != guards.group(2): bad.append(f"only {guards.group(1)} of {guards.group(2)} stack guards armed") - if cell.iommu: + postures = POSTURE.findall(text) + if reached and not postures: + bad.append("no [IOMMU] posture line") + if postures: + vendor, enforcing = EXPECTED_POSTURE[cell.iommu] + for seen_vendor, seen_enforcing, _ in postures: + if (seen_vendor, seen_enforcing) != (vendor, enforcing): + bad.append(f"posture {seen_vendor} enforcing={seen_enforcing}, " + f"expected {vendor} enforcing={enforcing}") + break + if cell.iommu == "intel-iommu": if UNRESTRICTED in text: bad.append("kernel reports DMA is unrestricted with an IOMMU present") if REMAPPED not in text: bad.append("VT-d never reported devices remapped") + if cell.iommu == "amd-iommu" and AMD_VI_NAMED not in text: + bad.append("kernel never named the AMD-Vi hardware it does not drive") return bad diff --git a/scripts/check_aarch64_boot.py b/scripts/check_aarch64_boot.py new file mode 100644 index 0000000000..e58b1b3a98 --- /dev/null +++ b/scripts/check_aarch64_boot.py @@ -0,0 +1,261 @@ +#!/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 . +"""Boot the aarch64 kernel under QEMU virt and hold its serial log to one cell's claims. + +core the core profile reaches its named markers in order, and no + trap, refusal or panic line appears on the way +trap-sp0 built with nonos-trap-proof-sp0: exactly one [TRAP] line for + the SP_EL0 vector, carrying the syndrome of `brk #0x5350` + (ESR 0xF2005350) and no FAR, which a breakpoint does not + write, a saved SPSR that says EL1 on SP_EL0, the SP_EL0 value + the proof loaded, and an ELR that points at that very + instruction in the image +trap-kernel-abort built with nonos-trap-proof-kernel-abort: exactly two [TRAP] + lines, a kernel page fault whose ESR is a same-EL data abort + with a level 0 translation fault on a read, and the faulting + address with its access, both as the trap contract prints them + +A trap cell also fails if any kernel line follows the last [TRAP] line, or if +the boot reaches Core ready. QEMU is stopped as soon +as the cell has what it needs plus a short settle, or at the deadline. The log +is written whole for the lane to upload. --replay judges a saved log without +QEMU; --self-test proves each cell rejects the logs it must reject. +""" + +import argparse +import re +import subprocess +import sys +import tempfile +import threading +import time +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from check_kernel_elf import Elf, PF_X, PT_LOAD # noqa: E402 + +QEMU_FLAGS = ["-M", "virt,gic-version=3", "-cpu", "max", "-m", "512", "-nographic", + "-serial", "mon:stdio", "-device", "virtio-rng-pci"] +SETTLE_SECS = 3.0 + +CORE_MARKERS = ["[KSEC] 4/4 sections mapped as declared", "[NONOS] Core ready", + "[UKERNEL] Entering userspace", "[INIT] Starting"] +NEVER = ["[TRAP]", "KERNEL FATAL TRAP", "[BOOT] refused", "KERNEL PANIC"] + +HEX = r"0x([0-9A-F]{16})" +SP0_LINE = re.compile(rf"^\[TRAP\] SP_EL0 vector sync esr={HEX} elr={HEX} spsr={HEX} sp={HEX}$") +SP0_ESR = 0xF2005350 +SP0_MARK = 0x0000535053505350 +BRK_5350 = (0xD4200000 | (0x5350 << 5)).to_bytes(4, "little") +SPSR_M_EL1T = 0b0100 + +ABORT_LINE = re.compile(rf"^\[TRAP\] KERNEL FATAL TRAP: Page Fault esr={HEX} elr={HEX} sp={HEX} origin=EL1$") +ABORT_DETAIL = "[TRAP] far=0x0000400000000000 read not-present EL1" +EC_DATA_ABORT_SAME = 0x25 +DFSC_TRANSLATION_L0 = 0x04 +WNR = 1 << 6 + + +def trap_lines(lines): + return [line for line in lines if line.startswith("[TRAP]")] + + +def in_text(elf, addr): + return elf is None or any( + t == PT_LOAD and f & PF_X and v <= addr < v + m for t, f, v, m in elf.segments()) + + +def judge_core(lines, elf): + failed = [] + at = 0 + for marker in CORE_MARKERS: + hits = [i for i, line in enumerate(lines) if marker in line] + if not hits: + failed.append(f"marker never printed: {marker}") + elif hits[0] < at: + failed.append(f"marker out of order: {marker}") + else: + at = hits[0] + for bad in NEVER: + for line in lines: + if bad in line: + failed.append(f"forbidden line: {line}") + return failed + + +def judge_sp0(lines, elf): + failed = [] + traps = trap_lines(lines) + if len(traps) != 1: + failed.append(f"want exactly one [TRAP] line, got {len(traps)}") + match = next((SP0_LINE.match(t) for t in traps if SP0_LINE.match(t)), None) + if not match: + return failed + ["no line of the form `[TRAP] SP_EL0 vector sync esr=.. elr=.. spsr=.. sp=..`"] + esr, elr, spsr, sp = (int(g, 16) for g in match.groups()) + if esr != SP0_ESR: + failed.append(f"esr {esr:#x}, want {SP0_ESR:#x} (EC 0x3C, IL, imm 0x5350)") + if spsr & 0xF != SPSR_M_EL1T: + failed.append(f"saved SPSR.M {spsr & 0xF:#06b}, want {SPSR_M_EL1T:#06b} (EL1 on SP_EL0)") + if sp != SP0_MARK: + failed.append(f"sp {sp:#x}, want the SP_EL0 the proof loaded, {SP0_MARK:#x}") + if not in_text(elf, elr): + failed.append(f"elr {elr:#x} lies in no executable segment of the image") + elif elf is not None and elf.read(elr, 4) != BRK_5350: + failed.append(f"the instruction at elr {elr:#x} is not `brk #0x5350`") + return failed + ran_on(lines) + + +def judge_abort(lines, elf): + failed = [] + traps = trap_lines(lines) + if len(traps) != 2: + failed.append(f"want exactly two [TRAP] lines, got {len(traps)}") + match = next((ABORT_LINE.match(t) for t in traps if ABORT_LINE.match(t)), None) + if not match: + return failed + ["no line of the form `[TRAP] KERNEL FATAL TRAP: Page Fault esr=.. elr=.. sp=.. origin=EL1`"] + esr, elr, _ = (int(g, 16) for g in match.groups()) + if (esr >> 26) & 0x3F != EC_DATA_ABORT_SAME: + failed.append(f"esr {esr:#x} has EC {(esr >> 26) & 0x3F:#x}, want {EC_DATA_ABORT_SAME:#x} (data abort, same EL)") + if esr & 0x3F != DFSC_TRANSLATION_L0: + failed.append(f"esr {esr:#x} has DFSC {esr & 0x3F:#x}, want {DFSC_TRANSLATION_L0:#x} (level 0 translation)") + if esr & WNR: + failed.append(f"esr {esr:#x} says write, the proof reads") + if not in_text(elf, elr): + failed.append(f"elr {elr:#x} lies in no executable segment of the image") + if traps.count(ABORT_DETAIL) != 1 or traps.index(ABORT_DETAIL) != traps.index(match.group(0)) + 1: + failed.append(f"the report is not followed by `{ABORT_DETAIL}`") + return failed + ran_on(lines) + + +def ran_on(lines): + """A terminal trap parks the CPU, so no kernel line may follow the last one.""" + failed = [f"the boot reached Core ready: {line}" for line in lines if "[NONOS] Core ready" in line] + last = max((i for i, line in enumerate(lines) if line.startswith("[TRAP]")), default=len(lines)) + return failed + [f"the boot ran past the trap: {line}" for line in lines[last + 1:] + if line.strip() and not line.startswith("qemu-system-")] + + +CELLS = { + "core": (judge_core, lambda lines: "[INIT] Starting" in "\n".join(lines)), + "trap-sp0": (judge_sp0, lambda lines: any(SP0_LINE.match(t) for t in trap_lines(lines))), + "trap-kernel-abort": (judge_abort, lambda lines: ABORT_DETAIL in lines), +} + + +def boot(qemu, elf_path, cell, deadline, log_path): + """Run QEMU until the cell is satisfied plus a settle, or the deadline.""" + proc = subprocess.Popen([qemu, *QEMU_FLAGS, "-kernel", str(elf_path)], + stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, + stderr=subprocess.STDOUT) + lines = [] + reader = threading.Thread(target=lambda: lines.extend( + raw.decode("utf-8", "replace").rstrip("\r\n") for raw in proc.stdout), daemon=True) + reader.start() + done = CELLS[cell][1] + started = time.monotonic() + satisfied_at = None + while time.monotonic() - started < deadline and proc.poll() is None: + if satisfied_at is None and done(list(lines)): + satisfied_at = time.monotonic() + if satisfied_at is not None and time.monotonic() - satisfied_at >= SETTLE_SECS: + break + time.sleep(0.2) + proc.kill() + proc.wait() + reader.join(timeout=5) + elapsed = time.monotonic() - started + log_path.write_text("\n".join(lines) + "\n") + return lines, elapsed, satisfied_at is not None + + +def self_test(): + good_sp0 = ["[NONOS] dtb at 0x0000000000000000", + "[TRAP] SP_EL0 vector sync esr=0x00000000F2005350 " + "elr=0x0000000040110110 spsr=0x00000000600003C4 sp=0x0000535053505350"] + good_abort = ["[TRAP] KERNEL FATAL TRAP: Page Fault esr=0x0000000096000004 " + "elr=0x00000000400C6D5C sp=0x000000004140FB80 origin=EL1", ABORT_DETAIL] + good_core = ["[KSEC] 4/4 sections mapped as declared", "[NONOS] Core ready", + "[UKERNEL] Entering userspace", "[INIT] Starting"] + cases = [ + ("core", good_core, True), + ("core", good_core[:3], False), + ("core", [good_core[1], good_core[0]] + good_core[2:], False), + ("core", good_core + ["[BOOT] refused: gic: x, mpidr=0x0000000000000000"], False), + ("core", good_core + ["!!! KERNEL PANIC !!!"], False), + ("trap-sp0", good_sp0, True), + ("trap-sp0", [good_sp0[1].replace("F2005350", "F2005351")], False), + ("trap-sp0", [good_sp0[1].replace("600003C4", "600003C5")], False), + ("trap-sp0", good_sp0 + good_sp0[1:], False), + ("trap-sp0", good_sp0 + ["[NONOS] Core ready"], False), + ("trap-sp0", [good_sp0[1].replace("0x0000535053505350", "0x000000004140FB80")], False), + ("trap-sp0", [good_sp0[1].replace(" elr=", " far=0x0000000000000000 elr=")], False), + ("trap-sp0", good_sp0 + ["[NONOS] no usable device tree, assuming QEMU virt"], False), + ("trap-sp0", good_sp0 + ["qemu-system-aarch64: terminating on signal 15"], True), + ("trap-kernel-abort", good_abort, True), + ("trap-kernel-abort", [good_abort[0].replace("96000004", "96000005"), ABORT_DETAIL], False), + ("trap-kernel-abort", [good_abort[0].replace("96000004", "96000044"), ABORT_DETAIL], False), + ("trap-kernel-abort", [good_abort[0].replace("96000004", "92000004"), ABORT_DETAIL], False), + ("trap-kernel-abort", good_abort[:1], False), + ("trap-kernel-abort", [ABORT_DETAIL, good_abort[0]], False), + ("trap-kernel-abort", good_abort + ["[TRAP-PROOF] read of an unmapped address returned 0x0"], False), + ] + wrong = [(cell, i) for i, (cell, log, ok) in enumerate(cases) + if (not CELLS[cell][0](log, None)) != ok] + if wrong: + print(f"aarch64-boot: self-test failed on cases {wrong}") + return 1 + print(f"aarch64-boot: self-test passed, {sum(not ok for *_, ok in cases)} bad logs rejected " + f"and {sum(ok for *_, ok in cases)} good ones accepted") + return 0 + + +def main(): + ap = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + ap.add_argument("--cell", choices=sorted(CELLS)) + ap.add_argument("--elf", type=Path, help="the kernel ELF to boot and to read ELR against") + ap.add_argument("--log", type=Path, help="where the serial log is written") + ap.add_argument("--deadline", type=float, default=300.0, help="seconds before QEMU is stopped") + ap.add_argument("--qemu", default="qemu-system-aarch64") + ap.add_argument("--replay", type=Path, help="judge this saved log instead of booting") + ap.add_argument("--self-test", action="store_true") + a = ap.parse_args() + if a.self_test: + return self_test() + if not a.cell or not a.elf: + ap.error("--cell and --elf are required") + elf = Elf(a.elf.read_bytes()) + if a.replay: + lines = a.replay.read_text(errors="replace").splitlines() + note = f"replayed {a.replay}" + else: + log = a.log or Path(tempfile.mkdtemp()) / f"{a.cell}.log" + lines, elapsed, satisfied = boot(a.qemu, a.elf, a.cell, a.deadline, log) + note = f"{len(lines)} lines in {elapsed:.0f}s, log {log}" + ("" if satisfied else ", deadline hit") + failed = CELLS[a.cell][0](lines, elf) + if failed: + print(f"aarch64-boot: cell {a.cell} FAILED ({note}):") + for line in failed: + print(f" {line}") + return 1 + print(f"aarch64-boot: cell {a.cell} passed ({note})") + for line in trap_lines(lines): + print(f" {line}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) 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_kernel_elf.py b/scripts/check_kernel_elf.py new file mode 100644 index 0000000000..5b681b782b --- /dev/null +++ b/scripts/check_kernel_elf.py @@ -0,0 +1,166 @@ +#!/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 kernel ELF is a loadable image for the architecture it claims, or the lane fails. + +A build lane that only checks the exit status of cargo proves that rustc and +lld ran. It does not prove the output is something a loader can start. This +checks the properties the loader and the first instruction depend on: + + - the ELF class, byte order, type and machine match the target architecture + - the entry point is the `_start` symbol the linker script names in ENTRY() + - the entry point lies inside a PT_LOAD segment that is executable + - no PT_LOAD segment is both writable and executable + - the signed manifest and signature sections are present and not empty + +Reads the ELF directly, so it runs on a host with no cross binutils. +""" + +import argparse +import struct +import sys +from pathlib import Path + +ET_EXEC = 2 +PT_LOAD = 1 +PF_X = 1 +PF_W = 2 +SHT_SYMTAB = 2 + +MACHINES = {"x86_64": 62, "aarch64": 183, "riscv64": 243} +ENTRY_SYMBOL = "_start" +REQUIRED_SECTIONS = (".nonos.manifest", ".nonos.sig") + + +class Elf: + def __init__(self, data): + if data[:4] != b"\x7fELF": + raise ValueError("not an ELF file") + if data[4] != 2 or data[5] != 1: + raise ValueError("not a little-endian ELF64 file") + self.data = data + (self.e_type, self.e_machine, _, self.e_entry, self.e_phoff, self.e_shoff, _, + _, self.e_phentsize, self.e_phnum, self.e_shentsize, self.e_shnum, + self.e_shstrndx) = struct.unpack_from(". +"""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/aarch64/asm/start.S b/src/arch/aarch64/asm/start.S index c5537e8d18..f2589bf2b1 100644 --- a/src/arch/aarch64/asm/start.S +++ b/src/arch/aarch64/asm/start.S @@ -61,6 +61,18 @@ _start: b .L_bss .L_bss_done: + /* Vectors before any Rust runs, so an exception from here on is + * reported by name instead of taken through whatever VBAR_EL1 held + * at reset. FP/SIMD with them: the handlers are compiled code and + * may use the vector registers, which trap while CPACR_EL1.FPEN is + * clear. After the BSS clear, because the handlers read BSS. */ + mov x0, #(3 << 20) + msr cpacr_el1, x0 + adrp x0, __aarch64_vectors_el1 + add x0, x0, :lo12:__aarch64_vectors_el1 + msr vbar_el1, x0 + isb + mov x0, x19 bl kernel_entry @@ -78,8 +90,8 @@ _start: /* AP entry. PSCI CPU_ON arranges for this hart to land here with * x0 = context_id (= stack_top picked by the kernel) * and the EL the boot CPU was running at. SCTLR_EL1 is reset to the - * same safe value as on BSP entry; per-CPU VBAR_EL1 / GIC / timer - * setup happens in Rust before IRQs are unmasked. */ + * same safe value as on BSP entry and VBAR_EL1 is installed here; + * per-CPU GIC / timer setup happens in Rust before IRQs are unmasked. */ .section .text._aarch64_secondary_start, "ax", %progbits .balign 16 @@ -94,6 +106,15 @@ _aarch64_secondary_start: msr sctlr_el1, x1 isb + /* Vectors and FP/SIMD before any Rust runs, as on the boot CPU. + * x0 still carries the context id, so x1 does the work. */ + mov x1, #(3 << 20) + msr cpacr_el1, x1 + adrp x1, __aarch64_vectors_el1 + add x1, x1, :lo12:__aarch64_vectors_el1 + msr vbar_el1, x1 + isb + bl aarch64_ap_entry .L_ap_hang: diff --git a/src/arch/aarch64/asm/vectors.S b/src/arch/aarch64/asm/vectors.S index 0de60a34b1..174f095660 100644 --- a/src/arch/aarch64/asm/vectors.S +++ b/src/arch/aarch64/asm/vectors.S @@ -93,13 +93,13 @@ __aarch64_vectors_el1: /* Current EL with SP_EL0 — kernel uses SPx; SP0 is a bug. */ .balign 0x80 - b __aarch64_invalid_sp0 + b __aarch64_invalid_sp0_sync .balign 0x80 - b __aarch64_invalid_sp0 + b __aarch64_invalid_sp0_irq .balign 0x80 - b __aarch64_invalid_sp0 + b __aarch64_invalid_sp0_fiq .balign 0x80 - b __aarch64_invalid_sp0 + b __aarch64_invalid_sp0_serror /* Current EL with SP_ELx — kernel mode. */ .balign 0x80 @@ -123,13 +123,13 @@ __aarch64_vectors_el1: /* Lower EL using AArch32 — unsupported. */ .balign 0x80 - b __aarch64_invalid_aarch32 + b __aarch64_invalid_aarch32_sync .balign 0x80 - b __aarch64_invalid_aarch32 + b __aarch64_invalid_aarch32_irq .balign 0x80 - b __aarch64_invalid_aarch32 + b __aarch64_invalid_aarch32_fiq .balign 0x80 - b __aarch64_invalid_aarch32 + b __aarch64_invalid_aarch32_serror .size __aarch64_vectors_el1, . - __aarch64_vectors_el1 @@ -217,20 +217,33 @@ __aarch64_serror_lower: RESTORE_GPRS eret +/* The two groups the kernel never uses. Each slot gets its own entry so + * the handler knows which kind of exception it was: ESR_EL1 and FAR_EL1 + * belong to it only for a synchronous exception or an SError, and an + * interrupt leaves whatever an earlier exception wrote there. x1 carries + * the kind (0 sync, 1 IRQ, 2 FIQ, 3 SError) and is set after the frame is + * saved, so the frame holds the interrupted x1. + * + * The SP_EL0 group is taken from EL1 running on SP_EL0, so the stack + * pointer the interrupted code had is SP_EL0, not the SP_EL1 this entry + * runs on. The user save records exactly that, as it does for AArch32 EL0. */ +.macro INVALID_ENTRY name, handler, kind .balign 16 -__aarch64_invalid_sp0: - SAVE_GPRS - SAVE_SYSREGS_KERNEL - mov x0, sp - bl aarch64_exc_invalid_sp0 -1: wfi - b 1b - -.balign 16 -__aarch64_invalid_aarch32: +\name: SAVE_GPRS SAVE_SYSREGS_USER mov x0, sp - bl aarch64_exc_invalid_aarch32 + mov x1, #\kind + bl \handler 1: wfi b 1b +.endm + +INVALID_ENTRY __aarch64_invalid_sp0_sync, aarch64_exc_invalid_sp0, 0 +INVALID_ENTRY __aarch64_invalid_sp0_irq, aarch64_exc_invalid_sp0, 1 +INVALID_ENTRY __aarch64_invalid_sp0_fiq, aarch64_exc_invalid_sp0, 2 +INVALID_ENTRY __aarch64_invalid_sp0_serror, aarch64_exc_invalid_sp0, 3 +INVALID_ENTRY __aarch64_invalid_aarch32_sync, aarch64_exc_invalid_aarch32, 0 +INVALID_ENTRY __aarch64_invalid_aarch32_irq, aarch64_exc_invalid_aarch32, 1 +INVALID_ENTRY __aarch64_invalid_aarch32_fiq, aarch64_exc_invalid_aarch32, 2 +INVALID_ENTRY __aarch64_invalid_aarch32_serror, aarch64_exc_invalid_aarch32, 3 diff --git a/src/arch/aarch64/boot/dtb_adapter/parse/devices.rs b/src/arch/aarch64/boot/dtb_adapter/parse/devices.rs index f8122d94fa..24e85c2ed9 100644 --- a/src/arch/aarch64/boot/dtb_adapter/parse/devices.rs +++ b/src/arch/aarch64/boot/dtb_adapter/parse/devices.rs @@ -22,7 +22,7 @@ use crate::arch::fdt::find::timer::find as find_timer; use crate::arch::fdt::find::uart::{find as find_uart, UartKind}; use crate::arch::fdt::Fdt; -pub fn populate(fdt: &Fdt, info: &mut BootInfo) { +pub(super) fn populate(fdt: &Fdt, info: &mut BootInfo) { populate_uart(fdt, info); populate_gic(fdt, info); populate_timer(fdt, info); diff --git a/src/arch/aarch64/boot/dtb_adapter/parse/memory.rs b/src/arch/aarch64/boot/dtb_adapter/parse/memory.rs index 5a78cb5bfb..1541da5f7d 100644 --- a/src/arch/aarch64/boot/dtb_adapter/parse/memory.rs +++ b/src/arch/aarch64/boot/dtb_adapter/parse/memory.rs @@ -18,7 +18,7 @@ use crate::arch::aarch64::boot::info::{BootInfo, MemoryType}; use crate::arch::fdt::find::memory::{find, MemoryRange}; use crate::arch::fdt::Fdt; -pub fn populate(fdt: &Fdt, info: &mut BootInfo) -> bool { +pub(super) fn populate(fdt: &Fdt, info: &mut BootInfo) -> bool { let mut ranges = [MemoryRange { base: 0, size: 0 }; 8]; let mem_count = match find(fdt, &mut ranges) { Ok(n) => n, diff --git a/src/arch/aarch64/boot/dtb_adapter/parse/processors.rs b/src/arch/aarch64/boot/dtb_adapter/parse/processors.rs index 4bfc2603dc..b11874b862 100644 --- a/src/arch/aarch64/boot/dtb_adapter/parse/processors.rs +++ b/src/arch/aarch64/boot/dtb_adapter/parse/processors.rs @@ -22,7 +22,7 @@ use crate::arch::fdt::Fdt; /// is sized for the same number. const MAX_CPUS: usize = crate::arch::aarch64::boot::stack::MAX_CPUS; -pub fn populate(fdt: &Fdt, info: &mut BootInfo) { +pub(super) fn populate(fdt: &Fdt, info: &mut BootInfo) { let mut affinities = [0u64; MAX_CPUS]; let Ok(n) = cpus::find(fdt, &mut affinities) else { return; diff --git a/src/arch/aarch64/boot/entry.rs b/src/arch/aarch64/boot/entry.rs index c24790281e..229374f1cc 100644 --- a/src/arch/aarch64/boot/entry.rs +++ b/src/arch/aarch64/boot/entry.rs @@ -30,11 +30,10 @@ use crate::sys::serial; #[no_mangle] pub extern "C" fn kernel_entry(dtb_ptr: u64) -> ! { - // First statement in the function, before even a local exists. The compiler - // uses the vector registers for ordinary work, and building `BootInfo` here - // compiles to a `ldr q0` that traps while CPACR_EL1.FPEN is clear, with no - // vectors installed yet to report it. Anything placed above this call is - // running before the registers it may be compiled into are usable. + // start.S has already set CPACR_EL1.FPEN and VBAR_EL1, so the vector + // registers the compiler uses for ordinary work are usable here and an + // exception is reported. This finishes the per-CPU setup: the SCTLR bits, + // SVE where it is implemented, and the instruction cache. crate::arch::aarch64::cpu::init_cpu(); let mut info = BootInfo::default(); @@ -48,12 +47,18 @@ pub extern "C" fn kernel_entry(dtb_ptr: u64) -> ! { serial::print_hex(dtb_ptr); serial::println(b""); -let parsed = super::dtb_adapter::populate(dtb_ptr, &mut info); + #[cfg(feature = "nonos-trap-proof-sp0")] + super::trap_proof::sp_el0_vector(); + + let parsed = super::dtb_adapter::populate(dtb_ptr, &mut info); // Brings up the console, puts the MMU and caches into a known state, then // installs the vector table, the GIC and the timer. super::init(&info); + #[cfg(feature = "nonos-trap-proof-kernel-abort")] + super::trap_proof::kernel_data_abort(); + if parsed { serial::println(b"[NONOS] aarch64 boot init done"); } else { diff --git a/src/arch/aarch64/boot/info/types.rs b/src/arch/aarch64/boot/info/types.rs index a14977589d..9c1ea29935 100644 --- a/src/arch/aarch64/boot/info/types.rs +++ b/src/arch/aarch64/boot/info/types.rs @@ -15,10 +15,10 @@ // along with this program. If not, see . /// As many regions as the device tree walker reads in one pass. -pub const MAX_MEMORY_REGIONS: usize = 8; +pub(super) const MAX_MEMORY_REGIONS: usize = 8; /// As many CPUs as the device tree walker reads in one pass. -pub const MAX_CPUS: usize = 64; +pub(super) const MAX_CPUS: usize = 64; use super::memory::{MemoryRegion, MemoryType}; diff --git a/src/arch/aarch64/boot/init.rs b/src/arch/aarch64/boot/init.rs index 54350ccbd0..6bcd11d652 100644 --- a/src/arch/aarch64/boot/init.rs +++ b/src/arch/aarch64/boot/init.rs @@ -50,18 +50,18 @@ pub fn init(boot_info: &BootInfo) { ); crate::arch::aarch64::rtc::set_base(boot_info.rtc_base); exceptions::install_vbar_el1(); - if security::init_all().is_err() { - cpu::halt(); + if let Err(error) = security::init_all() { + super::refuse(b"security", super::security_reason(error)); } mmu::init_mmu(boot_info); if boot_info.gic_unsupported { - cpu::halt(); + super::refuse(b"gic", b"the device tree names a GIC other than v3, the only one driven"); } gic::init_gic(boot_info.gic_dist_base, boot_info.gic_redist_base); timer::init_timer(); timer::configure_preemption_intid(boot_info.timer_phys_intid); - if timer::install_on_cpu().is_err() { - cpu::halt(); + if let Err(reason) = timer::install_on_cpu() { + super::refuse(b"timer tick", reason.as_bytes()); } if super::multicore::roster::len() > 1 { super::multicore::start_secondary_cpus(boot_info); diff --git a/src/arch/aarch64/boot/mod.rs b/src/arch/aarch64/boot/mod.rs index b4f60db1d5..cf54d73f0d 100644 --- a/src/arch/aarch64/boot/mod.rs +++ b/src/arch/aarch64/boot/mod.rs @@ -21,7 +21,10 @@ mod init; mod memory; pub mod multicore; mod pci_windows; +mod refuse; pub mod stack; +#[cfg(any(feature = "nonos-trap-proof-sp0", feature = "nonos-trap-proof-kernel-abort"))] +mod trap_proof; pub use entry::kernel_entry; pub use info::{BootInfo, MemoryRegion}; @@ -29,4 +32,5 @@ pub use init::init; pub(crate) use memory::init_boot_memory; pub use multicore::start_secondary_cpus; pub(crate) use pci_windows::remap as remap_pci_windows; +pub(crate) use refuse::{refuse, security_reason}; pub use stack::setup_stack; diff --git a/src/arch/aarch64/boot/multicore/ap_entry.rs b/src/arch/aarch64/boot/multicore/ap_entry.rs index 31e33e9e9b..4cdd0d458f 100644 --- a/src/arch/aarch64/boot/multicore/ap_entry.rs +++ b/src/arch/aarch64/boot/multicore/ap_entry.rs @@ -16,6 +16,7 @@ use core::sync::atomic::Ordering; +use crate::arch::aarch64::boot::{refuse, security_reason}; use crate::arch::aarch64::cpu; use crate::arch::aarch64::exceptions::install_vbar_el1; use crate::arch::aarch64::gic::init_gic_cpu; @@ -29,19 +30,17 @@ use super::state::CPUS_ONLINE; pub extern "C" fn aarch64_ap_entry() -> ! { install_vbar_el1(); cpu::init_cpu(); - if security::init_all().is_err() { - cpu::halt(); + if let Err(error) = security::init_all() { + refuse(b"secondary security", security_reason(error)); } init_gic_cpu(); init_timer_cpu(); - if install_preemption_tick().is_err() { - cpu::halt(); + if let Err(reason) = install_preemption_tick() { + refuse(b"secondary timer tick", reason.as_bytes()); } CPUS_ONLINE.fetch_add(1, Ordering::AcqRel); - let cpu_id = cpu::id::cpu_id(); - loop { idle_cpu(); } diff --git a/src/arch/aarch64/boot/multicore/roster.rs b/src/arch/aarch64/boot/multicore/roster.rs index ef4b491158..d09909a6d4 100644 --- a/src/arch/aarch64/boot/multicore/roster.rs +++ b/src/arch/aarch64/boot/multicore/roster.rs @@ -51,7 +51,8 @@ pub(in crate::arch::aarch64) unsafe fn populate(affinities: &[u64]) { // SAFETY: the caller guarantees exclusive access, so writing the array is // not a data race, and `n` is clamped to both lengths. unsafe { - for (slot, affinity) in AFFINITIES.iter_mut().take(n).zip(source) { + let table = &mut *core::ptr::addr_of_mut!(AFFINITIES); + for (slot, affinity) in table.iter_mut().take(n).zip(source) { *slot = *affinity; } } diff --git a/src/arch/aarch64/boot/multicore/start_cpus.rs b/src/arch/aarch64/boot/multicore/start_cpus.rs index 49e1222eb1..f5d74a5f2d 100644 --- a/src/arch/aarch64/boot/multicore/start_cpus.rs +++ b/src/arch/aarch64/boot/multicore/start_cpus.rs @@ -35,7 +35,7 @@ const CHECKIN_SPINS: u64 = 200_000_000; /// kernel down because one core of many refused to come up turns a degraded /// boot into no boot at all. pub fn start_secondary_cpus(_boot_info: &BootInfo) { - let entry = _aarch64_secondary_start as u64; + let entry = _aarch64_secondary_start as unsafe extern "C" fn() as u64; let mut released = 0u32; for index in 1..roster::len() { diff --git a/src/arch/aarch64/boot/pci_windows.rs b/src/arch/aarch64/boot/pci_windows.rs index ef276100f0..f96e9877ee 100644 --- a/src/arch/aarch64/boot/pci_windows.rs +++ b/src/arch/aarch64/boot/pci_windows.rs @@ -32,7 +32,7 @@ static IO_SIZE: AtomicU64 = AtomicU64::new(0); /// Nothing is published to the accessors here. Config space sits at 256 GiB on /// this board, which the boot map does not describe, and `BootInfo` lives in /// the entry path's frame and is gone by the time `remap` runs. -pub fn publish(info: &super::BootInfo) { +pub(super) fn publish(info: &super::BootInfo) { ECAM_BASE.store(info.pci_ecam_base, Ordering::Relaxed); ECAM_SIZE.store(info.pci_ecam_size, Ordering::Relaxed); IO_BASE.store(info.pci_io_cpu_base, Ordering::Relaxed); @@ -54,7 +54,7 @@ pub fn publish(info: &super::BootInfo) { const ECAM_BUSES: u64 = 16; const ECAM_BUS_STRIDE: u64 = 0x10_0000; -pub fn remap() { +pub(crate) fn remap() { let ecam_size = ECAM_SIZE.load(Ordering::Acquire).min(ECAM_BUSES * ECAM_BUS_STRIDE); if ecam_size > 0 { match map(ECAM_BASE.load(Ordering::Relaxed), ecam_size) { diff --git a/src/arch/aarch64/boot/refuse.rs b/src/arch/aarch64/boot/refuse.rs new file mode 100644 index 0000000000..9382d5a490 --- /dev/null +++ b/src/arch/aarch64/boot/refuse.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 . + +//! Bring-up that cannot go on says why before the CPU parks. +//! +//! Each refusal is one `[BOOT]` line naming the step and the reason, written +//! through the fatal writer so it does not wait on a lock, then the CPU halts +//! with interrupts still masked as the boot path leaves them. + +use crate::arch::aarch64::cpu; +use crate::arch::aarch64::cpu::id::mpidr_affinity; +use crate::arch::aarch64::security::pac::PacError; +use crate::sys::serial::Line; + +/// Park this CPU after naming the step that failed and why. +pub(crate) fn refuse(step: &[u8], reason: &[u8]) -> ! { + Line::new() + .str(b"[BOOT] refused: ") + .str(step) + .str(b": ") + .str(reason) + .str(b", mpidr=") + .hex(mpidr_affinity()) + .end_fatal(); + cpu::halt() +} + +/// What the security bring-up error means, as a reason for [`refuse`]. +pub(crate) fn security_reason(error: PacError) -> &'static [u8] { + match error { + PacError::EntropyUnavailable => b"no entropy source to key pointer authentication from", + } +} diff --git a/src/arch/aarch64/boot/stack/register.rs b/src/arch/aarch64/boot/stack/register.rs index 19c5febb17..54d649f7e6 100644 --- a/src/arch/aarch64/boot/stack/register.rs +++ b/src/arch/aarch64/boot/stack/register.rs @@ -16,14 +16,14 @@ use core::arch::asm; -pub fn switch_to(kernel_top: u64, irq_top: u64) { +pub(super) fn switch_to(kernel_top: u64, irq_top: u64) { unsafe { asm!("mov sp, {0}", in(reg) kernel_top, options(nostack)); asm!("msr sp_el0, {0}", in(reg) irq_top, options(nostack)); } } -pub fn current_stack_pointer() -> u64 { +pub(super) fn current_stack_pointer() -> u64 { let sp: u64; unsafe { asm!("mov {}, sp", out(reg) sp, options(nostack)); diff --git a/src/arch/aarch64/boot/stack/state.rs b/src/arch/aarch64/boot/stack/state.rs index b8a6ac8f8f..5aaf4ee58c 100644 --- a/src/arch/aarch64/boot/stack/state.rs +++ b/src/arch/aarch64/boot/stack/state.rs @@ -47,18 +47,18 @@ static IRQ_STACKS: StackBank = static EXCEPTION_STACKS: StackBank = StackBank::new([const { ExceptionStack::new() }; MAX_CPUS]); -pub fn kernel_top(cpu_id: usize) -> Option { +pub(super) fn kernel_top(cpu_id: usize) -> Option { KERNEL_STACKS.get(cpu_id).map(KernelStack::top) } -pub fn kernel_base(cpu_id: usize) -> Option { +pub(super) fn kernel_base(cpu_id: usize) -> Option { KERNEL_STACKS.get(cpu_id).map(KernelStack::base) } -pub fn irq_top(cpu_id: usize) -> Option { +pub(super) fn irq_top(cpu_id: usize) -> Option { IRQ_STACKS.get(cpu_id).map(IrqStack::top) } -pub fn exception_top(cpu_id: usize) -> Option { +pub(super) fn exception_top(cpu_id: usize) -> Option { EXCEPTION_STACKS.get(cpu_id).map(ExceptionStack::top) } diff --git a/src/arch/aarch64/boot/trap_proof.rs b/src/arch/aarch64/boot/trap_proof.rs new file mode 100644 index 0000000000..bef81cb42a --- /dev/null +++ b/src/arch/aarch64/boot/trap_proof.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 . + +//! Deliberate exceptions for the aarch64 boot lane. +//! +//! Each feature here compiles one exception into the boot path at a fixed +//! point, and the lane asserts the exact line the kernel prints for it. What is +//! under test is that an exception this kernel does not resume from is named, +//! with its syndrome, before the CPU parks. No profile enables either feature, +//! so no shipped image carries them. +//! +//! Neither function returns, but both are typed as returning, so the boot code +//! after each call site stays reachable to the compiler and a proof build +//! carries the same warnings as the image it stands in for. + +/// `brk #0x5350` taken while EL1 runs on SP_EL0, which lands in the SP_EL0 +/// vector group this kernel never uses. Runs with the MMU off, before the +/// device tree is read, so it also proves the vectors are live that early. +/// The syndrome is fixed by the architecture: EC 0x3C, IL set, the immediate +/// in the ISS, so ESR_EL1 reads 0xF2005350. SP_EL0 is loaded with a value +/// nothing else would hold, so the lane can check the vector saved the stack +/// pointer the interrupted code was on rather than its own. +#[cfg(feature = "nonos-trap-proof-sp0")] +pub(super) fn sp_el0_vector() { + const SP_EL0_MARK: u64 = 0x0000_5350_5350_5350; + // SAFETY: SP_EL0 is written while SPSel still selects SP_EL1, where the + // write is permitted. The SP switch and the breakpoint are one asm block, + // so nothing runs on SP_EL0 in between. The exception is taken on SP_EL1, + // which still holds the boot stack, and its handler never returns. + unsafe { + core::arch::asm!( + "msr sp_el0, {mark}", + "msr spsel, #0", + "brk #0x5350", + mark = in(reg) SP_EL0_MARK, + options(noreturn, nostack) + ); + } +} + +/// A kernel read of a virtual address no boot table describes. Level 0 entry +/// 128 of the boot tables is never filled, so the walk stops there with a +/// level 0 translation fault. Runs after the MMU is on, so the report goes +/// through the trap contract and the serial lock is live. +#[cfg(feature = "nonos-trap-proof-kernel-abort")] +pub(super) fn kernel_data_abort() { + const UNMAPPED: u64 = 0x0000_4000_0000_0000; + // SAFETY: nothing maps UNMAPPED, so the read faults and never returns + // data; the fault is terminal and the CPU parks in the trap path. + let value = unsafe { core::ptr::read_volatile(UNMAPPED as *const u64) }; + crate::sys::serial::Line::new() + .str(b"[TRAP-PROOF] read of an unmapped address returned ") + .hex(value) + .end(); + crate::arch::aarch64::cpu::halt() +} diff --git a/src/arch/aarch64/context/capture/gprs.rs b/src/arch/aarch64/context/capture/gprs.rs index 8606427559..e1e7ead70e 100644 --- a/src/arch/aarch64/context/capture/gprs.rs +++ b/src/arch/aarch64/context/capture/gprs.rs @@ -17,7 +17,7 @@ use crate::arch::aarch64::context::types::SavedUser; use crate::arch::aarch64::exceptions::frame::ExceptionFrame; -pub fn copy(saved: &mut SavedUser, frame: &ExceptionFrame) { +pub(super) fn copy(saved: &mut SavedUser, frame: &ExceptionFrame) { saved.gprs[0] = frame.x0; saved.gprs[1] = frame.x1; saved.gprs[2] = frame.x2; diff --git a/src/arch/aarch64/cpu/control.rs b/src/arch/aarch64/cpu/control.rs index a280e1ae99..100c74ba57 100644 --- a/src/arch/aarch64/cpu/control.rs +++ b/src/arch/aarch64/cpu/control.rs @@ -21,8 +21,8 @@ use core::arch::asm; /// Deliberately leaves M, C and I alone. Translation and the caches come on /// together in `mmu::control::enable_mmu`, once TTBR and TCR are loaded and the /// tables describe the image. Setting M here faults on the next instruction -/// fetch, before the vectors are installed to report it, so the machine hangs -/// with nothing on the console to say why. +/// fetch, and with no tables loaded the fetch of the vector faults too, so the +/// machine hangs with nothing on the console to say why. /// /// SA0 keeps stack alignment checked at EL0. UCI lets EL0 issue the cache /// maintenance it is permitted. WXN is cleared because write implying diff --git a/src/arch/aarch64/cpu/features/probe.rs b/src/arch/aarch64/cpu/features/probe.rs index fe1ad07da1..7fad8fb9f3 100644 --- a/src/arch/aarch64/cpu/features/probe.rs +++ b/src/arch/aarch64/cpu/features/probe.rs @@ -53,7 +53,8 @@ pub fn has_feature(feature: CpuFeature) -> bool { CpuFeature::Mte => ((aa64pfr1 >> 8) & 0xF) >= 1, CpuFeature::Mte2 => ((aa64pfr1 >> 8) & 0xF) >= 2, CpuFeature::Rng => ((aa64isar0 >> 60) & 0xF) >= 1, - CpuFeature::Pan => (read_aa64mmfr1() & 0xF) >= 1, + // ID_AA64MMFR1_EL1.PAN is [23:20]; [3:0] is HAFDBS, a different feature. + CpuFeature::Pan => ((read_aa64mmfr1() >> 20) & 0xF) >= 1, } } diff --git a/src/arch/aarch64/cpu/features/registers.rs b/src/arch/aarch64/cpu/features/registers.rs index 814f63c6f6..64cc7b3826 100644 --- a/src/arch/aarch64/cpu/features/registers.rs +++ b/src/arch/aarch64/cpu/features/registers.rs @@ -16,7 +16,7 @@ use core::arch::asm; -pub fn read_aa64isar0() -> u64 { +pub(super) fn read_aa64isar0() -> u64 { let value: u64; unsafe { asm!("mrs {}, id_aa64isar0_el1", out(reg) value, options(nostack)); @@ -24,7 +24,7 @@ pub fn read_aa64isar0() -> u64 { value } -pub fn read_aa64isar1() -> u64 { +pub(super) fn read_aa64isar1() -> u64 { let value: u64; unsafe { asm!("mrs {}, id_aa64isar1_el1", out(reg) value, options(nostack)); @@ -32,7 +32,7 @@ pub fn read_aa64isar1() -> u64 { value } -pub fn read_aa64pfr0() -> u64 { +pub(super) fn read_aa64pfr0() -> u64 { let value: u64; unsafe { asm!("mrs {}, id_aa64pfr0_el1", out(reg) value, options(nostack)); @@ -40,7 +40,7 @@ pub fn read_aa64pfr0() -> u64 { value } -pub fn read_aa64pfr1() -> u64 { +pub(super) fn read_aa64pfr1() -> u64 { let value: u64; unsafe { asm!("mrs {}, id_aa64pfr1_el1", out(reg) value, options(nostack)); @@ -56,7 +56,7 @@ pub fn read_aa64pfr1() -> u64 { /// compiler a feature bit it may then emit into ordinary code. The encoding is /// architecturally fixed and reads as zero where SVE is absent, which is exactly /// the answer a feature probe wants. -pub fn read_aa64zfr0() -> u64 { +pub(super) fn read_aa64zfr0() -> u64 { let value: u64; // SAFETY: S3_0_C0_C4_4 is ID_AA64ZFR0_EL1. Reading an ID register at EL1 has // no side effects. @@ -67,7 +67,7 @@ pub fn read_aa64zfr0() -> u64 { } /// `ID_AA64MMFR1_EL1`, which reports the memory-model features PAN lives in. -pub fn read_aa64mmfr1() -> u64 { +pub(super) fn read_aa64mmfr1() -> u64 { let value: u64; // SAFETY: an identification register, always readable at EL1, no side effects. unsafe { diff --git a/src/arch/aarch64/exceptions/contract/cause.rs b/src/arch/aarch64/exceptions/contract/cause.rs index 02f6b3d23a..ad3bae8c71 100644 --- a/src/arch/aarch64/exceptions/contract/cause.rs +++ b/src/arch/aarch64/exceptions/contract/cause.rs @@ -16,12 +16,16 @@ use crate::arch::aarch64::exceptions::frame::ExceptionFrame; use crate::arch::aarch64::exceptions::syndrome::ExceptionClass; -use crate::arch::trap::contract::{FaultAccess, PageFaultInfo, TrapCause}; +use crate::arch::trap::contract::TrapCause; use super::page_fault; +/// `OtherException` carries the architectural EC from ESR_EL1[31:26], not +/// the position of its `ExceptionClass` variant, so the number printed is +/// the one the Arm ARM tables list. pub(super) fn project(frame: &ExceptionFrame) -> TrapCause { - let ec = ExceptionClass::from(((frame.esr >> 26) & 0x3F) as u8); + let raw = ((frame.esr >> 26) & 0x3F) as u8; + let ec = ExceptionClass::from(raw); match ec { ExceptionClass::DataAbortLower | ExceptionClass::DataAbortSame => { TrapCause::PageFault(page_fault::decode_data(frame)) @@ -37,7 +41,7 @@ pub(super) fn project(frame: &ExceptionFrame) -> TrapCause { | ExceptionClass::Fp32 | ExceptionClass::Fp64 => TrapCause::DeviceNotAvailable, ExceptionClass::Pac | ExceptionClass::EretEretaa | ExceptionClass::BranchTarget => { - TrapCause::OtherException(ec as u8) + TrapCause::OtherException(raw) } ExceptionClass::Unknown | ExceptionClass::WfeWfi @@ -58,6 +62,6 @@ pub(super) fn project(frame: &ExceptionFrame) -> TrapCause { | ExceptionClass::WatchpointLower | ExceptionClass::WatchpointSame | ExceptionClass::Bkpt32 - | ExceptionClass::Brk64 => TrapCause::OtherException(ec as u8), + | ExceptionClass::Brk64 => TrapCause::OtherException(raw), } } diff --git a/src/arch/aarch64/exceptions/contract/impl_frame.rs b/src/arch/aarch64/exceptions/contract/impl_frame.rs index c1c26e048c..890dd89807 100644 --- a/src/arch/aarch64/exceptions/contract/impl_frame.rs +++ b/src/arch/aarch64/exceptions/contract/impl_frame.rs @@ -35,4 +35,8 @@ impl ContractFrame for ExceptionFrame { fn cause(&self) -> TrapCause { cause::project(self) } + + fn syndrome(&self) -> Option { + Some(self.esr) + } } diff --git a/src/arch/aarch64/exceptions/handlers/fatal.rs b/src/arch/aarch64/exceptions/handlers/fatal.rs index 0ae27cee18..ecf6ff2d89 100644 --- a/src/arch/aarch64/exceptions/handlers/fatal.rs +++ b/src/arch/aarch64/exceptions/handlers/fatal.rs @@ -14,12 +14,65 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . +//! The vectors with no handler: SError, FIQ, every slot of the SP_EL0 and +//! AArch32 groups, and an interrupt nothing is routed to. Each names itself in +//! one `[TRAP]` line with the registers the vector saved, then parks the CPU. +//! +//! ESR_EL1 is written by a synchronous exception or an SError and left alone by +//! an interrupt, so interrupt lines leave it out rather than print an earlier +//! exception's syndrome as this one's. FAR_EL1 is written only by aborts, PC +//! alignment faults and watchpoints, and not even by an abort that reports it +//! invalid, so it is printed only then. + use crate::arch::aarch64::cpu; use crate::arch::aarch64::exceptions::frame::ExceptionFrame; +use crate::arch::aarch64::exceptions::syndrome::{decode_esr, ExceptionClass}; +use crate::arch::aarch64::exceptions::terminal; +use crate::sys::serial::Line; + +/// ISS bit 10 of an abort syndrome: FAR_EL1 does not hold the faulting address. +const ISS_FNV: u64 = 1 << 10; + +/// Report a terminal synchronous exception or SError. +pub(super) fn fatal(tag: &[u8], frame: &ExceptionFrame) -> ! { + if terminal::enter() { + let mut line = Line::new(); + line.str(b"[TRAP] ").str(tag).str(b" esr=").hex(frame.esr); + if far_is_valid(frame.esr) { + line.str(b" far=").hex(frame.far); + } + registers(&mut line, frame).end_fatal(); + } + cpu::halt() +} -pub fn fatal(_tag: &[u8], _frame: &ExceptionFrame) -> ! { - unsafe { - core::arch::asm!("msr daifset, #0xf", options(nostack)); +/// Report a terminal interrupt. `intid` is what the GIC acknowledged, or none +/// when the vector was taken with nothing acknowledged. +pub(super) fn fatal_interrupt(tag: &[u8], intid: Option, frame: &ExceptionFrame) -> ! { + if terminal::enter() { + let mut line = Line::new(); + line.str(b"[TRAP] ").str(tag); + if let Some(intid) = intid { + line.str(b" unrouted intid=").dec(u64::from(intid)); + } + registers(&mut line, frame).end_fatal(); } cpu::halt() } + +fn far_is_valid(esr: u64) -> bool { + let class = decode_esr(esr).class; + if class.is_data_abort() || class.is_instruction_abort() { + return esr & ISS_FNV == 0; + } + matches!( + class, + ExceptionClass::PcAlignment + | ExceptionClass::WatchpointLower + | ExceptionClass::WatchpointSame + ) +} + +fn registers<'a>(line: &'a mut Line, frame: &ExceptionFrame) -> &'a mut Line { + line.str(b" elr=").hex(frame.elr).str(b" spsr=").hex(frame.spsr).str(b" sp=").hex(frame.sp) +} diff --git a/src/arch/aarch64/exceptions/handlers/fiq.rs b/src/arch/aarch64/exceptions/handlers/fiq.rs index a9d93de482..c1575a7493 100644 --- a/src/arch/aarch64/exceptions/handlers/fiq.rs +++ b/src/arch/aarch64/exceptions/handlers/fiq.rs @@ -16,16 +16,16 @@ use crate::arch::aarch64::exceptions::frame::ExceptionFrame; -use super::fatal::fatal; +use super::fatal::fatal_interrupt; #[no_mangle] pub extern "C" fn aarch64_exc_fiq_current(frame: *mut ExceptionFrame) -> ! { let frame = unsafe { &*frame }; - fatal(b"FIQ EL1", frame) + fatal_interrupt(b"FIQ EL1", None, frame) } #[no_mangle] pub extern "C" fn aarch64_exc_fiq_lower(frame: *mut ExceptionFrame) -> ! { let frame = unsafe { &*frame }; - fatal(b"FIQ EL0", frame) + fatal_interrupt(b"FIQ EL0", None, frame) } diff --git a/src/arch/aarch64/exceptions/handlers/invalid.rs b/src/arch/aarch64/exceptions/handlers/invalid.rs index 9d3cb4b930..7a7f439c65 100644 --- a/src/arch/aarch64/exceptions/handlers/invalid.rs +++ b/src/arch/aarch64/exceptions/handlers/invalid.rs @@ -16,16 +16,31 @@ use crate::arch::aarch64::exceptions::frame::ExceptionFrame; -use super::fatal::fatal; +use super::fatal::{fatal, fatal_interrupt}; + +/// Which slot of the group was taken, as its vector entry passes it in x1. +const KIND_IRQ: u64 = 1; +const KIND_FIQ: u64 = 2; +const KIND_SERROR: u64 = 3; #[no_mangle] -pub extern "C" fn aarch64_exc_invalid_sp0(frame: *mut ExceptionFrame) -> ! { +pub extern "C" fn aarch64_exc_invalid_sp0(frame: *mut ExceptionFrame, kind: u64) -> ! { let frame = unsafe { &*frame }; - fatal(b"SP_EL0 vector", frame) + match kind { + KIND_IRQ => fatal_interrupt(b"SP_EL0 vector IRQ", None, frame), + KIND_FIQ => fatal_interrupt(b"SP_EL0 vector FIQ", None, frame), + KIND_SERROR => fatal(b"SP_EL0 vector SError", frame), + _ => fatal(b"SP_EL0 vector sync", frame), + } } #[no_mangle] -pub extern "C" fn aarch64_exc_invalid_aarch32(frame: *mut ExceptionFrame) -> ! { +pub extern "C" fn aarch64_exc_invalid_aarch32(frame: *mut ExceptionFrame, kind: u64) -> ! { let frame = unsafe { &*frame }; - fatal(b"AArch32 vector", frame) + match kind { + KIND_IRQ => fatal_interrupt(b"AArch32 vector IRQ", None, frame), + KIND_FIQ => fatal_interrupt(b"AArch32 vector FIQ", None, frame), + KIND_SERROR => fatal(b"AArch32 vector SError", frame), + _ => fatal(b"AArch32 vector sync", frame), + } } diff --git a/src/arch/aarch64/exceptions/handlers/irq.rs b/src/arch/aarch64/exceptions/handlers/irq.rs index a5dda7bb36..81ec35ca25 100644 --- a/src/arch/aarch64/exceptions/handlers/irq.rs +++ b/src/arch/aarch64/exceptions/handlers/irq.rs @@ -18,7 +18,7 @@ use crate::arch::aarch64::context::save_user_frame; use crate::arch::aarch64::exceptions::frame::ExceptionFrame; use crate::arch::aarch64::gic::{acknowledge_interrupt, dispatch_irq, end_interrupt}; -use super::fatal::fatal; +use super::fatal::fatal_interrupt; #[no_mangle] pub extern "C" fn aarch64_exc_irq_current(frame: *mut ExceptionFrame) { @@ -44,5 +44,5 @@ fn handle(frame: &ExceptionFrame, tag: &[u8]) { return; } end_interrupt(intid); - fatal(tag, frame) + fatal_interrupt(tag, Some(intid), frame) } diff --git a/src/arch/aarch64/exceptions/handlers/sync.rs b/src/arch/aarch64/exceptions/handlers/sync.rs index ed79507b51..32bf5246e6 100644 --- a/src/arch/aarch64/exceptions/handlers/sync.rs +++ b/src/arch/aarch64/exceptions/handlers/sync.rs @@ -15,10 +15,11 @@ // along with this program. If not, see . use crate::arch::aarch64::exceptions::frame::ExceptionFrame; -use crate::arch::aarch64::fpu::try_enable_for_current_task; +use crate::arch::aarch64::fpu::{enable as enable_fp, try_enable_for_current_task}; use crate::arch::trap::contract::deliver; +use crate::process::signal::SIGILL; +use crate::sys::serial::Line; -use super::fatal::fatal; use super::svc; #[no_mangle] @@ -42,7 +43,28 @@ pub extern "C" fn aarch64_exc_sync_lower(frame: *mut ExceptionFrame) { if try_enable_for_current_task() { return; } - fatal(b"FP/SIMD access (no per-task FP slot)", frame) + refuse_fp(frame) } deliver(frame) } + +/// An EL0 task touched the vector registers and there is nowhere to keep its +/// FP/SIMD state. Enabling the unit for it would hand it the registers of +/// whichever task last used them, so the task is ended with SIGILL and the +/// CPU goes on to the next one. +/// +/// The trap means CPACR_EL1.FPEN was 0b00, which traps EL1 too, and the exit +/// path is compiled code that uses the vector registers. So the unit is +/// enabled for the kernel first. The task never returns to EL0, and the next +/// task's `prepare_incoming` sets FPEN again before it runs. +fn refuse_fp(frame: &ExceptionFrame) -> ! { + enable_fp(); + Line::new() + .str(b"[FPU] refused: EL0 FP/SIMD access with no per-task FP slot, pid=") + .dec(u64::from(crate::process::current_pid().unwrap_or(0))) + .str(b" elr=") + .hex(frame.elr) + .str(b" signal=SIGILL") + .end(); + crate::process::terminate_current_with_signal(SIGILL) +} diff --git a/src/arch/aarch64/exceptions/mod.rs b/src/arch/aarch64/exceptions/mod.rs index bdb5116ab8..d8f77c9b35 100644 --- a/src/arch/aarch64/exceptions/mod.rs +++ b/src/arch/aarch64/exceptions/mod.rs @@ -19,6 +19,7 @@ pub mod frame; pub mod handlers; pub mod install; pub mod syndrome; +pub(crate) mod terminal; pub mod verify; pub use frame::ExceptionFrame; diff --git a/src/arch/aarch64/exceptions/terminal.rs b/src/arch/aarch64/exceptions/terminal.rs new file mode 100644 index 0000000000..fc96bdad06 --- /dev/null +++ b/src/arch/aarch64/exceptions/terminal.rs @@ -0,0 +1,89 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public 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 way into every exception this kernel does not resume from. +//! +//! Both terminal paths, the vectors with no handler and the synchronous faults +//! the trap contract classifies as fatal, come through [`enter`] before they +//! print. It masks D, A, I and F on this CPU, gives EL1 the FP/SIMD registers, +//! and counts how deep this CPU is in the terminal path. The first time, the +//! caller reports. If the report itself faults, the second entry says so in a +//! line that needs no formatting and the CPU parks. Any deeper entry parks +//! without writing, because by then writing is what faults. +//! +//! FP/SIMD is granted because the report is compiled code that uses the vector +//! registers, and lazy FP leaves CPACR_EL1.FPEN trapping EL1 as well as EL0 +//! while a task that has not touched them since it was switched in is current. +//! The CPU is parking, so whose register contents this exposes does not matter. +//! +//! The count is kept per CPU, by roster index, so that two CPUs failing at once +//! both report. It is read and written with plain loads and stores, never an +//! atomic read-modify-write: the path runs with the MMU off early in boot, when +//! this memory is Device memory and exclusive access to it is not guaranteed. + +use core::arch::asm; +use core::sync::atomic::{AtomicU8, Ordering}; + +use crate::arch::aarch64::boot::stack::MAX_CPUS; +use crate::arch::aarch64::cpu::id::cpu_id; +use crate::sys::serial::core::write_fatal_line; + +/// CPACR_EL1.FPEN = 0b11: FP/SIMD usable at EL1 and EL0 without trapping. +const CPACR_FPEN_FULL: u64 = 0b11 << 20; + +static DEPTH: [AtomicU8; MAX_CPUS] = [const { AtomicU8::new(0) }; MAX_CPUS]; + +const NESTED: &[u8] = b"[TRAP] fault while reporting a fault, this CPU is parked"; + +/// Mask interrupts, grant EL1 the vector registers, and say whether this CPU +/// may report. False means the caller must go straight to its halt. +pub(crate) fn enter() -> bool { + // SAFETY: masking D, A, I and F at EL1 is always permitted and only stops + // further asynchronous exceptions on this CPU. Widening CPACR_EL1.FPEN + // cannot fault, and the ISB makes it visible before any vector register + // use that follows. No `nomem`: the compiler must not move a load that + // may use a vector register above the grant. + unsafe { + asm!( + "msr daifset, #0xf", + "mrs {t}, cpacr_el1", + "orr {t}, {t}, {fpen}", + "msr cpacr_el1, {t}", + "isb", + t = out(reg) _, + fpen = in(reg) CPACR_FPEN_FULL, + options(nostack, preserves_flags) + ); + } + let depth = &DEPTH[slot()]; + let seen = depth.load(Ordering::Relaxed); + depth.store(seen.saturating_add(1), Ordering::Relaxed); + match seen { + 0 => true, + 1 => { + write_fatal_line(NESTED); + false + } + _ => false, + } +} + +/// This CPU's roster index. Every CPU the kernel starts has its own; before the +/// roster is latched it is 0, which is right because only the boot CPU runs +/// then. The lookup is a load-acquire and plain loads, nothing exclusive. +fn slot() -> usize { + cpu_id() % MAX_CPUS +} diff --git a/src/arch/aarch64/fpu/context.rs b/src/arch/aarch64/fpu/context.rs index d239be8516..4a325f02e9 100644 --- a/src/arch/aarch64/fpu/context.rs +++ b/src/arch/aarch64/fpu/context.rs @@ -28,5 +28,3 @@ impl FpSimdContext { Self { q: [0u128; 32], fpsr: 0, fpcr: 0, _pad: [0u32; 2] } } } - -pub const FP_SIMD_CONTEXT_BYTES: usize = core::mem::size_of::(); diff --git a/src/arch/aarch64/fpu/current.rs b/src/arch/aarch64/fpu/current.rs index d1f982833f..d63f05ed43 100644 --- a/src/arch/aarch64/fpu/current.rs +++ b/src/arch/aarch64/fpu/current.rs @@ -20,7 +20,7 @@ use crate::process::core::{CURRENT_PID, PROCESS_TABLE}; use super::slot::FpSimdSlot; -pub fn slot_mut() -> Option<&'static mut FpSimdSlot> { +pub(super) fn slot_mut() -> Option<&'static mut FpSimdSlot> { let pid = CURRENT_PID.load(Ordering::Acquire); if pid == 0 { return None; diff --git a/src/arch/aarch64/fpu/enable.rs b/src/arch/aarch64/fpu/enable.rs index a220f54d1d..10978c1fad 100644 --- a/src/arch/aarch64/fpu/enable.rs +++ b/src/arch/aarch64/fpu/enable.rs @@ -26,8 +26,10 @@ pub fn enable() { asm!("mrs {}, cpacr_el1", out(reg) cpacr, options(nomem, nostack)); } cpacr = (cpacr & !CPACR_FPEN_MASK) | CPACR_FPEN_FULL; + // No `nomem`: callers use the vector registers straight after, and a load + // into one must not be moved above the write that stops it trapping. unsafe { - asm!("msr cpacr_el1, {}", "isb", in(reg) cpacr, options(nomem, nostack)); + asm!("msr cpacr_el1, {}", "isb", in(reg) cpacr, options(nostack)); } } diff --git a/src/arch/aarch64/gic/irq_handlers/state.rs b/src/arch/aarch64/gic/irq_handlers/state.rs index aac70c0190..ef3aedee0d 100644 --- a/src/arch/aarch64/gic/irq_handlers/state.rs +++ b/src/arch/aarch64/gic/irq_handlers/state.rs @@ -16,7 +16,7 @@ use core::sync::atomic::{AtomicPtr, AtomicU8}; -pub const MAX_INTID: u32 = 1020; +pub(super) const MAX_INTID: u32 = 1020; pub(super) static IRQ_HANDLERS: [AtomicPtr<()>; MAX_INTID as usize] = { const INIT: AtomicPtr<()> = AtomicPtr::new(core::ptr::null_mut()); diff --git a/src/arch/aarch64/interrupt_controller/intid.rs b/src/arch/aarch64/interrupt_controller/intid.rs index 3439e44de3..53371abe02 100644 --- a/src/arch/aarch64/interrupt_controller/intid.rs +++ b/src/arch/aarch64/interrupt_controller/intid.rs @@ -22,12 +22,12 @@ use crate::arch::interrupt_controller::Ipi; -pub const SGI_TLB_SHOOTDOWN: u32 = 0; -pub const SGI_RESCHEDULE: u32 = 1; -pub const SGI_CALL_FUNCTION: u32 = 2; -pub const SGI_BARRIER: u32 = 3; -pub const SGI_PANIC: u32 = 4; -pub const SGI_STOP: u32 = 5; +pub(super) const SGI_TLB_SHOOTDOWN: u32 = 0; +pub(super) const SGI_RESCHEDULE: u32 = 1; +pub(super) const SGI_CALL_FUNCTION: u32 = 2; +pub(super) const SGI_BARRIER: u32 = 3; +pub(super) const SGI_PANIC: u32 = 4; +pub(super) const SGI_STOP: u32 = 5; pub const fn intid_of(ipi: Ipi) -> u32 { match ipi { diff --git a/src/arch/aarch64/mmu/boot_map.rs b/src/arch/aarch64/mmu/boot_map.rs index ca2a638f94..60371bfc40 100644 --- a/src/arch/aarch64/mmu/boot_map.rs +++ b/src/arch/aarch64/mmu/boot_map.rs @@ -14,13 +14,8 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -extern "C" { - static __kernel_image_start: u8; - static __kernel_rw_start: u8; -} - use super::super::boot::info::{BootInfo, MemoryType as BootMemoryType}; -use super::{control, state, ttbr, PageAttributes}; +use super::{control, image_map, state, ttbr, PageAttributes}; const BLOCK_2M: u64 = 2 * 1024 * 1024; const DEVICE_SLOT: usize = 3; @@ -119,22 +114,25 @@ unsafe fn map_range(slot: usize, base: u64, size: u64, kind: BootMemoryType) { let data_attrs = region_attrs(kind); let code_attrs = PageAttributes::kernel_code(); - let img_start = (&raw const __kernel_image_start) as u64; - // Only text and rodata are executable and read only. Everything from the - // writable data onward, .bss and the stack included, has to stay writable. - let img_end = (&raw const __kernel_rw_start) as u64; let mut phys = base & !(BLOCK_2M - 1); let end = base.saturating_add(size); // One table describes one gigabyte; anything past it belongs to another slot. let table_end = ((l1_idx as u64) + 1) * GIB; while phys < end && phys < table_end { let l2_idx = ((phys / BLOCK_2M) % ENTRIES) as usize; - // The image is inside a region the firmware calls Available, so pick - // attributes per block rather than per region or our own text ends up - // mapped execute-never. - let overlaps_image = phys < img_end && phys.saturating_add(BLOCK_2M) > img_start; - let attrs = if overlaps_image { &code_attrs } else { &data_attrs }; - state::l2(slot).set_block(l2_idx, phys, attrs); + /* + * The image sits inside a region the firmware calls Available, and its + * text and read-only data share a block, so a block that touches it is + * described page by page. Only when no level 3 table covers the block is + * it mapped whole and executable, which the kernel section check then + * reports as W^X not held. + */ + let paged = image_map::overlaps_image(phys) + && image_map::map_image_block(slot, l1_idx, l2_idx, phys, &data_attrs); + if !paged { + let attrs = if image_map::overlaps_image(phys) { &code_attrs } else { &data_attrs }; + state::l2(slot).set_block(l2_idx, phys, attrs); + } phys = phys.saturating_add(BLOCK_2M); } } diff --git a/src/arch/aarch64/mmu/image_map.rs b/src/arch/aarch64/mmu/image_map.rs new file mode 100644 index 0000000000..bafe8a6cd2 --- /dev/null +++ b/src/arch/aarch64/mmu/image_map.rs @@ -0,0 +1,94 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public 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 image at page granularity. +//! +//! The boot map describes RAM in 2 MiB blocks, and one block carries one set of +//! permissions. Text and read-only data share the block the image starts in, so +//! mapped as a block the read-only data is executable and the kernel section +//! check reports W^X broken. Each block the image touches is described by a table +//! of 4 KiB pages instead: text is read only and executable, the rest of the +//! image read only and execute never, and the part of the block outside the +//! image keeps the attributes of the region it belongs to. + +use super::{state, PageAttributes}; + +extern "C" { + static __kernel_image_start: u8; + static __kernel_text_start: u8; + static __kernel_text_end: u8; + static __kernel_rw_start: u8; +} + +const PAGE_4K: u64 = 4096; +const BLOCK_2M: u64 = 2 * 1024 * 1024; +const PAGES_PER_BLOCK: usize = 512; + +/// Whether the 2 MiB block at `block` holds any part of the text or read-only +/// data. Writable data starts on its own 2 MiB boundary, so it never shares one. +pub(super) fn overlaps_image(block: u64) -> bool { + let (start, end) = image_bounds(); + block < end && block.saturating_add(BLOCK_2M) > start +} + +/// Describe the 2 MiB block at `block` with 4 KiB pages. The block sits at +/// `l2_index` of level 2 table `slot`, under level 1 entry `l1_index`. Returns +/// false having written nothing when no level 3 table exists for that position, +/// and the caller maps the block whole. +/// +/// # Safety +/// +/// Boot CPU only, before these tables are live, with no other reference to level +/// 2 table `slot` or to the level 3 table at (`l1_index`, `l2_index`). +pub(super) unsafe fn map_image_block( + slot: usize, + l1_index: usize, + l2_index: usize, + block: u64, + outside: &PageAttributes, +) -> bool { + if l1_index >= state::L3_L1_SPAN || l2_index >= PAGES_PER_BLOCK { + return false; + } + let code = PageAttributes::kernel_code(); + let rodata = PageAttributes::kernel_rodata(); + let (image_start, image_end) = image_bounds(); + let (text_start, text_end) = text_bounds(); + let table = state::l3(l1_index, l2_index); + let mut page = block; + for index in 0..PAGES_PER_BLOCK { + let next = page.saturating_add(PAGE_4K); + let attrs = if page < text_end && next > text_start { + &code + } else if page < image_end && next > image_start { + &rodata + } else { + outside + }; + table.set_page(index, page, attrs); + page = next; + } + state::l2(slot).set_table(l2_index, state::l3_addr(l1_index, l2_index)); + true +} + +fn image_bounds() -> (u64, u64) { + ((&raw const __kernel_image_start) as u64, (&raw const __kernel_rw_start) as u64) +} + +fn text_bounds() -> (u64, u64) { + ((&raw const __kernel_text_start) as u64, (&raw const __kernel_text_end) as u64) +} diff --git a/src/arch/aarch64/mmu/mod.rs b/src/arch/aarch64/mmu/mod.rs index c8b80bf462..2c9f9d4d02 100644 --- a/src/arch/aarch64/mmu/mod.rs +++ b/src/arch/aarch64/mmu/mod.rs @@ -18,6 +18,7 @@ pub mod attributes; mod boot_map; mod control; pub mod granule; +mod image_map; mod map; mod state; pub mod table; diff --git a/src/arch/aarch64/mmu/state.rs b/src/arch/aarch64/mmu/state.rs index ae8cf6f3e8..6eda79ba53 100644 --- a/src/arch/aarch64/mmu/state.rs +++ b/src/arch/aarch64/mmu/state.rs @@ -31,44 +31,64 @@ static mut KERNEL_L2: [PageTable; 6] = [ /// the direct map hangs off its own level 1 rather than sharing the identity /// map's. static mut KERNEL_L1_HIGH: PageTable = PageTable::new(); -static mut KERNEL_L3: [[PageTable; 512]; 4] = [[PageTable::new(); 512]; 4]; +/// How many level 1 entries have level 3 tables behind them. +pub(super) const L3_L1_SPAN: usize = 4; +static mut KERNEL_L3: [[PageTable; 512]; L3_L1_SPAN] = [[PageTable::new(); 512]; L3_L1_SPAN]; +/* + * The table getters hand out `&'static mut` to a static, so two live results for + * the same table alias. Callers must hold at most one at a time, which the boot + * path does: it builds the tables on the boot CPU before any secondary or + * interrupt exists. The borrow is taken through a raw pointer so no shared or + * unique reference to the whole static is ever formed on the way. + */ + +/// # Safety +/// No other reference to `KERNEL_L0` may be live while the result is. pub(super) unsafe fn l0() -> &'static mut PageTable { - &mut KERNEL_L0 + &mut *core::ptr::addr_of_mut!(KERNEL_L0) } +/// # Safety +/// No other reference to `KERNEL_L1` may be live while the result is. pub(super) unsafe fn l1() -> &'static mut PageTable { - &mut KERNEL_L1 + &mut *core::ptr::addr_of_mut!(KERNEL_L1) } +/// # Safety +/// No other reference to `KERNEL_L2[index]` may be live while the result is. pub(super) unsafe fn l2(index: usize) -> &'static mut PageTable { - &mut KERNEL_L2[index] + &mut (*core::ptr::addr_of_mut!(KERNEL_L2))[index] } +/// # Safety +/// No other reference to `KERNEL_L3[l1][l2]` may be live while the result is. pub(super) unsafe fn l3(l1: usize, l2: usize) -> &'static mut PageTable { - &mut KERNEL_L3[l1][l2] + &mut (*core::ptr::addr_of_mut!(KERNEL_L3))[l1][l2] } pub(super) unsafe fn l0_addr() -> u64 { - &KERNEL_L0 as *const _ as u64 + core::ptr::addr_of!(KERNEL_L0) as u64 } pub(super) unsafe fn l1_addr() -> u64 { - &KERNEL_L1 as *const _ as u64 + core::ptr::addr_of!(KERNEL_L1) as u64 } +/// # Safety +/// No other reference to `KERNEL_L1_HIGH` may be live while the result is. pub(super) unsafe fn l1_high() -> &'static mut PageTable { - &mut KERNEL_L1_HIGH + &mut *core::ptr::addr_of_mut!(KERNEL_L1_HIGH) } pub(super) unsafe fn l1_high_addr() -> u64 { - &KERNEL_L1_HIGH as *const _ as u64 + core::ptr::addr_of!(KERNEL_L1_HIGH) as u64 } pub(super) unsafe fn l2_addr(index: usize) -> u64 { - &KERNEL_L2[index] as *const _ as u64 + core::ptr::addr_of!((*core::ptr::addr_of!(KERNEL_L2))[index]) as u64 } pub(super) unsafe fn l3_addr(l1: usize, l2: usize) -> u64 { - &KERNEL_L3[l1][l2] as *const _ as u64 + core::ptr::addr_of!((*core::ptr::addr_of!(KERNEL_L3))[l1][l2]) as u64 } diff --git a/src/arch/aarch64/security/mte/range.rs b/src/arch/aarch64/security/mte/range.rs index 998d1fce59..f05d64b852 100644 --- a/src/arch/aarch64/security/mte/range.rs +++ b/src/arch/aarch64/security/mte/range.rs @@ -25,7 +25,12 @@ pub fn clear_tag(ptr: *mut u8, size: usize) { } fn clear_granule(addr: u64) { + /* + * STZG takes the tag from bits [59:56] of a general register, and register + * 31 in that field is SP, not XZR, so the zero tag has to come from a + * register that holds 0. + */ unsafe { - asm!("stzg xzr, [{0}]", in(reg) addr); + asm!("stzg {tag}, [{addr}]", tag = in(reg) 0u64, addr = in(reg) addr, options(nostack, preserves_flags)); } } diff --git a/src/arch/aarch64/security/pac/key.rs b/src/arch/aarch64/security/pac/key.rs index 4b40be8aa3..ed38aba414 100644 --- a/src/arch/aarch64/security/pac/key.rs +++ b/src/arch/aarch64/security/pac/key.rs @@ -15,17 +15,17 @@ // along with this program. If not, see . #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct PacKey { +pub(super) struct PacKey { pub lo: u64, pub hi: u64, } impl PacKey { - pub const fn new(lo: u64, hi: u64) -> Self { + pub(super) const fn new(lo: u64, hi: u64) -> Self { Self { lo, hi } } - pub fn from_bytes(bytes: [u8; 16]) -> Self { + pub(super) fn from_bytes(bytes: [u8; 16]) -> Self { let lo = u64::from_le_bytes(first_word(bytes)); let hi = u64::from_le_bytes(second_word(bytes)); Self { lo, hi } @@ -33,7 +33,7 @@ impl PacKey { } #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct PacKeys { +pub(super) struct PacKeys { pub ia: PacKey, pub ib: PacKey, pub da: PacKey, diff --git a/src/arch/aarch64/security/pac/keygen.rs b/src/arch/aarch64/security/pac/keygen.rs index ef6ae25557..f7ff214388 100644 --- a/src/arch/aarch64/security/pac/keygen.rs +++ b/src/arch/aarch64/security/pac/keygen.rs @@ -22,7 +22,7 @@ use crate::crypto::rng::fill_random_bytes_secure; use super::error::{PacError, PacResult}; use super::key::{PacKey, PacKeys}; -pub fn generate_keys() -> PacResult { +pub(super) fn generate_keys() -> PacResult { Ok(PacKeys { ia: generate_key()?, ib: generate_key()?, diff --git a/src/arch/aarch64/security/pac/registers.rs b/src/arch/aarch64/security/pac/registers.rs index 651693ae62..dc9767de8b 100644 --- a/src/arch/aarch64/security/pac/registers.rs +++ b/src/arch/aarch64/security/pac/registers.rs @@ -18,7 +18,7 @@ use core::arch::asm; use super::key::PacKeys; -pub fn install_keys(keys: &PacKeys) { +pub(super) fn install_keys(keys: &PacKeys) { unsafe { asm!("msr apiakeylo_el1, {}", in(reg) keys.ia.lo); asm!("msr apiakeyhi_el1, {}", in(reg) keys.ia.hi); diff --git a/src/arch/aarch64/timer/preemption/state.rs b/src/arch/aarch64/timer/preemption/state.rs index c9884e392d..858831ee06 100644 --- a/src/arch/aarch64/timer/preemption/state.rs +++ b/src/arch/aarch64/timer/preemption/state.rs @@ -22,6 +22,6 @@ pub fn configure(intid: u32) { PHYS_INTID.store(intid, Ordering::Release); } -pub fn phys_intid() -> u32 { +pub(super) fn phys_intid() -> u32 { PHYS_INTID.load(Ordering::Acquire) } diff --git a/src/arch/aarch64/uart/pl011.rs b/src/arch/aarch64/uart/pl011.rs index b6cf6a7202..ec57bb2215 100644 --- a/src/arch/aarch64/uart/pl011.rs +++ b/src/arch/aarch64/uart/pl011.rs @@ -24,6 +24,5 @@ mod rx; mod tx; pub use api::{getc, init_uart, putc, puts}; -pub use config::{Pl011ConfigError, Pl011ConfigResult}; pub use device::Pl011; pub use interrupt::handle_uart_interrupt; diff --git a/src/arch/aarch64/uart/pl011/config.rs b/src/arch/aarch64/uart/pl011/config.rs index 7321a49b63..9115b71345 100644 --- a/src/arch/aarch64/uart/pl011/config.rs +++ b/src/arch/aarch64/uart/pl011/config.rs @@ -23,7 +23,7 @@ pub enum Pl011ConfigError { InvalidClock, } -pub type Pl011ConfigResult = Result; +pub(super) type Pl011ConfigResult = Result; impl Pl011 { pub fn init(&self, baud: u32, clock: u32) -> Pl011ConfigResult<()> { diff --git a/src/arch/aarch64/uart/pl011/constants.rs b/src/arch/aarch64/uart/pl011/constants.rs index 7b53ebaf04..43f1224701 100644 --- a/src/arch/aarch64/uart/pl011/constants.rs +++ b/src/arch/aarch64/uart/pl011/constants.rs @@ -14,26 +14,26 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -pub const UARTDR: u64 = 0x000; -pub const UARTFR: u64 = 0x018; -pub const UARTIBRD: u64 = 0x024; -pub const UARTFBRD: u64 = 0x028; -pub const UARTLCR_H: u64 = 0x02C; -pub const UARTCR: u64 = 0x030; -pub const UARTIMSC: u64 = 0x038; -pub const UARTMIS: u64 = 0x040; -pub const UARTICR: u64 = 0x044; +pub(super) const UARTDR: u64 = 0x000; +pub(super) const UARTFR: u64 = 0x018; +pub(super) const UARTIBRD: u64 = 0x024; +pub(super) const UARTFBRD: u64 = 0x028; +pub(super) const UARTLCR_H: u64 = 0x02C; +pub(super) const UARTCR: u64 = 0x030; +pub(super) const UARTIMSC: u64 = 0x038; +pub(super) const UARTMIS: u64 = 0x040; +pub(super) const UARTICR: u64 = 0x044; -pub const FR_BUSY: u32 = 1 << 3; -pub const FR_RXFE: u32 = 1 << 4; -pub const FR_TXFF: u32 = 1 << 5; +pub(super) const FR_BUSY: u32 = 1 << 3; +pub(super) const FR_RXFE: u32 = 1 << 4; +pub(super) const FR_TXFF: u32 = 1 << 5; -pub const CR_UARTEN: u32 = 1 << 0; -pub const CR_TXE: u32 = 1 << 8; -pub const CR_RXE: u32 = 1 << 9; +pub(super) const CR_UARTEN: u32 = 1 << 0; +pub(super) const CR_TXE: u32 = 1 << 8; +pub(super) const CR_RXE: u32 = 1 << 9; -pub const LCR_FEN: u32 = 1 << 4; -pub const LCR_WLEN_8: u32 = 0b11 << 5; +pub(super) const LCR_FEN: u32 = 1 << 4; +pub(super) const LCR_WLEN_8: u32 = 0b11 << 5; -pub const IMSC_RXIM: u32 = 1 << 4; -pub const INTERRUPT_CLEAR_ALL: u32 = 0x7FF; +pub(super) const IMSC_RXIM: u32 = 1 << 4; +pub(super) const INTERRUPT_CLEAR_ALL: u32 = 0x7FF; diff --git a/src/arch/paging/descriptor/aarch64/build.rs b/src/arch/paging/descriptor/aarch64/build.rs index 0202889c98..241404a451 100644 --- a/src/arch/paging/descriptor/aarch64/build.rs +++ b/src/arch/paging/descriptor/aarch64/build.rs @@ -64,10 +64,17 @@ const fn execute_never(user: bool, executable: bool) -> u64 { /// An entry pointing at the next level of table. /// -/// The hierarchical attribute bits at 63:59 stay clear, so this level -/// restricts nothing and the leaf decides. `user_accessible` is unused for -/// that reason and taken only so both backends share a signature. +/// A table built without `user_accessible` sets APTable[0] (bit 61), which takes +/// EL0 access away from everything the walk reaches below it, whatever the leaves +/// say. That is the aarch64 counterpart of a clear U/S bit on an x86_64 interior +/// entry and it is the bit `table_grants_user` reads. The other hierarchical bits +/// stay clear, so a user table restricts nothing and its leaves decide. #[inline] -pub const fn table(pa: u64, _user_accessible: bool) -> u64 { - (pa & ADDR_MASK) | VALID | TABLE_OR_PAGE +pub const fn table(pa: u64, user_accessible: bool) -> u64 { + let entry = (pa & ADDR_MASK) | VALID | TABLE_OR_PAGE; + if user_accessible { + entry + } else { + entry | APTABLE_NO_EL0 + } } diff --git a/src/arch/paging/descriptor/aarch64/mod.rs b/src/arch/paging/descriptor/aarch64/mod.rs index c8344189bf..bd97a5d0a3 100644 --- a/src/arch/paging/descriptor/aarch64/mod.rs +++ b/src/arch/paging/descriptor/aarch64/mod.rs @@ -26,4 +26,6 @@ mod read; pub use bits::ADDR_MASK; pub use build::{leaf, table}; -pub use read::{address, is_block, is_present, is_user, is_writable, table_grants_user}; +pub use read::{ + address, is_block, is_executable, is_present, is_user, is_writable, table_grants_user, +}; diff --git a/src/arch/trap/contract/backend_aarch64/detail.rs b/src/arch/trap/contract/backend_aarch64/detail.rs index 07543ec773..4f87518570 100644 --- a/src/arch/trap/contract/backend_aarch64/detail.rs +++ b/src/arch/trap/contract/backend_aarch64/detail.rs @@ -15,43 +15,41 @@ // along with this program. If not, see . use crate::arch::trap::contract::cause::{FaultAccess, PageFaultInfo, TrapCause}; -use crate::sys::serial::{print_hex, print_str}; +use crate::sys::serial::Line; pub(super) fn report(cause: &TrapCause) { + let mut line = Line::new(); + line.str(b"[TRAP] "); match cause { - TrapCause::PageFault(info) => page_fault(info), + TrapCause::PageFault(info) => page_fault(&mut line, info), TrapCause::ProtectionFault { error_code } | TrapCause::StackSegment { error_code } | TrapCause::SegmentNotPresent { error_code } | TrapCause::InvalidTss { error_code } | TrapCause::ControlProtection { error_code } | TrapCause::DoubleFault { error_code } => { - print_str(" ESR="); - print_hex(*error_code); - print_str("\n"); + line.str(b"esr=").hex(*error_code); } TrapCause::OtherException(ec) => { - print_str(" EC="); - print_hex(*ec as u64); - print_str("\n"); + line.str(b"ec=").hex(u64::from(*ec)); } - _ => {} + _ => return, } + line.end_fatal(); } -fn page_fault(info: &PageFaultInfo) { - print_str(" FAR="); - print_hex(info.fault_address); - print_str(access_label(info.access)); - print_str(if info.present { " present" } else { " not-present" }); - print_str(if info.user { " EL0" } else { " EL1" }); - print_str("\n"); +fn page_fault(line: &mut Line, info: &PageFaultInfo) { + line.str(b"far=") + .hex(info.fault_address) + .str(access_label(info.access)) + .str(if info.present { b" present" } else { b" not-present" }) + .str(if info.user { b" EL0" } else { b" EL1" }); } -fn access_label(access: FaultAccess) -> &'static str { +fn access_label(access: FaultAccess) -> &'static [u8] { match access { - FaultAccess::Read => " read", - FaultAccess::Write => " write", - FaultAccess::InstructionFetch => " ifetch", + FaultAccess::Read => b" read", + FaultAccess::Write => b" write", + FaultAccess::InstructionFetch => b" ifetch", } } diff --git a/src/arch/trap/contract/backend_aarch64/report_fatal.rs b/src/arch/trap/contract/backend_aarch64/report_fatal.rs index 72ad53b1f6..50a73bb69e 100644 --- a/src/arch/trap/contract/backend_aarch64/report_fatal.rs +++ b/src/arch/trap/contract/backend_aarch64/report_fatal.rs @@ -14,21 +14,31 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . +use crate::arch::aarch64::exceptions::terminal; use crate::arch::trap::contract::cause::TrapCause; use crate::arch::trap::contract::frame::TrapFrame; -use crate::sys::serial::{print_hex, print_str}; +use crate::sys::serial::Line; use super::{detail, label}; +/// Each line is built whole and written through the fatal writer, so a CPU +/// that faulted while holding the serial lock still gets its report out, and +/// a fault inside the report parks the CPU instead of recursing. pub(in crate::arch::trap::contract) fn report_fatal(frame: &F, cause: &TrapCause) { - print_str("\n!!! KERNEL FATAL TRAP: "); - print_str(label::for_cause(cause)); - print_str(" !!!\n ELR="); - print_hex(frame.instruction_pointer()); - print_str(" SP="); - print_hex(frame.stack_pointer()); - print_str(" origin="); - print_str(if frame.from_user() { "EL0" } else { "EL1" }); - print_str("\n"); + if !terminal::enter() { + return; + } + let mut line = Line::new(); + line.str(b"[TRAP] KERNEL FATAL TRAP: ").str(label::for_cause(cause).as_bytes()); + if let Some(esr) = frame.syndrome() { + line.str(b" esr=").hex(esr); + } + line.str(b" elr=") + .hex(frame.instruction_pointer()) + .str(b" sp=") + .hex(frame.stack_pointer()) + .str(b" origin=") + .str(if frame.from_user() { b"EL0" } else { b"EL1" }) + .end_fatal(); detail::report(cause); } diff --git a/src/arch/trap/contract/frame.rs b/src/arch/trap/contract/frame.rs index ccc745b935..1216e9b8a6 100644 --- a/src/arch/trap/contract/frame.rs +++ b/src/arch/trap/contract/frame.rs @@ -20,8 +20,7 @@ use super::cause::TrapCause; /// this on its concrete frame; the contract goes through these methods /// only. /// -/// Only x86 projects a real frame here today. Other arches add their -/// own projection when their trap shims land. +/// Each architecture's trap shim implements it beside its own frame. pub trait TrapFrame { fn instruction_pointer(&self) -> u64; fn stack_pointer(&self) -> u64; @@ -30,4 +29,11 @@ pub trait TrapFrame { /// Cause projection runs here. Arch-specific status reads (CR2, /// ESR_EL1, scause / stval) happen in the impl, not in the contract. fn cause(&self) -> TrapCause; + + /// The raw syndrome word, on an architecture whose frame captured a + /// syndrome register (aarch64: ESR_EL1). None elsewhere: x86_64 folds + /// what its error code says into the cause. + fn syndrome(&self) -> Option { + None + } } diff --git a/src/arch/trap/contract/mod.rs b/src/arch/trap/contract/mod.rs index bdce0470f9..01a770713c 100644 --- a/src/arch/trap/contract/mod.rs +++ b/src/arch/trap/contract/mod.rs @@ -24,15 +24,9 @@ //! portable policy work to do for those. Per-arch primitives (fatal //! report sink and CPU halt) live behind `backend`. //! -//! Current implementation status: only x86_64 is wired. aarch64 and -//! riscv64 are structurally accounted for — the contract surface, -//! `TrapFrame` trait, `TrapCause`, and `backend` dispatch hub are -//! architecture-neutral, with explicit insertion points for those -//! arches: a `backend_aarch64` / `backend_riscv64` module behind the -//! `cfg` switch in `backend.rs`, plus a `TrapFrame` impl alongside the -//! per-arch entry shim that performs cause projection from the -//! arch-native status registers. None of that is implemented yet, and -//! a build for those targets fails at `backend.rs` until it is. +//! Each architecture supplies a backend behind the `cfg` switch in +//! `backend.rs` and a `TrapFrame` impl beside its entry shim, which does +//! the cause projection from the arch-native status registers. mod backend; #[cfg(target_arch = "aarch64")] diff --git a/src/arch/user_access/pan.rs b/src/arch/user_access/pan.rs index 41b7565d17..2fa9b99b76 100644 --- a/src/arch/user_access/pan.rs +++ b/src/arch/user_access/pan.rs @@ -26,7 +26,8 @@ use crate::arch::aarch64::cpu::{has_feature, CpuFeature}; /// `MSR PAN, #0`, encoded by hand so the assembler takes it without the build -/// enabling `+pan` everywhere. `PAN` is `MSR (immediate)` with op1 = 0, op2 = 4. +/// enabling `+pan` everywhere. `PAN` is `MSR (immediate)` with op1 = 0, op2 = 4 +/// and the immediate in CRm: 0xd500401f | op2 << 5 | imm << 8. #[inline(always)] pub(super) fn allow() { if !supported() { @@ -36,7 +37,7 @@ pub(super) fn allow() { // writable at EL1. Writing it changes only PSTATE.PAN, and the guard that // called this restores it. unsafe { - core::arch::asm!("msr pan, #0", options(nomem, nostack, preserves_flags)); + core::arch::asm!(".inst 0xd500409f", options(nomem, nostack, preserves_flags)); } } @@ -48,7 +49,7 @@ pub(super) fn deny() { // SAFETY: as for `allow`. Setting the bit is the safe direction: it can // only turn an access that would have succeeded into a fault. unsafe { - core::arch::asm!("msr pan, #1", options(nomem, nostack, preserves_flags)); + core::arch::asm!(".inst 0xd500419f", options(nomem, nostack, preserves_flags)); } } diff --git a/src/arch/x86_64/asm/tramp_ctx.inc b/src/arch/x86_64/asm/tramp_ctx.inc index fbe6ac0bff..b2c3214e74 100644 --- a/src/arch/x86_64/asm/tramp_ctx.inc +++ b/src/arch/x86_64/asm/tramp_ctx.inc @@ -46,6 +46,8 @@ and rsp, -16 fxsave [rsp] mov rbx, rsp + /* The save area is the shim's second argument: it may rewrite it. */ + mov rsi, rbx call \shim fxrstor [rbx] mov rsp, rbp 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/extended.rs b/src/arch/x86_64/iommu/regs/cap/extended.rs new file mode 100644 index 0000000000..6e18e8148a --- /dev/null +++ b/src/arch/x86_64/iommu/regs/cap/extended.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 . + +/// Snoop Control, ECAP bit 7: the unit honours the snoop bit of a second-level leaf. +pub const fn snoop_control(ecap: u64) -> bool { + ecap & (1 << 7) != 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..8e0384a722 100644 --- a/src/arch/x86_64/iommu/regs/cap/mod.rs +++ b/src/arch/x86_64/iommu/regs/cap/mod.rs @@ -16,12 +16,14 @@ mod agaw; mod behaviour; +mod extended; mod fault; 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 extended::snoop_control; 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/drivers/virtio_rng/device/core.rs b/src/drivers/virtio_rng/device/core.rs index bd0d187551..3e0ab53c4b 100644 --- a/src/drivers/virtio_rng/device/core.rs +++ b/src/drivers/virtio_rng/device/core.rs @@ -20,8 +20,14 @@ use crate::drivers::virtio_rng::queue::RngQueue; pub(in crate::drivers::virtio_rng) struct VirtioRngDevice { pub(super) access: AccessMode, pub(super) queue: RngQueue, + /// Regions this driver handed the device with no IOMMU domain confining + /// them: the virtqueue and the data buffer, once the device accepted them. + unconfined: u32, } +/// The virtqueue and the data buffer. +const DMA_REGIONS: u32 = 2; + impl VirtioRngDevice { pub(in crate::drivers::virtio_rng) fn from_bar0(bar0: u32) -> Result { if bar0 == 0 { @@ -33,8 +39,10 @@ impl VirtioRngDevice { AccessMode::Mmio((bar0 & 0xFFFFFFF0) as u64) }; let queue = RngQueue::new()?; - let mut dev = Self { access, queue }; + let mut dev = Self { access, queue, unconfined: 0 }; dev.init_legacy()?; + dev.unconfined = DMA_REGIONS; + crate::memory::iommu::note_unconfined(DMA_REGIONS); Ok(dev) } } @@ -42,5 +50,6 @@ impl VirtioRngDevice { impl Drop for VirtioRngDevice { fn drop(&mut self) { self.write8(LEG_STATUS, 0); + crate::memory::iommu::note_unconfined_released(self.unconfined); } } 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..7fc4220652 --- /dev/null +++ b/src/hardware/broker/confine/attach.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 . + +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. + * No unit in service is the same whether none was found, none is + * up yet, or the one found (AMD-Vi) has no backend here. + */ + Err( + IommuError::NotInitialized + | IommuError::NotSupported + | IommuError::NoIommu + | IommuError::AmdViNotDriven, + ) => { + 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..b4cadae082 --- /dev/null +++ b/src/hardware/broker/dma/map/transaction.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 . + +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, + }); + + // A grant no remapping unit confines reaches all memory: the posture line + // counts it until it is released. + if !confined { + crate::memory::iommu::note_unconfined(1); + } + 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..a823894702 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,32 +56,23 @@ 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); } else if !pool::free(g.physical_start, pages) { 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); - } + if !g.confined { + crate::memory::iommu::note_unconfined_released(1); } } 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/hardware/e1000_capsule/spawn.rs b/src/hardware/e1000_capsule/spawn.rs index 6ceb7901e6..422c3cbffe 100644 --- a/src/hardware/e1000_capsule/spawn.rs +++ b/src/hardware/e1000_capsule/spawn.rs @@ -15,8 +15,8 @@ // along with this program. If not, see . //! Spawn the e1000 driver capsule with the broker capability -//! bundle. PCI MMIO + INTx + DMA driver — needs IPC | Memory | -//! Driver | DeviceEnum | Mmio | Irq | Dma. No Network cap: frame +//! bundle. PCI MMIO + DMA driver, polled — needs IPC | Memory | +//! Crypto | Driver | DeviceEnum | Mmio | Dma. No Network cap: frame //! transport over IPC, not a network-service authority. use super::client::REPLY_INBOX; @@ -62,7 +62,6 @@ pub fn spawn_driver_e1000_capsule() -> Result<(), SpawnError> { | Capability::Driver.bit() | Capability::DeviceEnum.bit() | Capability::Mmio.bit() - | Capability::Irq.bit() | Capability::Dma.bit(), debug_tag: b"[DRIVER-E1000] load_elf_executable error:", }; diff --git a/src/hardware/rtl8139_capsule/spawn.rs b/src/hardware/rtl8139_capsule/spawn.rs index 52d5620307..fbd5ab12f5 100644 --- a/src/hardware/rtl8139_capsule/spawn.rs +++ b/src/hardware/rtl8139_capsule/spawn.rs @@ -50,9 +50,12 @@ pub fn spawn_driver_rtl8139_capsule() -> Result<(), SpawnError> { target_triple: TARGET_TRIPLE, requested_caps: Capability::IPC.bit() | Capability::Memory.bit() + // The station address is drawn rather than read out of the IDR, + // and CryptoRandom is gated on this capability. The draw fails + // closed, so without it the card never comes up. + | Capability::Crypto.bit() | Capability::Driver.bit() | Capability::DeviceEnum.bit() - | Capability::Irq.bit() | Capability::Dma.bit() | Capability::Pio.bit(), debug_tag: b"[DRIVER-RTL8139] load_elf_executable error:", diff --git a/src/hardware/rtl8169_capsule/spawn.rs b/src/hardware/rtl8169_capsule/spawn.rs index 6b0da059d4..1107aaf47d 100644 --- a/src/hardware/rtl8169_capsule/spawn.rs +++ b/src/hardware/rtl8169_capsule/spawn.rs @@ -50,10 +50,13 @@ pub fn spawn_driver_rtl8169_capsule() -> Result<(), SpawnError> { target_triple: TARGET_TRIPLE, requested_caps: Capability::IPC.bit() | Capability::Memory.bit() + // The station address is drawn rather than read out of the IDR, + // and CryptoRandom is gated on this capability. The draw fails + // closed, so without it the card never comes up. + | Capability::Crypto.bit() | Capability::Driver.bit() | Capability::DeviceEnum.bit() | Capability::Mmio.bit() - | Capability::Irq.bit() | Capability::Dma.bit(), debug_tag: b"[DRIVER-RTL8169] load_elf_executable error:", }; diff --git a/src/interrupts/isr/timer_trampoline/guest_stop.rs b/src/interrupts/isr/timer_trampoline/guest_stop.rs new file mode 100644 index 0000000000..7a0f3447a1 --- /dev/null +++ b/src/interrupts/isr/timer_trampoline/guest_stop.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. + +//! A tick that interrupted a guest thread, handed to the foreign layer. + +use crate::process::userspace::types::UserContext; + +/// A guest thread its supervisor asked to stop is parked here, running no +/// code, until it is answered. It resumes from the trampoline's frame `ctx`, +/// which a signal answer rewrites to enter the handler, with the FPU loaded +/// from `fx`, which the same answer rewrites to the handler's clean state. +pub(super) fn on_user_tick(ctx: *mut UserContext, fx: *mut u8) { + let words = ctx.cast::<[u64; crate::process::foreign::TICK_FRAME_WORDS]>(); + /* + * SAFETY: eK@nonos.systems - the trampoline's 160-byte frame, 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 }, fx); +} diff --git a/src/interrupts/isr/timer_trampoline/handler.rs b/src/interrupts/isr/timer_trampoline/handler.rs index 43fcc23cee..a2a55ef095 100644 --- a/src/interrupts/isr/timer_trampoline/handler.rs +++ b/src/interrupts/isr/timer_trampoline/handler.rs @@ -22,7 +22,8 @@ use crate::process::userspace::types::UserContext; /// On entry, `ctx` points at a stack-resident region whose layout /// matches the first 160 bytes of `UserContext` (15 GPRs + iretq /// frame). The pointer is valid only for the duration of this call; -/// the trampoline reuses the memory on return. +/// the trampoline reuses the memory on return. `fx` is the 512-byte +/// FXSAVE area the trampoline reloads the FPU from on the way out. /// /// When the trap originated from CPL=3, this function snapshots the /// frame onto the current PCB's `saved_user_context` so the scheduler @@ -31,7 +32,7 @@ use crate::process::userspace::types::UserContext; /// later context write overwrites earlier ones, and the scheduler /// `take()`s the most recent one. #[no_mangle] -pub(crate) extern "C" fn timer_trap_handler(ctx: *mut UserContext) { +pub(crate) extern "C" fn timer_trap_handler(ctx: *mut UserContext, fx: *mut u8) { // SAFETY: eK@nonos.systems — `ctx` was produced by the trampoline // above and points at 160 bytes of valid stack memory laid out as // the leading fields of `UserContext`. We read those fields here; @@ -89,15 +90,20 @@ 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(); - // 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 - // CR3 until something runnable appears. Draining here in that window - // would free the very stack this trap frame sits on and the live page - // tables. The queues are retried on every tick, so reclamation happens - // as soon as a real context is interrupted instead. - if crate::process::current_pid().is_some() { - crate::process::exit::drain_pending_teardowns(); - crate::kernel_core::process_spawn::drain_pending_kernel_stacks(); + /* + * 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; + } + } + super::reclaim::on_tick(from_user); + if from_user { + drop(_ctx_guard); + super::guest_stop::on_user_tick(ctx, fx); } } diff --git a/src/interrupts/isr/timer_trampoline/mod.rs b/src/interrupts/isr/timer_trampoline/mod.rs index 9f9efdc12c..0027f44484 100644 --- a/src/interrupts/isr/timer_trampoline/mod.rs +++ b/src/interrupts/isr/timer_trampoline/mod.rs @@ -52,5 +52,7 @@ //! runs on whatever kernel stack was already current, and `swapgs` //! is skipped on both entry and exit. +mod guest_stop; mod handler; +mod reclaim; mod send_eoi; diff --git a/src/interrupts/isr/timer_trampoline/reclaim.rs b/src/interrupts/isr/timer_trampoline/reclaim.rs new file mode 100644 index 0000000000..47ccabd0a2 --- /dev/null +++ b/src/interrupts/isr/timer_trampoline/reclaim.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. + +//! Reclaiming dead processes from the timer tick, and which ticks may. + +/// Finish the queued teardowns and free the queued kernel stacks, when the +/// tick interrupted a context that holds no kernel lock. +pub(super) fn on_tick(from_user: bool) { + /* + * Never 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 CR3 until something + * runnable appears. Draining in that window would free the very stack this + * trap frame sits on and the live page tables. + * + * Nor while kernel code is at work: it holds plain spin locks with + * interrupts open (init reads the process table once a second), and the + * teardown takes the same table for writing with interrupts closed, a spin + * that never ends on the CPU that holds the read. + * + * A tick from user mode holds no kernel lock, the rule tick.rs switches + * by, and neither does the halt a CPU waits in when nothing is runnable: + * a busy machine reclaims at its next tick in user code, an idle one at + * its next tick at all. + */ + let holds_no_lock = from_user || crate::process::accounting::is_idle(); + if holds_no_lock && crate::process::current_pid().is_some() { + crate::process::exit::drain_pending_teardowns(); + crate::kernel_core::process_spawn::drain_pending_kernel_stacks(); + } +} 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_dma_protection.rs b/src/kernel_core/init/entry/init_dma_protection.rs index 965eb14334..dee19224bd 100644 --- a/src/kernel_core/init/entry/init_dma_protection.rs +++ b/src/kernel_core/init/entry/init_dma_protection.rs @@ -25,18 +25,29 @@ //! //! That was invisible because the only lane that presents an IOMMU asserted //! nothing about its own log, and every other lane gives QEMU no IOMMU, so -//! `no remapping units in DMAR; DMA is unrestricted` was the expected output -//! everywhere and a failed probe read the same as a machine without one. +//! a report of no remapping units was the expected output everywhere and a +//! failed probe read the same as a machine without one. //! //! The two ordering facts it does depend on both hold here: ACPI publishes //! the DRHD bases during `init_core_systems`, and PCI is enumerated there //! too, so the devices a unit has to account for are already known. -/// Report what the firmware declared, then confine DMA to it. +/* + * The vendor is selected from the ACPI tables before any unit is touched. + * The VT-d probe and bring-up run only when DMAR described a remapping unit; + * an AMD-Vi or IOMMU-less machine is named by the selection line instead. + * The posture line comes last and is read from the capability query, so it + * states what is in force rather than what was attempted. + */ +/// Select the IOMMU, bring VT-d into service where DMAR described a unit, state the posture. pub(super) fn init_dma_protection() { #[cfg(all(target_arch = "x86_64", feature = "nonos-arch-iommu"))] { - crate::arch::x86_64::iommu::unit::report::init(); - crate::arch::x86_64::iommu::unit::bringup::init(); + use crate::memory::iommu::{report_posture, select_vendor, IommuVendor}; + if select_vendor() == IommuVendor::IntelVtd { + crate::arch::x86_64::iommu::unit::report::init(); + crate::arch::x86_64::iommu::unit::bringup::init(); + } + report_posture(); } } 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/init_vm_and_protection.rs b/src/kernel_core/init/entry/init_vm_and_protection.rs index bc9cadce2c..006ee5d951 100644 --- a/src/kernel_core/init/entry/init_vm_and_protection.rs +++ b/src/kernel_core/init/entry/init_vm_and_protection.rs @@ -18,6 +18,7 @@ //! is settled. use super::fatal::fatal; +#[cfg(target_arch = "x86_64")] use crate::memory::mmu; pub(super) fn init_vm_and_protection() { @@ -36,13 +37,17 @@ pub(super) fn init_vm_and_protection() { // user bit, so a supervisor access to a user page never happens. A part // without execute-never is fatal: that same directmap is built NX, and // with EFER.NXE clear the whole window stays executable. + #[cfg(target_arch = "x86_64")] if mmu::init_mmu().is_err() { fatal("memory: init_mmu failed", "no execute-never support"); } + #[cfg(target_arch = "x86_64")] match mmu::protection_flags() { Ok(flags) => mmu::report_protection(flags), Err(_) => fatal("memory: protection flags unreadable", "mmu not initialised"), } + #[cfg(target_arch = "aarch64")] + report_el1_protection(); arm_stack_guards(); super::report_sections::report_kernel_sections(); } @@ -69,3 +74,16 @@ fn arm_stack_guards() { #[cfg(not(target_arch = "x86_64"))] fn arm_stack_guards() {} + +/* + * aarch64 has no control-register switch for what init_mmu turns on for x86_64: + * execute-never and read-only are PXN, UXN and AP[2] in every descriptor the + * encoder writes. PAN, the SMAP counterpart, is not set at boot, so the line says + * so rather than claiming a protection this kernel does not enable. + */ +#[cfg(target_arch = "aarch64")] +fn report_el1_protection() { + crate::sys::serial::println( + b"[CPU-PROT] aarch64 pxn=descriptor uxn=descriptor ro=descriptor pan=off", + ); +} 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/lib.rs b/src/lib.rs index a27143baac..448b6d3e4f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -25,10 +25,9 @@ #![allow(clippy::declare_interior_mutable_const)] #![allow(clippy::not_unsafe_ptr_arg_deref)] -// x86_64 is the production release target. aarch64 and riscv64 are brought up -// behind `nonos-arch-preview`: the arch trees compile and boot in QEMU but are -// not yet release-signed. Without the feature, a non-x86_64 build is refused so -// a release can never ship an unfinished arch by accident. +// x86_64 is the production release target. Any other architecture builds only +// with `nonos-arch-preview`; without it, the build is refused here so a release +// cannot ship another architecture by accident. #[cfg(all(not(target_arch = "x86_64"), not(feature = "nonos-arch-preview")))] compile_error!( "Developer Preview 1.0 ships only x86_64. Build aarch64/riscv64 with \ diff --git a/src/memory/hardening/constants.rs b/src/memory/hardening/constants.rs index 88a3d6cf19..edb3aabc9d 100644 --- a/src/memory/hardening/constants.rs +++ b/src/memory/hardening/constants.rs @@ -19,6 +19,7 @@ // The control-register bits are defined once, by the module that writes them. // A second definition here is how the check ends up testing a different bit // than the bring-up set. +#[cfg(target_arch = "x86_64")] pub use crate::memory::mmu::CR4_REQUIRED_BITS; /// Pattern used for heap corruption detection. diff --git a/src/memory/hardening/manager/verify/protection.rs b/src/memory/hardening/manager/verify/protection.rs index 6a468fd680..469318f0b6 100644 --- a/src/memory/hardening/manager/verify/protection.rs +++ b/src/memory/hardening/manager/verify/protection.rs @@ -22,10 +22,22 @@ //! however this path is reached the machine ends up in the state the boot log //! reported rather than in whichever state ran last. +#[cfg(target_arch = "x86_64")] use crate::memory::mmu; /// Idempotent: `init_mmu` returns having touched nothing if the bring-up /// already ran from the boot path, which is the usual case. +#[cfg(target_arch = "x86_64")] pub fn init_module_memory_protection() { let _ = mmu::init_mmu(); } + +/// aarch64 has no register-level bring-up to delegate to: execute-never and +/// read-only live in each descriptor, and PAN is not set at boot. The call is +/// refused by name so a caller cannot read it as protection switched on. +#[cfg(not(target_arch = "x86_64"))] +pub fn init_module_memory_protection() { + crate::sys::serial::println( + b"[MEM-HARDEN] refused: no ring-0 protection bring-up on this arch, PAN not enabled", + ); +} 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.rs b/src/memory/iommu/backend.rs index 905f286dee..cd31e50224 100644 --- a/src/memory/iommu/backend.rs +++ b/src/memory/iommu/backend.rs @@ -21,4 +21,7 @@ mod inner; #[path = "backend_unsupported.rs"] mod inner; -pub(super) use inner::{allocate_domain, attach_device, detach_device, free_domain, map, unmap}; +pub(super) use inner::{ + allocate_domain, attach_device, capabilities, detach_device, free_domain, map, select_vendor, + unmap, +}; diff --git a/src/memory/iommu/backend_unsupported.rs b/src/memory/iommu/backend_unsupported.rs index 44f5a88c4a..5903cc1efc 100644 --- a/src/memory/iommu/backend_unsupported.rs +++ b/src/memory/iommu/backend_unsupported.rs @@ -16,10 +16,21 @@ use crate::memory::addr::PhysAddr; +use super::super::capabilities::IommuCapabilities; use super::super::device::DeviceAddress; use super::super::domain_id::DomainId; use super::super::error::IommuError; use super::super::protection::IommuProtection; +use super::super::vendor::IommuVendor; + +/// A build with no IOMMU backend reads no DMAR or IVRS table, so it has nothing to select. +pub(in crate::memory::iommu) fn select_vendor() -> IommuVendor { + IommuVendor::Absent +} + +pub(in crate::memory::iommu) fn capabilities() -> IommuCapabilities { + IommuCapabilities::none_in_force(IommuVendor::Absent) +} pub(in crate::memory::iommu) fn allocate_domain() -> Result { Err(IommuError::NotSupported) diff --git a/src/memory/iommu/backend_x86_64/capabilities.rs b/src/memory/iommu/backend_x86_64/capabilities.rs new file mode 100644 index 0000000000..070097ffa9 --- /dev/null +++ b/src/memory/iommu/backend_x86_64/capabilities.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 guarantees the selected IOMMU gives right now. + +use crate::arch::x86_64::iommu::globals::{is_enforcing, page_levels}; +use crate::arch::x86_64::iommu::regs::cap::snoop_control; +use crate::arch::x86_64::iommu::types::{MAX_VTD_DOMAINS, PAGE_SIZE_4K}; +use crate::arch::x86_64::iommu::unit::probe::unit_count; +use crate::arch::x86_64::iommu::unit::report::probed; +use crate::memory::iommu::{IommuCapabilities, IommuVendor}; + +use super::select; + +/// Before selection nothing has been checked, so nothing is claimed. +pub(crate) fn capabilities() -> IommuCapabilities { + match select::selected() { + Some(IommuVendor::IntelVtd) => vtd(), + Some(vendor) => IommuCapabilities::none_in_force(vendor), + None => IommuCapabilities::none_in_force(IommuVendor::Absent), + } +} + +/* + * Bring-up programs the first unit only. With one unit that is every unit; + * with more, devices behind the others reach memory directly, so nothing is + * claimed. The width is what the domains' depth reaches, cut to what the unit + * accepts. Pages are 4 KiB only because map_range installs nothing larger. + */ +fn vtd() -> IommuCapabilities { + let none = IommuCapabilities::none_in_force(IommuVendor::IntelVtd); + let (Some(info), Some(levels)) = (probed(), page_levels()) else { + return none; + }; + if !is_enforcing() || unit_count() != 1 { + return none; + } + let Some(reach) = levels.checked_mul(9).and_then(|bits| bits.checked_add(12)) else { + return none; + }; + IommuCapabilities { + vendor: IommuVendor::IntelVtd, + enforcing: true, + address_width_bits: reach.min(info.max_address_width), + interrupt_remapping: false, + page_sizes: PAGE_SIZE_4K as u64, + snoop_control: snoop_control(info.ecap), + domain_count: info.domains.min(MAX_VTD_DOMAINS as u32), + } +} diff --git a/src/memory/iommu/backend_x86_64/dispatch.rs b/src/memory/iommu/backend_x86_64/dispatch.rs new file mode 100644 index 0000000000..cb2a76b95a --- /dev/null +++ b/src/memory/iommu/backend_x86_64/dispatch.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 . + +//! Every domain call, routed by the vendor selected at boot. + +use crate::memory::addr::PhysAddr; +use crate::memory::iommu::{DeviceAddress, DomainId, IommuError, IommuProtection, IommuVendor}; + +use super::{refuse, select}; + +/* + * On VT-d, and before selection has run, the call goes straight to the VT-d + * backend, which gates itself on translation being in service. Once selection + * has found no unit this kernel drives, the call is refused by name instead + * of reaching tables no hardware walks. + */ +fn route(op: &'static [u8]) -> Result<(), IommuError> { + match select::selected() { + None | Some(IommuVendor::IntelVtd) => Ok(()), + Some(IommuVendor::AmdVi) => Err(refuse::amd_vi(op)), + Some(IommuVendor::Absent) => Err(refuse::absent(op)), + } +} + +pub(crate) fn allocate_domain() -> Result { + route(b"allocate_domain")?; + super::domain::allocate_domain() +} + +pub(crate) fn free_domain(id: DomainId) -> Result<(), IommuError> { + route(b"free_domain")?; + super::domain::free_domain(id) +} + +pub(crate) fn map( + domain: DomainId, + iova: u64, + phys: PhysAddr, + size: usize, + protection: IommuProtection, +) -> Result<(), IommuError> { + route(b"map")?; + super::mapping::map(domain, iova, phys, size, protection) +} + +pub(crate) fn unmap(domain: DomainId, iova: u64, size: usize) -> Result<(), IommuError> { + route(b"unmap")?; + super::mapping::unmap(domain, iova, size) +} + +pub(crate) fn attach_device(domain: DomainId, device: DeviceAddress) -> Result<(), IommuError> { + route(b"attach_device")?; + super::device::attach_device(domain, device) +} + +pub(crate) fn detach_device(domain: DomainId, device: DeviceAddress) -> Result<(), IommuError> { + route(b"detach_device")?; + super::device::detach_device(domain, device) +} 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/iommu/backend_x86_64/mod.rs b/src/memory/iommu/backend_x86_64/mod.rs index df77649bb5..2b7d987276 100644 --- a/src/memory/iommu/backend_x86_64/mod.rs +++ b/src/memory/iommu/backend_x86_64/mod.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 . +mod capabilities; mod device; +mod dispatch; mod domain; mod enforced; mod mapping; +mod refuse; +mod select; -pub(crate) use device::{attach_device, detach_device}; -pub(crate) use domain::{allocate_domain, free_domain}; -pub(crate) use mapping::{map, unmap}; +pub(crate) use capabilities::capabilities; +pub(crate) use dispatch::{allocate_domain, attach_device, detach_device, free_domain, map, unmap}; +pub(crate) use select::select_vendor; diff --git a/src/memory/iommu/backend_x86_64/refuse.rs b/src/memory/iommu/backend_x86_64/refuse.rs new file mode 100644 index 0000000000..facbbe29bd --- /dev/null +++ b/src/memory/iommu/backend_x86_64/refuse.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 . + +//! A domain call on a machine with no IOMMU this kernel drives, refused by name. + +use crate::memory::iommu::IommuError; +use crate::sys::serial; + +pub(super) fn amd_vi(op: &'static [u8]) -> IommuError { + serial::print(b"[AMD-VI] refused "); + serial::print(op); + serial::println(b": no AMD-Vi backend in this kernel"); + IommuError::AmdViNotDriven +} + +pub(super) fn absent(op: &'static [u8]) -> IommuError { + serial::print(b"[IOMMU] refused "); + serial::print(op); + serial::println(b": no DMAR remapping unit and no IVRS table"); + IommuError::NoIommu +} diff --git a/src/memory/iommu/backend_x86_64/select.rs b/src/memory/iommu/backend_x86_64/select.rs new file mode 100644 index 0000000000..f56b73360d --- /dev/null +++ b/src/memory/iommu/backend_x86_64/select.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 . + +//! Which IOMMU this boot has, decided once from the firmware's tables. + +use spin::Once; + +use crate::arch::x86_64::acpi::has_table; +use crate::arch::x86_64::iommu::globals::is_present; +use crate::memory::iommu::IommuVendor; +use crate::sys::serial; + +static SELECTED: Once = Once::new(); + +pub(crate) fn select_vendor() -> IommuVendor { + *SELECTED.call_once(detect) +} + +/// The vendor once selection has run, `None` before it. +pub(super) fn selected() -> Option { + SELECTED.get().copied() +} + +/* + * Keyed on firmware tables, never on CPUID. `is_present` is set only once + * DMAR yielded a remapping unit base, so a DMAR with no DRHD does not count. + * VT-d wins when both tables exist because it is the one this kernel drives; + * the AMD-Vi units are then named as unconfined rather than passed over. + */ +fn detect() -> IommuVendor { + let ivrs = has_table(b"IVRS"); + if is_present() { + if ivrs { + serial::println( + b"[IOMMU] IVRS also present; devices behind AMD-Vi units are not confined", + ); + } + return IommuVendor::IntelVtd; + } + if ivrs { + serial::println( + b"[AMD-VI] IVRS present; no AMD-Vi backend in this kernel, IOMMU domains refused; DMA is unrestricted", + ); + return IommuVendor::AmdVi; + } + serial::println( + b"[IOMMU] no DMAR remapping unit and no IVRS table; IOMMU domains refused; DMA is unrestricted", + ); + IommuVendor::Absent +} diff --git a/src/memory/iommu/capabilities.rs b/src/memory/iommu/capabilities.rs new file mode 100644 index 0000000000..a51d6d1e50 --- /dev/null +++ b/src/memory/iommu/capabilities.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 the IOMMU guarantees at the moment of asking. + +use super::vendor::IommuVendor; + +/* + * Every field is a guarantee in force now, never a capacity the hardware + * reports and the kernel does not use. While `enforcing` is false no device + * is translated, so every other field is zero or false. `enforcing` says the + * kernel's tables are live; it does not say a device is held to its grants, + * since bring-up's identity domain maps all of RAM for every enumerated + * device. + */ +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct IommuCapabilities { + pub vendor: IommuVendor, + /// Every remapping unit the firmware described translates with this kernel's tables. + pub enforcing: bool, + /// IOVAs below 2^n can be mapped into a domain and are translated. + pub address_width_bits: u8, + /// The kernel programmed interrupt remapping. + pub interrupt_remapping: bool, + /// Bit k set: `IommuDomain::map` installs leaves of 2^k bytes. + pub page_sizes: u64, + /// Leaves ask for snooping and the unit honours the request. + pub snoop_control: bool, + /* + * Size of the domain id space the unit and the kernel's domain table both + * accept. A capacity, not what is left: ids are never reused, id 0 is + * never handed out and bring-up's identity domain holds id 1. + */ + pub domain_count: u32, +} + +impl IommuCapabilities { + /// No guarantee at all, reported under `vendor`. + pub const fn none_in_force(vendor: IommuVendor) -> Self { + Self { + vendor, + enforcing: false, + address_width_bits: 0, + interrupt_remapping: false, + page_sizes: 0, + snoop_control: false, + domain_count: 0, + } + } +} diff --git a/src/memory/iommu/error.rs b/src/memory/iommu/error.rs index 34a5dbcef0..53dcd858fe 100644 --- a/src/memory/iommu/error.rs +++ b/src/memory/iommu/error.rs @@ -29,4 +29,8 @@ pub enum IommuError { DeviceDetachFailed, PageTableExhausted, BackendFault, + /// IVRS describes AMD-Vi hardware and this kernel has no AMD-Vi backend. + AmdViNotDriven, + /// The firmware described neither a DMAR remapping unit nor an IVRS table. + NoIommu, } diff --git a/src/memory/iommu/mod.rs b/src/memory/iommu/mod.rs index 091e6ffa7f..8be5dc095c 100644 --- a/src/memory/iommu/mod.rs +++ b/src/memory/iommu/mod.rs @@ -15,14 +15,24 @@ // along with this program. If not, see . mod backend; +mod capabilities; mod device; mod domain; mod domain_id; mod error; +mod posture; mod protection; +mod query; +mod unconfined; +mod vendor; +pub use capabilities::IommuCapabilities; pub use device::DeviceAddress; pub use domain::IommuDomain; pub use domain_id::DomainId; pub use error::IommuError; +pub use posture::report_posture; pub use protection::IommuProtection; +pub use query::{capabilities, select_vendor}; +pub use unconfined::{note_unconfined, note_unconfined_released, unconfined_grants}; +pub use vendor::IommuVendor; diff --git a/src/memory/iommu/posture.rs b/src/memory/iommu/posture.rs new file mode 100644 index 0000000000..70f5e036d8 --- /dev/null +++ b/src/memory/iommu/posture.rs @@ -0,0 +1,87 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public 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 line that says what the IOMMU is doing on this boot. +//! +//! Two facts, never one without the other: whether the unit is enforcing, and +//! how many mappings devices can reach with no domain confining them. A unit in +//! service with unconfined grants beside it is a mechanism present and a +//! property absent, and the line says both. Only when the count is zero does +//! `enforcing=1` mean that device DMA is confined. + +use core::sync::atomic::{AtomicBool, Ordering}; + +use super::query::capabilities; +use super::unconfined::unconfined_grants; +use crate::sys::serial::Line; + +static REPORTED: AtomicBool = AtomicBool::new(false); + +/* + * Built from the capability query and the unconfined count and nothing else, + * so the line and the query cannot disagree. The boot matrix reads the vendor, + * enforcing= and the count from the last posture line in a log. + */ +pub fn report_posture() { + let caps = capabilities(); + posture_line(); + Line::new() + .str(b"[IOMMU] capabilities aw=") + .dec(u64::from(caps.address_width_bits)) + .str(b" ir=") + .str(flag(caps.interrupt_remapping)) + .str(b" snoop=") + .str(flag(caps.snoop_control)) + .str(b" pages=") + .hex(caps.page_sizes) + .str(b" domains=") + .dec(u64::from(caps.domain_count)) + .end(); + REPORTED.store(true, Ordering::Release); +} + +/// Print the posture line again after the unconfined count changed, once the +/// boot has reported it the first time. Before that nothing has been said, and +/// on a build that never reports a posture nothing is said at all. +pub(super) fn report_again() { + if REPORTED.load(Ordering::Acquire) { + posture_line(); + } +} + +/* + * The vendor is "none" on a machine with neither DMAR remapping units nor an + * IVRS table, so every line has the same shape for the boot matrix to read. + */ +fn posture_line() { + let caps = capabilities(); + Line::new() + .str(b"[IOMMU] ") + .str(caps.vendor.name()) + .str(b" present, enforcing=") + .str(flag(caps.enforcing)) + .str(b", unconfined grants=") + .dec(u64::from(unconfined_grants())) + .end(); +} + +const fn flag(on: bool) -> &'static [u8] { + if on { + b"1" + } else { + b"0" + } +} diff --git a/src/memory/iommu/query.rs b/src/memory/iommu/query.rs new file mode 100644 index 0000000000..ba8cb82225 --- /dev/null +++ b/src/memory/iommu/query.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 two questions any caller may put to the IOMMU layer. + +use super::backend; +use super::capabilities::IommuCapabilities; +use super::vendor::IommuVendor; + +/* + * Decided on the first call and fixed after it, so the first call has to + * come after ACPI parsing. The boot makes it from init_dma_protection. + */ +pub fn select_vendor() -> IommuVendor { + backend::select_vendor() +} + +/// The guarantees in force at the moment of the call. Never triggers selection. +pub fn capabilities() -> IommuCapabilities { + backend::capabilities() +} diff --git a/src/memory/iommu/unconfined.rs b/src/memory/iommu/unconfined.rs new file mode 100644 index 0000000000..deaa94e289 --- /dev/null +++ b/src/memory/iommu/unconfined.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 . + +//! DMA that reaches a device with no IOMMU domain confining it. +//! +//! Today that is every broker grant on every machine: `MkDmaMap` hands the +//! capsule the host-physical address of its buffer and no domain takes part, +//! whichever vendor was selected. VT-d being in service does not change that, +//! because bring-up's identity domain maps all of RAM for every enumerated +//! device. So `enforcing=1` on its own would tell a reader that DMA on the +//! machine is confined when the mappings drivers actually make go around the +//! unit. The posture line carries this count beside it, and the attestation +//! document binds the pair into what the TPM signs. +//! +//! The count is of mappings still in place: a grant adds to it and its +//! release takes it back off. + +use core::sync::atomic::{AtomicU32, Ordering}; + +static IN_PLACE: AtomicU32 = AtomicU32::new(0); + +/// Mappings a device can reach with no IOMMU domain confining them, now. +pub fn unconfined_grants() -> u32 { + IN_PLACE.load(Ordering::Acquire) +} + +/// `regions` more mappings were handed to a device with no domain confining them. +pub fn note_unconfined(regions: u32) { + IN_PLACE.fetch_add(regions, Ordering::AcqRel); + super::posture::report_again(); +} + +/// `regions` unconfined mappings were taken back from their device. +pub fn note_unconfined_released(regions: u32) { + let _ = IN_PLACE + .fetch_update(Ordering::AcqRel, Ordering::Acquire, |n| Some(n.saturating_sub(regions))); + super::posture::report_again(); +} diff --git a/src/memory/iommu/vendor.rs b/src/memory/iommu/vendor.rs new file mode 100644 index 0000000000..9c79bcb866 --- /dev/null +++ b/src/memory/iommu/vendor.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 . + +//! Which remapping hardware the firmware described. + +/* + * Chosen from the ACPI tables, never from CPUID: QEMU presents an + * intel-iommu under KVM on AMD hosts, and that machine is VT-d. + */ +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum IommuVendor { + /// No DMAR remapping unit and no IVRS table, or a build with no IOMMU backend. + Absent, + /// DMAR described at least one remapping unit. + IntelVtd, + /// IVRS is present and DMAR described no unit. This kernel does not drive AMD-Vi. + AmdVi, +} + +impl IommuVendor { + /// The name the posture line prints. + pub const fn name(self) -> &'static [u8] { + match self { + Self::Absent => b"none", + Self::IntelVtd => b"intel-vt-d", + Self::AmdVi => b"amd-vi", + } + } +} 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/mapping_in_asid.rs b/src/memory/paging/manager/api/mapping_in_asid.rs index 2edbdd3981..6d3023f60f 100644 --- a/src/memory/paging/manager/api/mapping_in_asid.rs +++ b/src/memory/paging/manager/api/mapping_in_asid.rs @@ -15,6 +15,9 @@ // along with this program. If not, see . use super::globals::{PAGING_MANAGER, PAGING_STATS}; +#[cfg(not(target_arch = "x86_64"))] +use crate::arch::run_without_interrupts as without_interrupts; +#[cfg(target_arch = "x86_64")] use crate::arch::x86_64::idt::without_interrupts; use crate::memory::addr::{PhysAddr, VirtAddr}; use crate::memory::paging::error::PagingResult; 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/memory/unified/init/clear_low_half.rs b/src/memory/unified/init/clear_low_half.rs index 4663e1df65..bb2267a4e6 100644 --- a/src/memory/unified/init/clear_low_half.rs +++ b/src/memory/unified/init/clear_low_half.rs @@ -22,7 +22,15 @@ pub(super) fn clear_low_half() -> Result<(), &'static str> { crate::arch::x86_64::paging::clear_low_half() } +/* + * Off x86_64 the kernel still executes from the boot identity map in the low + * half, so removing it would unmap the code running this. It is kept, and the + * log says so rather than the caller reporting a teardown that did not happen. + */ #[cfg(not(target_arch = "x86_64"))] pub(super) fn clear_low_half() -> Result<(), &'static str> { + crate::sys::serial::println( + b"[VM-INIT] low half kept: the kernel still runs from the boot identity map", + ); Ok(()) } diff --git a/src/memory/unified/init/run.rs b/src/memory/unified/init/run.rs index 5ad1a67ec8..2d54380e2d 100644 --- a/src/memory/unified/init/run.rs +++ b/src/memory/unified/init/run.rs @@ -112,6 +112,7 @@ pub fn init_unified_vm() -> Result<(), &'static str> { // own text — leave it. if kernel_half_populated >= 2 { clear_low_half()?; + #[cfg(target_arch = "x86_64")] crate::sys::serial::println(b"[VM-INIT] low half cleared"); } 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/address_space/types.rs b/src/process/address_space/types.rs index bac4883463..f778d62f93 100644 --- a/src/process/address_space/types.rs +++ b/src/process/address_space/types.rs @@ -121,9 +121,15 @@ impl AddressSpace { // page table base address. The nomem option is correct as this does not // access memory through a pointer. The nostack option is correct as no // stack space is used. + #[cfg(target_arch = "x86_64")] unsafe { core::arch::asm!("mov {}, cr3", out(reg) cr3, options(nomem, nostack)); } + // The same root through the arch reader: TTBR0_EL1 with the ASID masked. + #[cfg(not(target_arch = "x86_64"))] + { + cr3 = crate::arch::paging::read_root(); + } Self { pid: 0, 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/userland/capsule_terminal/src/term/grid/move_cells.rs b/src/process/core/table/alarm_scan.rs similarity index 50% rename from userland/capsule_terminal/src/term/grid/move_cells.rs rename to src/process/core/table/alarm_scan.rs index 5da67135c4..99a6d6ae11 100644 --- a/userland/capsule_terminal/src/term/grid/move_cells.rs +++ b/src/process/core/table/alarm_scan.rs @@ -14,33 +14,28 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -use crate::term::dimensions::{COLS, VISIBLE_ROWS}; -use crate::term::grid::types::Grid; +use super::super::types::Pid; +use super::types::ProcessTable; -impl Grid { - pub fn move_cells( - &mut self, - from_x: usize, - from_y: usize, - to_x: usize, - to_y: usize, - w: usize, - h: usize, - ) { - for ry in 0..h { - let fy = from_y + ry; - let ty = to_y + ry; - if fy >= VISIBLE_ROWS || ty >= VISIBLE_ROWS { - continue; +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; } - let fw = w.min(COLS.saturating_sub(from_x)); - let tw = fw.min(COLS.saturating_sub(to_x)); - if tw == 0 { - continue; + if pcb.check_alarm_expired() { + out[n] = pcb.pid; + n += 1; } - let src = Grid::idx(from_x, fy); - let dst = Grid::idx(to_x, ty); - self.cells.copy_within(src..src + tw, dst); } + n } } diff --git a/userland/capsule_terminal/src/term/vt/utf8/state.rs b/src/process/core/table/current_pid.rs similarity index 51% rename from userland/capsule_terminal/src/term/vt/utf8/state.rs rename to src/process/core/table/current_pid.rs index 2e1556b373..b20a4e6616 100644 --- a/userland/capsule_terminal/src/term/vt/utf8/state.rs +++ b/src/process/core/table/current_pid.rs @@ -14,30 +14,36 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -//! What the decoder carries between bytes. - -/// Accumulates bytes until they form a character. -#[derive(Default)] -pub struct Utf8 { - /// Bits gathered so far from the bytes of the current sequence. - pub(super) acc: u32, - /// Continuation bytes still expected. - pub(super) left: u8, - /// How many the sequence asked for, kept so an overlong encoding can be - /// told from a legitimate one of the same value. - pub(super) width: u8, +use core::sync::atomic::{AtomicU32, Ordering}; + +const INIT_PID: AtomicU32 = AtomicU32::new(0); + +pub struct CurrentPid { + slots: [AtomicU32; crate::smp::MAX_CPUS], } -impl Utf8 { - pub(super) fn begin(&mut self, bits: u32, follow: u8) { - self.acc = bits; - self.left = follow; - self.width = follow; +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); } - pub(super) fn reset(&mut self) { - self.acc = 0; - self.left = 0; - self.width = 0; + #[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/finalize.rs b/src/process/exit/finalize.rs index 6fdcd9e1bb..200f4681b3 100644 --- a/src/process/exit/finalize.rs +++ b/src/process/exit/finalize.rs @@ -35,6 +35,7 @@ pub(super) fn finalize_teardown(pid: Pid) { let _ = crate::ipc::nonos_inbox::unregister_for_pid(pid); } + crate::syscall::microkernel::tty_table::forget(pid); crate::process::clear_interrupt_context(pid); crate::process::clear_fpu_state(pid); crate::process::core::init::reparent_orphans(pid); 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..3b2975f56c 100644 --- a/src/process/foreign/exec.rs +++ b/src/process/foreign/exec.rs @@ -16,16 +16,11 @@ //! `MkForeignExec`: the same guest, a different program. -use crate::syscall::microkernel::errnos::{ERRNO_INVAL, ERRNO_PERM}; +use crate::syscall::microkernel::errnos::{ERRNO_INVAL, ERRNO_NOENT, ERRNO_PERM}; use super::exec_context::fresh; -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; +use super::exec_swap::{drop_tls, swap}; +use super::peer_guard::{in_user_half, pid_arg, supervised_asid}; pub fn sys_foreign_exec(pid: u64, entry: u64, rsp: u64) -> i64 { let Some(caller) = crate::process::current_pid() else { @@ -41,30 +36,34 @@ pub fn sys_foreign_exec(pid: u64, entry: u64, rsp: u64) -> i64 { if rsp == 0 || !in_user_half(entry, 1) || !in_user_half(rsp, 1) { return ERRNO_INVAL; } + /* + * A thread stopped at a tick is in no call an exec could answer: the stop + * takes only a handler, so the thread would run on in the old image over + * a context and a thread pointer already replaced. Refused as for a + * thread not parked at all. + */ + if super::trap_table::parked_nr(pid) == Some(super::frame::NR_INTERRUPTED) { + return ERRNO_NOENT; + } + /* Read while the stack is the supervisor's alone, applied once it runs. */ + let name = supervised_asid(caller, u64::from(pid)) + .ok() + .and_then(|(asid, _held)| super::guest_name::from_stack(asid, rsp)); let Some(previous) = swap(pid, Some(fresh(entry, rsp))) else { return ERRNO_INVAL; }; drop_tls(pid); // Answering is what releases the guest. - match super::trap_reply::answer_raw(pid, EXECED) { - 0 => 0, + match super::trap_reply::answer_raw(pid, super::trap_table::Answer::Execed) { + 0 => { + if let Some(name) = name { + super::guest_stats::rename(pid, name); + } + 0 + } err => { swap(pid, previous); err } } } - -/// Put a context in place and hand back the one it displaced. -fn swap(pid: u32, ctx: Saved) -> Option { - crate::process::with_process(pid, |p| { - core::mem::replace(&mut *p.saved_user_context.lock(), ctx) - }) -} - -/// Forget the thread pointer the replaced runtime set: the scheduler writes -/// the control block's base on every switch, so leaving it would put the new -/// image back on the old TLS the first time it is preempted. -fn drop_tls(pid: u32) { - crate::process::with_process(pid, |pcb| pcb.set_tls_base(0)); -} diff --git a/src/process/foreign/exec_swap.rs b/src/process/foreign/exec_swap.rs new file mode 100644 index 0000000000..fb84fc916c --- /dev/null +++ b/src/process/foreign/exec_swap.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 . + +//! Replacing the context an exec leaves behind, and the thread pointer that +//! went with it. + +pub(super) type Saved = Option; + +/// Put a context in place and hand back the one it displaced. +pub(super) fn swap(pid: u32, ctx: Saved) -> Option { + crate::process::with_process(pid, |p| { + core::mem::replace(&mut *p.saved_user_context.lock(), ctx) + }) +} + +/// Forget the thread pointer the replaced runtime set: the scheduler writes +/// the control block's base on every switch, so leaving it would put the new +/// image back on the old TLS the first time it is preempted. +pub(super) fn drop_tls(pid: u32) { + crate::process::with_process(pid, |pcb| pcb.set_tls_base(0)); +} diff --git a/src/process/foreign/fork.rs b/src/process/foreign/fork.rs index 293738b7ea..9e67cbe35b 100644 --- a/src/process/foreign/fork.rs +++ b/src/process/foreign/fork.rs @@ -38,19 +38,27 @@ pub fn sys_foreign_fork(pid: u64) -> i64 { // A guest that is not parked inside a syscall has no frame to copy. return ERRNO_NOENT; }; - let child = match super::spawn::empty_guest(caller, b"fork") { + let comm = super::guest_stats::comm_of(parent); + let child = match super::spawn::empty_guest(caller, comm.as_bytes()) { Ok(pid) => pid, Err(e) => return e, }; 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/guest_name.rs b/src/process/foreign/guest_name.rs new file mode 100644 index 0000000000..4a68ab2d33 --- /dev/null +++ b/src/process/foreign/guest_name.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 guest's name in the process table follows the program it runs, as a +//! Linux task's comm does: the last part of argv[0], at most 15 bytes of +//! printable ASCII, behind a "foreign:" the guest cannot remove. + +use alloc::string::String; +use alloc::vec::Vec; + +use crate::memory::addr::{PhysAddr, VirtAddr}; +use crate::memory::paging::manager::translate_in_asid; + +use super::peer_guard::{in_user_half, PAGE}; + +/// Linux keeps this many bytes of a task's name. +const COMM: usize = 15; +/// How far into argv[0] its terminator is looked for. +const LOOK: u64 = 256; + +/// The name argv[0] gives on a new stack at `rsp`, or None when the stack, +/// the pointer or the string cannot be read or names nothing printable. +/// The caller holds the peer lock, so no page read here can be unmapped. +pub(super) fn from_stack(asid: u32, rsp: u64) -> Option { + let at = rsp.checked_add(8)?; + let ptr = u64::from_le_bytes(read(asid, at, 8, false)?.try_into().ok()?); + let raw = read(asid, ptr, LOOK, true)?; + let path = &raw[..raw.iter().position(|b| *b == 0)?]; + let base = path.rsplit(|b| *b == b'/').next()?; + let comm = &base[..base.len().min(COMM)]; + if comm.is_empty() || !comm.iter().all(|b| (0x21..0x7f).contains(b)) { + return None; + } + Some(alloc::format!("foreign:{}", core::str::from_utf8(comm).ok()?)) +} + +/// Up to `len` bytes of the guest at `va`, only from its own half, stopping +/// after the page that holds a terminator when `string` asks for one. +fn read(asid: u32, va: u64, len: u64, string: bool) -> Option> { + let mut out = Vec::new(); + let mut at = va; + while (out.len() as u64) < len { + let page = at & !(PAGE - 1); + let take = core::cmp::min(PAGE - (at - page), len - out.len() as u64); + if !in_user_half(at, take) { + return None; + } + let phys = translate_in_asid(asid, VirtAddr::new(page))?; + let virt = crate::memory::unified::phys_to_virt(PhysAddr::new(phys.as_u64() + at - page))?; + // SAFETY: eK@nonos.systems - `phys` came from the guest's own page + // tables for an address in its own half, and `take` stays inside + // that one frame, which the held peer lock keeps mapped. + out.extend_from_slice(unsafe { + core::slice::from_raw_parts(virt.as_u64() as *const u8, take as usize) + }); + if string && out.contains(&0) { + break; + } + at += take; + } + Some(out) +} diff --git a/src/process/foreign/guest_stats.rs b/src/process/foreign/guest_stats.rs new file mode 100644 index 0000000000..f3dad1c706 --- /dev/null +++ b/src/process/foreign/guest_stats.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 . + +//! What the process table shows for a guest that the kernel would not see +//! on its own: the calls trapped to its supervisor, the pages that +//! supervisor put into it, and the name of the program it runs. + +use alloc::string::String; +use core::sync::atomic::Ordering; + +use crate::process::accounting::{self, Kind, Total}; + +/// A trapped call is one of the guest's syscalls, counted as the syscall +/// entry counts every other process's. +pub(super) fn called(pid: u32) { + accounting::bump(pid, Kind::Syscall); + accounting::bump_total(Total::Syscalls); +} + +/// Pages a peer call mapped into or took out of the guest, kept in its +/// resident count. A page filled on first touch of a reservation is not +/// counted here. +pub(super) fn resident(pid: u32, gained: u64, lost: u64) { + crate::process::with_process(pid, |pcb| { + let mem = pcb.memory.lock(); + let _ = mem.resident_pages.fetch_update(Ordering::Relaxed, Ordering::Relaxed, |n| { + Some(n.saturating_add(gained).saturating_sub(lost)) + }); + }); +} + +/// Name the guest, once an exec has answered, after the program it now runs. +pub(super) fn rename(pid: u32, name: String) { + crate::process::with_process(pid, |pcb| *pcb.name.lock() = name); +} + +/// The part of a guest's name after "foreign:", which a forked child takes as +/// a Linux child takes its parent's comm. +pub(super) fn comm_of(pid: u32) -> String { + crate::process::with_process(pid, |pcb| { + String::from(pcb.name.lock().strip_prefix("foreign:").unwrap_or("fork")) + }) + .unwrap_or_else(|| String::from("fork")) +} diff --git a/src/process/foreign/interrupt/call.rs b/src/process/foreign/interrupt/call.rs new file mode 100644 index 0000000000..41a4662cfc --- /dev/null +++ b/src/process/foreign/interrupt/call.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 `MkForeignInterrupt` call: a supervisor marks one of its guests. + +use crate::process::foreign::trap_table::parked_nr; + +/// Mark `pid`, one of the caller's guests. 1 says it is parked in a call +/// already and is left unmarked: its supervisor delivers with the answer, or +/// marks it again once it is answered; 0 says it is marked. +pub fn sys_foreign_interrupt(pid: u64) -> i64 { + let pid = match crate::process::foreign::signal_call::supervised(pid) { + Ok(p) => p, + Err(e) => return e, + }; + if parked_nr(pid).is_some() { + return 1; + } + super::marks::mark(pid); + 0 +} diff --git a/src/process/foreign/interrupt/marks.rs b/src/process/foreign/interrupt/marks.rs new file mode 100644 index 0000000000..b96a70e91f --- /dev/null +++ b/src/process/foreign/interrupt/marks.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 . + +//! Which guest threads their supervisors have marked for a tick stop. + +use alloc::vec::Vec; +use core::sync::atomic::{AtomicBool, Ordering}; + +use spin::Mutex; + +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); + +pub(super) fn mark(pid: u32) { + let mut marked = MARKED.lock(); + /* + * Asked under the lock: a teardown drops the thread's registry row before + * it forgets its mark, so a thread gone meanwhile is never left one. + */ + if !crate::process::foreign::is_foreign(pid) { + return; + } + if !marked.contains(&pid) { + marked.push(pid); + } + ANY.store(true, Ordering::Release); +} + +/// A thread that is gone keeps no mark for a later one with its pid. +pub(in crate::process::foreign) fn forget(pid: u32) { + let mut marked = MARKED.lock(); + marked.retain(|&p| p != pid); + ANY.store(!marked.is_empty(), Ordering::Release); +} + +/// Whether any thread is marked, for the tick's one-load test. +pub(super) fn any() -> bool { + ANY.load(Ordering::Acquire) +} + +pub(super) 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 +} diff --git a/src/process/foreign/interrupt/mod.rs b/src/process/foreign/interrupt/mod.rs new file mode 100644 index 0000000000..aa99113d46 --- /dev/null +++ b/src/process/foreign/interrupt/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 . + +//! `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. + +mod call; +mod marks; +mod tick; +mod tick_frame; + +pub use call::sys_foreign_interrupt; +pub(super) use marks::forget; +pub use tick::on_user_tick; +pub use tick_frame::WORDS as TICK_FRAME_WORDS; diff --git a/src/process/foreign/interrupt/tick.rs b/src/process/foreign/interrupt/tick.rs new file mode 100644 index 0000000000..9725801740 --- /dev/null +++ b/src/process/foreign/interrupt/tick.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 . + +//! Stopping a marked guest thread at a timer tick that interrupted it. + +use super::marks::{any, take}; +use super::tick_frame::{to_user, to_words, WORDS}; +use crate::process::foreign::frame::{ForeignFrame, NR_INTERRUPTED}; +use crate::process::foreign::trap_table::{park, Answer}; +use crate::process::foreign::{registry, signal_fpu, trap_frame, trap_wait}; + +/// Called by the timer trampoline, after the tick, for a tick that +/// interrupted user mode. `frame` is the interrupted register file the +/// trampoline restores (`tick_frame`), `fx` the FXSAVE area it reloads. +pub fn on_user_tick(frame: &mut [u64; WORDS], fx: *mut u8) { + if !any() || !crate::smp::preempt_enabled() { + return; + } + let Some(pid) = crate::process::current_pid() else { + return; + }; + if !take(pid) { + return; + } + let Some(supervisor) = 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); + trap_frame::keep(pid, held); + if !park(ForeignFrame::new(pid, NR_INTERRUPTED, [0; 6], held.rip)) { + trap_frame::drop_frame(pid); + return; + } + crate::sched::wake_process(supervisor); + if let Answer::Deliver(to) = trap_wait::wait_raw(pid) { + signal_fpu::enter_fpu(pid); + /* + * SAFETY: eK@nonos.systems - `fx` is the trampoline's 16-aligned, + * 512-byte area on this kernel stack. It held the interrupted state, + * which enter_fpu has kept; the clean state goes over it, or the + * trampoline's reload would hand that state to the handler. + */ + unsafe { core::arch::asm!("fxsave64 [{}]", in(reg) fx, options(nostack, preserves_flags)) }; + crate::arch::context::set_user_tls(to.fs_base); + *frame = to_words(&to); + } +} diff --git a/src/process/foreign/interrupt/tick_frame.rs b/src/process/foreign/interrupt/tick_frame.rs new file mode 100644 index 0000000000..c8a950fce2 --- /dev/null +++ b/src/process/foreign/interrupt/tick_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..db2ce3977f 100644 --- a/src/process/foreign/mod.rs +++ b/src/process/foreign/mod.rs @@ -19,10 +19,15 @@ mod exec; mod exec_context; mod exec_enter; +mod exec_swap; mod fork; mod frame; mod frame_cpu; mod frame_snapshot; +mod guest_name; +mod guest_stats; +mod interrupt; +mod notice; mod peer_chunk; mod peer_copy; mod peer_guard; @@ -33,12 +38,17 @@ mod peer_tls; mod peer_unmap; mod registry; mod resume; +mod signal_call; +mod signal_enter; +mod signal_fpu; +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 +58,15 @@ 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, TICK_FRAME_WORDS}; +pub use notice::note_signal_death; 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}; +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..b7755479c9 --- /dev/null +++ b/src/process/foreign/notice.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 . + +//! 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()); + +/// 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) = super::registry::supervisor_of(pid) { + post(supervisor, pid, code); + } +} + +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/peer_map.rs b/src/process/foreign/peer_map.rs index 3740c267a6..7fc09c5a08 100644 --- a/src/process/foreign/peer_map.rs +++ b/src/process/foreign/peer_map.rs @@ -51,6 +51,7 @@ pub fn sys_peer_map(pid: u64, addr: u64, len: u64, prot: u64) -> i64 { return ERRNO_INVAL; } let perms = perms_of(prot); + let mut mapped = 0; for i in 0..len.div_ceil(PAGE) { let va = VirtAddr::new(addr + i * PAGE); if translate_in_asid(asid, va).is_some() { @@ -62,8 +63,11 @@ pub fn sys_peer_map(pid: u64, addr: u64, len: u64, prot: u64) -> i64 { crate::memory::frame_alloc::zero_frame(frame); if map_page_in_asid(asid, va, frame, perms).is_err() { let _ = crate::memory::frame_alloc::deallocate_frame(frame); + super::guest_stats::resident(pid as u32, mapped, 0); return ERRNO_NOMEM; } + mapped += 1; } + super::guest_stats::resident(pid as u32, mapped, 0); 0 } diff --git a/src/process/foreign/peer_unmap.rs b/src/process/foreign/peer_unmap.rs index 5bc9e31400..9bee146a6d 100644 --- a/src/process/foreign/peer_unmap.rs +++ b/src/process/foreign/peer_unmap.rs @@ -41,6 +41,7 @@ pub fn sys_peer_unmap(pid: u64, addr: u64, len: u64) -> i64 { return ERRNO_INVAL; } let perms = PagePermissions::READ | PagePermissions::USER; + let mut dropped = 0; for i in 0..len.div_ceil(PAGE) { let va = VirtAddr::new(addr + i * PAGE); if translate_in_asid(asid, va).is_none() { @@ -48,7 +49,9 @@ pub fn sys_peer_unmap(pid: u64, addr: u64, len: u64) -> i64 { } if let Ok(frame) = unmap_page_in_asid(asid, va, perms) { let _ = crate::memory::frame_alloc::deallocate_frame(frame); + dropped += 1; } } + super::guest_stats::resident(pid as u32, 0, dropped); 0 } diff --git a/src/process/foreign/registry.rs b/src/process/foreign/registry.rs index 738a30f8a3..c1086e1a83 100644 --- a/src/process/foreign/registry.rs +++ b/src/process/foreign/registry.rs @@ -20,6 +20,8 @@ use alloc::vec::Vec; use spin::RwLock; +use crate::process::signal::constants::SIGKILL; + struct Entry { pid: u32, supervisor: u32, @@ -57,7 +59,9 @@ pub fn clear(pid: u32) { // Both directions go, not just this process's own row. FOREIGN.write().retain(|e| e.pid != pid && e.supervisor != pid); for guest in orphans { - super::trap_reply::abandon(guest); + /* As its supervisor's MkKill would: only released from its call, a + * guest spun on in its own code, or looped on exit, unanswered. */ + crate::process::exit::teardown(guest, 128 + i32::from(SIGKILL), true); } /* * A guest that died while parked leaves its frame behind, and @@ -65,4 +69,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..2a6a80d31a --- /dev/null +++ b/src/process/foreign/signal_enter.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 . + +//! A parked guest leaving into a signal handler, or back out of one, with +//! its FPU state kept by `signal_fpu`. + +use crate::arch::context::SavedUser; +use crate::process::userspace::restore_user_context_iretq; + +use super::signal_fpu::{enter_fpu, leave_fpu}; + +pub(super) fn deliver(pid: u32, ctx: SavedUser) -> ! { + enter_fpu(pid); + resume(ctx) +} + +pub(super) fn sigreturn(pid: u32, ctx: SavedUser) -> ! { + leave_fpu(pid); + resume(ctx) +} + +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_fpu.rs b/src/process/foreign/signal_fpu.rs new file mode 100644 index 0000000000..22b42b656c --- /dev/null +++ b/src/process/foreign/signal_fpu.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 FPU state of a guest thread inside a signal handler, kept 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::process::signal::SIGSEGV; +use crate::process::userspace::types::FpuState; + +/// Handlers nested deeper than this end the thread. +const DEPTH: usize = 8; + +static SAVED: Mutex>>> = Mutex::new(BTreeMap::new()); + +/// 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(); +} + +/// Put back the state the innermost handler was entered over. A return with +/// nothing delivered is not a frame this kernel made, and ends the thread. +pub(super) fn leave_fpu(pid: u32) { + let top = SAVED.lock().get_mut(&pid).and_then(|s| s.pop()); + match top { + Some(fpu) => fpu.restore(), + None => crate::process::terminate_current_with_signal(SIGSEGV), + } +} + +/// Exec and exit leave no handler to return from. +pub(super) fn forget(pid: u32) { + SAVED.lock().remove(&pid); +} 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/spawn_start.rs b/src/process/foreign/spawn_start.rs index 001952dcb5..96a1aed5e9 100644 --- a/src/process/foreign/spawn_start.rs +++ b/src/process/foreign/spawn_start.rs @@ -16,7 +16,7 @@ //! Making a built guest runnable. -use super::peer_guard::{in_user_half, pid_arg}; +use super::peer_guard::{in_user_half, pid_arg, supervised_asid}; use crate::syscall::microkernel::errnos::{ERRNO_INVAL, ERRNO_PERM}; // `rsp` of zero asks for the kernel's own user stack. @@ -45,5 +45,10 @@ pub fn sys_foreign_start(pid: u64, entry: u64, rsp: u64) -> i64 { if !in_user_half(entry, 1) || (rsp != 0 && !in_user_half(rsp, 0)) { return ERRNO_INVAL; } + /* A stack the supervisor built carries argv, and the program's name. */ + let name = (rsp != 0).then(|| supervised_asid(caller, u64::from(pid)).ok()).flatten(); + if let Some(name) = name.and_then(|(asid, _held)| super::guest_name::from_stack(asid, rsp)) { + super::guest_stats::rename(pid, name); + } super::start_context::install(pid, entry, rsp) } 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..531ae27b5f 100644 --- a/src/process/foreign/trap.rs +++ b/src/process/foreign/trap.rs @@ -25,16 +25,23 @@ use super::trap_wait::wait_for_answer; /// The kernel's answer to a syscall number it does not know, made by the /// supervisor rather than by the kernel. pub fn redirect(nr: u64, args: [u64; 6], frame: &[u64; FRAME_WORDS]) -> Option { + /* + * The numbers this kernel hands a supervisor for its own reasons, a death + * and a tick stop, are no syscall's: a guest naming one gets ENOSYS, as + * for any number nobody serves, not a stop its supervisor answers 0. + */ + if nr >= super::frame::NR_INTERRUPTED { + return None; + } let pid = crate::process::current_pid()?; let supervisor = registry::supervisor_of(pid)?; + super::guest_stats::called(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..012e915eda 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,25 @@ 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); + /* + * A handler answer spends the thread's stop mark as it is posted: a mark + * set after this, while the thread has yet to take the answer, is for + * another signal and stops it at its next tick. No other answer does. + */ + if matches!(answer, Answer::Deliver(_)) { + super::interrupt::forget(pid); + } drop(parked); crate::sched::wake_process(pid); 0 @@ -58,14 +66,5 @@ 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); -} - -/// 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); - } - drop(parked); - crate::sched::wake_process(pid); + super::signal_fpu::forget(pid); } diff --git a/src/process/foreign/trap_table.rs b/src/process/foreign/trap_table.rs index 1027858bc4..57ac84620f 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 } +/// The call `pid` is parked in that no answer has reached yet, if any. +pub(super) fn parked_nr(pid: u32) -> Option { + PARKED.lock().iter().find(|p| p.frame.pid == pid && p.answer.is_none()).map(|p| p.frame.nr) +} + /// 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..52c23054a2 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_fpu::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/foreign_absent.rs b/src/process/foreign_absent.rs new file mode 100644 index 0000000000..0803a9b848 --- /dev/null +++ b/src/process/foreign_absent.rs @@ -0,0 +1,84 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! Supervised hosting where the kernel cannot supervise. +//! +//! A guest exists because of one path: a syscall number this kernel does not +//! know is parked and handed to the guest's supervisor instead of refused. That +//! path is x86_64 only. It reads the frame syscall.S pushes and the user rsp +//! from gs, and resumes an exec'd guest with iretq. The aarch64 SVC handler +//! answers every unknown number with ENOSYS and parks nothing, and the thread +//! pointer these calls set is never installed there (see arch/context/tls.rs). +//! +//! So every foreign and peer call answers ERRNO_NOSYS, as the port I/O calls do +//! on this architecture, rather than creating a guest nobody can supervise or +//! reporting success for a thread pointer that is dropped. + +use crate::syscall::microkernel::errnos::ERRNO_NOSYS; + +pub fn sys_foreign_spawn(_name_ptr: u64, _name_len: u64) -> i64 { + ERRNO_NOSYS +} + +pub fn sys_foreign_start(_pid: u64, _entry: u64, _rsp: u64) -> i64 { + ERRNO_NOSYS +} + +pub fn sys_foreign_wait(_out_ptr: u64, _out_len: u64, _timeout_ms: u64) -> i64 { + ERRNO_NOSYS +} + +pub fn sys_foreign_reply(_pid: u64, _value: u64) -> i64 { + ERRNO_NOSYS +} + +pub fn sys_foreign_thread(_pid: u64, _entry: u64, _rsp: u64, _tls: u64) -> i64 { + ERRNO_NOSYS +} + +pub fn sys_foreign_fork(_pid: u64) -> i64 { + ERRNO_NOSYS +} + +pub fn sys_foreign_exec(_pid: u64, _entry: u64, _rsp: u64) -> i64 { + ERRNO_NOSYS +} + +pub fn sys_peer_map(_pid: u64, _addr: u64, _len: u64, _prot: u64) -> i64 { + ERRNO_NOSYS +} + +pub fn sys_peer_copy(_pid: u64, _guest_addr: u64, _buf: u64, _len: u64, _to_guest: u64) -> i64 { + ERRNO_NOSYS +} + +pub fn sys_peer_protect(_pid: u64, _addr: u64, _len: u64, _prot: u64) -> i64 { + ERRNO_NOSYS +} + +pub fn sys_peer_tls(_pid: u64, _base: u64) -> i64 { + ERRNO_NOSYS +} + +pub fn sys_peer_unmap(_pid: u64, _addr: u64, _len: u64) -> i64 { + ERRNO_NOSYS +} + +/// Drop `pid` from the foreign table. Spawn, thread and fork, the only paths +/// that record one, all refuse above, so none was ever recorded. +pub fn clear(pid: u32) { + let _ = pid; +} diff --git a/src/process/mod.rs b/src/process/mod.rs index 257d38a917..88bf365276 100644 --- a/src/process/mod.rs +++ b/src/process/mod.rs @@ -23,6 +23,10 @@ pub mod context; pub mod core; pub mod exit; pub mod fd_table; +#[cfg(target_arch = "x86_64")] +pub mod foreign; +#[cfg(not(target_arch = "x86_64"))] +#[path = "foreign_absent.rs"] pub mod foreign; pub mod fd_types; pub mod manager; 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/attest_doc/binding.rs b/src/security/attest_doc/binding.rs index e0e7c7c3af..fe3a086ed6 100644 --- a/src/security/attest_doc/binding.rs +++ b/src/security/attest_doc/binding.rs @@ -16,10 +16,20 @@ /// Domain separator. Without one, a digest computed here could be replayed as /// a digest computed for some other purpose over the same bytes. -const BIND_CONTEXT: &[u8] = b"nonos.attest.bind.v1"; +const BIND_CONTEXT: &[u8] = b"nonos.attest.bind.v2"; -/// Fold the challenge and the registry root into the value handed to the TPM -/// as `qualifyingData`. +/// What the machine says about device DMA, bound into the quote beside the +/// registry root: which IOMMU, whether it enforces, and how many mappings go +/// around it. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct DmaPosture { + pub vendor: u8, + pub enforcing: bool, + pub unconfined_grants: u32, +} + +/// Fold the challenge, the registry root and the DMA posture into the value +/// handed to the TPM as `qualifyingData`. /// /// This is the load-bearing step of the whole design. A TPM quote signs two /// things: the PCR values, and whatever the caller passed as qualifying data. @@ -32,10 +42,21 @@ const BIND_CONTEXT: &[u8] = b"nonos.attest.bind.v1"; /// verifier recomputes this digest from the challenge it issued and the root /// it was shown, and any substitution of either produces a different value /// than the one the TPM signed. -pub fn qualifying_data(challenge: &[u8; 32], registry_root: &[u8; 32]) -> [u8; 32] { +/// +/// The DMA posture is bound for the same reason. A document saying no mapping +/// goes around the IOMMU is worth nothing if that number could be edited after +/// the quote, so an attested boot with unconfined grants and one without are +/// different signed statements. +pub fn qualifying_data( + challenge: &[u8; 32], + registry_root: &[u8; 32], + dma: &DmaPosture, +) -> [u8; 32] { let mut hasher = blake3::Hasher::new(); hasher.update(BIND_CONTEXT); hasher.update(challenge); hasher.update(registry_root); + hasher.update(&[dma.vendor, u8::from(dma.enforcing)]); + hasher.update(&dma.unconfined_grants.to_be_bytes()); *hasher.finalize().as_bytes() } diff --git a/src/security/attest_doc/document.rs b/src/security/attest_doc/document.rs index 1a6447ac87..8dd27af035 100644 --- a/src/security/attest_doc/document.rs +++ b/src/security/attest_doc/document.rs @@ -19,10 +19,15 @@ use alloc::vec::Vec; /// Wire format version. A verifier that does not recognise it must refuse /// rather than parse optimistically: a document it half understands is worse /// than one it rejects. -pub const DOC_VERSION: u32 = 2; +pub const DOC_VERSION: u32 = 3; pub const DOC_MAGIC: &[u8; 8] = b"NONOSATT"; +/// `iommu_vendor` values. A verifier that meets another value must refuse. +pub const IOMMU_NONE: u8 = 0; +pub const IOMMU_INTEL_VTD: u8 = 1; +pub const IOMMU_AMD_VI: u8 = 2; + /// What the machine hands to whoever asked what it is running. /// /// `registry_root` and `capsule_count` are carried in the clear for the @@ -37,6 +42,16 @@ pub struct AttestationDoc { /// treat a document with this clear as a statement that the machine no /// longer knows everything it is running. pub registry_complete: bool, + /// Which IOMMU the machine selected at boot, as one of the `IOMMU_*` values. + pub iommu_vendor: u8, + /// Whether that unit translates with this kernel's tables. + pub iommu_enforcing: bool, + /// Mappings a device could reach with no IOMMU domain confining them when + /// the document was produced. With `iommu_enforcing` set and this above + /// zero, the unit is in service and device DMA still goes around it; only + /// the two together say whether DMA on the machine is confined. Like the + /// registry root, all three are folded into what the TPM signs. + pub unconfined_grants: u32, /// The `TPMS_ATTEST` the TPM produced, byte for byte as signed. pub attest: Vec, pub signature: Vec, @@ -59,6 +74,9 @@ impl AttestationDoc { out.extend_from_slice(&self.registry_root); out.extend_from_slice(&self.capsule_count.to_be_bytes()); out.push(u8::from(self.registry_complete)); + out.push(self.iommu_vendor); + out.push(u8::from(self.iommu_enforcing)); + out.extend_from_slice(&self.unconfined_grants.to_be_bytes()); out.extend_from_slice(&(self.attest.len() as u32).to_be_bytes()); out.extend_from_slice(&self.attest); out.extend_from_slice(&(self.signature.len() as u32).to_be_bytes()); diff --git a/src/security/attest_doc/mod.rs b/src/security/attest_doc/mod.rs index fb76700b87..d4b6293d53 100644 --- a/src/security/attest_doc/mod.rs +++ b/src/security/attest_doc/mod.rs @@ -29,6 +29,8 @@ mod error; mod produce; pub use attest::attest; -pub use binding::qualifying_data; -pub use document::{AttestationDoc, DOC_MAGIC, DOC_VERSION}; +pub use binding::{qualifying_data, DmaPosture}; +pub use document::{ + AttestationDoc, DOC_MAGIC, DOC_VERSION, IOMMU_AMD_VI, IOMMU_INTEL_VTD, IOMMU_NONE, +}; pub use error::AttestDocError; diff --git a/src/security/attest_doc/produce.rs b/src/security/attest_doc/produce.rs index a13b4ea407..c7f8c45161 100644 --- a/src/security/attest_doc/produce.rs +++ b/src/security/attest_doc/produce.rs @@ -16,9 +16,10 @@ use alloc::vec::Vec; -use super::binding::qualifying_data; -use super::document::AttestationDoc; +use super::binding::{qualifying_data, DmaPosture}; +use super::document::{AttestationDoc, IOMMU_AMD_VI, IOMMU_INTEL_VTD, IOMMU_NONE}; use super::error::AttestDocError; +use crate::memory::iommu::{capabilities, unconfined_grants, IommuVendor}; use crate::security::attest_registry::{attested_count, registry_complete, registry_root}; use crate::security::tpm::ak::ak_public; use crate::security::tpm::crb::transact; @@ -39,12 +40,16 @@ const RESPONSE_MAX: usize = 4096; /// Refuses when the registry is incomplete. A document that omits a running /// capsule is the one failure a remote party cannot detect, so the machine /// declines to speak rather than understate itself. -pub(super) fn produce(ak_handle: u32, challenge: &[u8; 32]) -> Result { +pub(super) fn produce( + ak_handle: u32, + challenge: &[u8; 32], +) -> Result { if !registry_complete() { return Err(AttestDocError::RegistryIncomplete); } let root = registry_root(); - let qualifying = qualifying_data(challenge, &root); + let dma = dma_posture(); + let qualifying = qualifying_data(challenge, &root, &dma); let cmd = build_quote(ak_handle, &qualifying, "ED_PCRS); let mut buf = [0u8; RESPONSE_MAX]; @@ -64,8 +69,23 @@ pub(super) fn produce(ak_handle: u32, challenge: &[u8; 32]) -> Result DmaPosture { + let caps = capabilities(); + let vendor = match caps.vendor { + IommuVendor::Absent => IOMMU_NONE, + IommuVendor::IntelVtd => IOMMU_INTEL_VTD, + IommuVendor::AmdVi => IOMMU_AMD_VI, + }; + DmaPosture { vendor, enforcing: caps.enforcing, unconfined_grants: unconfined_grants() } +} 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/sys/serial/core.rs b/src/sys/serial/core.rs index 920b63dd7f..143d2ff238 100644 --- a/src/sys/serial/core.rs +++ b/src/sys/serial/core.rs @@ -67,3 +67,39 @@ pub fn write_byte(ch: u8) { pub fn is_available() -> bool { crate::arch::console::is_available() } + +/* +The fatal path's writer, aarch64 only. A CPU that traps while it holds +SERIAL_LOCK, or while another CPU that will never run again holds it, +would spin in with_serial_lock forever and the trap would never be +named. So the lock is taken if it frees within a bounded number of +tries, and the line is written either way: a line interleaved with +another CPU's output can still be read, a line never written cannot. +With the MMU off the lock is not touched at all. Its word is Device +memory then, and exclusive access to Device memory is not guaranteed +to work. +*/ +#[cfg(target_arch = "aarch64")] +pub fn write_fatal_line(bytes: &[u8]) { + const TRIES: u32 = 1 << 20; + const SCTLR_M: u64 = 1; + let sctlr: u64; + // SAFETY: reading SCTLR_EL1 at EL1 has no side effect. + unsafe { + ::core::arch::asm!("mrs {}, sctlr_el1", out(reg) sctlr, options(nomem, nostack, preserves_flags)); + } + let mut guard = None; + if sctlr & SCTLR_M != 0 { + for _ in 0..TRIES { + guard = SERIAL_LOCK.try_lock(); + if guard.is_some() { + break; + } + ::core::hint::spin_loop(); + } + } + for &ch in bytes.iter().chain(b"\r\n") { + write_byte(ch); + } + drop(guard); +} diff --git a/src/sys/serial/line.rs b/src/sys/serial/line.rs index 1ff9bbf9d2..ad404d165a 100644 --- a/src/sys/serial/line.rs +++ b/src/sys/serial/line.rs @@ -102,3 +102,12 @@ impl Line { println(&self.buf[..self.len]); } } + +#[cfg(target_arch = "aarch64")] +impl Line { + /// Emit the line from a path that will never return, without waiting on a + /// lock that may never be released. See `core::write_fatal_line`. + pub fn end_fatal(&self) { + super::core::write_fatal_line(&self.buf[..self.len]); + } +} diff --git a/src/sys/timer/tsc/init.rs b/src/sys/timer/tsc/init.rs index b36aba1161..07638075e8 100644 --- a/src/sys/timer/tsc/init.rs +++ b/src/sys/timer/tsc/init.rs @@ -42,7 +42,7 @@ pub fn init(tsc_hz: u64, boot_epoch_ms: u64) { TIMER_INIT.store(true, Ordering::SeqCst); - serial::print(b"[TIMER] Initialized, TSC freq="); + serial::print(COUNTER_LABEL); serial::print_dec(hz / 1_000_000); serial::println(b" MHz"); } @@ -57,3 +57,12 @@ pub fn init_default() { pub fn calibrate_tsc_hz() -> u64 { crate::arch::time_counter_hz() } + +/* + * The rate printed above belongs to the TSC on x86_64 and to the generic timer + * (CNTFRQ_EL0) elsewhere, so the line names the counter it measured. + */ +#[cfg(target_arch = "x86_64")] +const COUNTER_LABEL: &[u8] = b"[TIMER] Initialized, TSC freq="; +#[cfg(not(target_arch = "x86_64"))] +const COUNTER_LABEL: &[u8] = b"[TIMER] Initialized, generic timer freq="; diff --git a/src/syscall/abi/registry/mk.rs b/src/syscall/abi/registry/mk.rs index 171c9ec52e..bc9fa186c3 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,7 +110,13 @@ 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"MTTY", SyscallNumber::MkTtySet, "MkTtySet"), + e(b"MTTQ", SyscallNumber::MkTtyQuery, "MkTtyQuery"), e(b"MSOW", SyscallNumber::MkStdoutWrite, "MkStdoutWrite"), e(b"MSWR", SyscallNumber::MkStoreWrite, "MkStoreWrite"), e(b"MCVF", SyscallNumber::MkCapsuleVerify, "MkCapsuleVerify"), diff --git a/src/syscall/contract/cap_table/mk.rs b/src/syscall/contract/cap_table/mk.rs index e007d63a00..0ae1d5861b 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 @@ -193,6 +200,13 @@ pub(super) fn check(caps: &CapabilityToken, number: SyscallNumber) -> Option caps.can_ipc(), + /* + * Saying what a child's streams are on is part of driving its stdio, + * so it needs what running the child needed. Asking about one's own + * streams reveals nothing about anyone else. + */ + SyscallNumber::MkTtySet => caps.can_ipc(), + SyscallNumber::MkTtyQuery => true, _ => return None, }) 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..381d1cd859 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,7 +102,13 @@ pub(super) fn matches(nr: SyscallNumber) -> bool { | MkLocalVerify | MkAppInstall | MkDevRootLocal + | MkLocalConsent + | MkLocalRestore + | MkAppLaunch + | MkAppInstallStatus | MkToolRun + | MkTtySet + | MkTtyQuery ) } 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..d5f39a56e8 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; @@ -49,6 +53,7 @@ use crate::syscall::microkernel::time::{ sys_time_adjust, sys_time_millis, sys_time_monotonic, sys_time_rtc, }; use crate::syscall::microkernel::tool_run::sys_tool_run; +use crate::syscall::microkernel::tty::{sys_tty_query, sys_tty_set}; use crate::syscall::microkernel::wait::sys_wait; pub(super) fn handle(nr: u64, a: Args) -> Option { @@ -87,22 +92,31 @@ 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), SYS_TOOL_RUN => sys_tool_run(a.a0, a.a1, a.a2, a.a3), + SYS_TTY_SET => sys_tty_set(a.a0, a.a1, a.a2, a.a3), + SYS_TTY_QUERY => sys_tty_query(a.a0), _ => return None, }) } diff --git a/src/syscall/microkernel/errnos.rs b/src/syscall/microkernel/errnos.rs index fdd743208a..88f82abf7d 100644 --- a/src/syscall/microkernel/errnos.rs +++ b/src/syscall/microkernel/errnos.rs @@ -29,6 +29,7 @@ pub const ERRNO_BUSY: i64 = -16; pub const ERRNO_EXIST: i64 = -17; pub const ERRNO_NODEV: i64 = -19; pub const ERRNO_INVAL: i64 = -22; +pub const ERRNO_NOTTY: i64 = -25; pub const ERRNO_NOSYS: i64 = -38; pub const ERRNO_NOTSUP: i64 = -95; pub const ERRNO_TIMEDOUT: i64 = -110; 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..9b2e9c77c6 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,11 +60,14 @@ 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; pub mod time; pub mod tool_run; +pub mod tty; +pub mod tty_table; pub mod wait; pub use attest::sys_attest_status; diff --git a/src/syscall/microkernel/numbers.rs b/src/syscall/microkernel/numbers.rs index a724b33a31..36de2ecff0 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"); @@ -132,3 +147,5 @@ pub const SYS_SPAWN_INSTANCE: u64 = tag4(b"MSPI"); // Run a baked, attested command-line tool by name, parented to the caller so // it can drive the tool's stdin and stdout. Gated on the IPC capability. pub const SYS_TOOL_RUN: u64 = tag4(b"MTRN"); +pub const SYS_TTY_SET: u64 = tag4(b"MTTY"); +pub const SYS_TTY_QUERY: u64 = tag4(b"MTTQ"); 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/microkernel/tool_run.rs b/src/syscall/microkernel/tool_run.rs index 0656eb12fd..0d9930ff36 100644 --- a/src/syscall/microkernel/tool_run.rs +++ b/src/syscall/microkernel/tool_run.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 super::errnos::{ERRNO_FAULT, ERRNO_INVAL, ERRNO_NOENT}; +use super::errnos::{ERRNO_FAULT, ERRNO_INVAL}; use crate::usercopy::{read_user_bytes, validate_user_read}; const MAX_NAME: usize = 48; @@ -52,7 +52,7 @@ pub fn sys_tool_run(name_ptr: u64, name_len: u64, argv_ptr: u64, argv_len: u64) } }; match crate::userspace::tool_capsules::run_named(&name, &argv) { - Some(pid) => pid as i64, - None => ERRNO_NOENT, + Ok(pid) => pid as i64, + Err(errno) => errno, } } diff --git a/src/syscall/microkernel/tty.rs b/src/syscall/microkernel/tty.rs new file mode 100644 index 0000000000..f256a4f9f8 --- /dev/null +++ b/src/syscall/microkernel/tty.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 . + +//! `MkTtySet` and `MkTtyQuery`: how a program learns whether it is writing +//! to a person or to a pipe. A launcher that renders a child's output +//! knows which of the child's streams reach its screen; the child asks +//! before choosing colour, columns and a pager. + +use super::errnos::{ERRNO_INVAL, ERRNO_NOTTY, ERRNO_PERM}; +use super::tty_table::{self, Tty, STREAMS_ALL}; +use crate::process::{current_pid, get_parent_pid}; + +/// `MkTtySet(pid, streams, cols, rows)`. Only the parent of `pid` may say +/// what its streams are on, the same rule that lets it drain them; anyone +/// else would be telling a stranger's program it has a screen. +pub fn sys_tty_set(pid: u64, streams: u64, cols: u64, rows: u64) -> i64 { + let fits = |v: u64| v <= u16::MAX as u64; + if pid == 0 || pid > u32::MAX as u64 || streams > STREAMS_ALL as u64 || !fits(cols) || !fits(rows) + { + return ERRNO_INVAL; + } + let caller = current_pid().unwrap_or(0); + if caller == 0 || get_parent_pid(pid as u32) != Some(caller) { + return ERRNO_PERM; + } + tty_table::set(pid as u32, Tty { streams: streams as u8, cols: cols as u16, rows: rows as u16 }); + 0 +} + +/// `MkTtyQuery(fd)`. For the caller's stdin, stdout or stderr on a +/// terminal, its size as `rows << 16 | cols`; ENOTTY for anything else. +pub fn sys_tty_query(fd: u64) -> i64 { + if fd > 2 { + return ERRNO_NOTTY; + } + let Some(pid) = current_pid() else { return ERRNO_NOTTY }; + match tty_table::get(pid) { + Some(t) if t.streams & (1 << fd) != 0 => ((t.rows as i64) << 16) | t.cols as i64, + _ => ERRNO_NOTTY, + } +} diff --git a/src/syscall/microkernel/tty_table.rs b/src/syscall/microkernel/tty_table.rs new file mode 100644 index 0000000000..bdc373813c --- /dev/null +++ b/src/syscall/microkernel/tty_table.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 . + +//! Which of a process's standard streams reach a terminal, and the size of +//! that terminal in cells. Set by the parent that drains the process's +//! output, read by the process, forgotten when it exits. + +use alloc::collections::BTreeMap; +use spin::Mutex; + +/// Bit 0 stdin, bit 1 stdout, bit 2 stderr. +pub const STREAMS_ALL: u8 = 0b111; + +#[derive(Clone, Copy)] +pub struct Tty { + pub streams: u8, + pub cols: u16, + pub rows: u16, +} + +static TABLE: Mutex> = Mutex::new(BTreeMap::new()); + +/// Record what `pid`'s streams are on. No streams at all clears the entry, +/// which is what a stage whose output is redirected to a file gets. +pub fn set(pid: u32, tty: Tty) { + let mut table = TABLE.lock(); + if tty.streams == 0 { + table.remove(&pid); + } else { + table.insert(pid, tty); + } +} + +pub fn get(pid: u32) -> Option { + TABLE.lock().get(&pid).copied() +} + +/// Called as a process is finalized, so a pid reused later starts with no +/// terminal it never had. +pub fn forget(pid: u32) { + TABLE.lock().remove(&pid); +} diff --git a/src/syscall/numbers/defs.rs b/src/syscall/numbers/defs.rs index 54bdf98363..5f96d8f92d 100644 --- a/src/syscall/numbers/defs.rs +++ b/src/syscall/numbers/defs.rs @@ -80,6 +80,8 @@ pub enum SyscallNumber { MkDevRootRequest = tag4(b"MDRQ"), MkDevRootConfirm = tag4(b"MDRC"), MkToolRun = tag4(b"MTRN"), + MkTtySet = tag4(b"MTTY"), + MkTtyQuery = tag4(b"MTTQ"), MkCapGrant = tag4(b"MCGT"), MkCapRevoke = tag4(b"MCRV"), MkCapCheck = tag4(b"MCCK"), @@ -116,6 +118,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 +133,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/userland/capsule_terminal/src/term/grid/mod.rs b/src/userspace/capsule_app_store/mod.rs similarity index 76% rename from userland/capsule_terminal/src/term/grid/mod.rs rename to src/userspace/capsule_app_store/mod.rs index fb9ef8f21f..fe406dcc5c 100644 --- a/userland/capsule_terminal/src/term/grid/mod.rs +++ b/src/userspace/capsule_app_store/mod.rs @@ -14,17 +14,11 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -pub mod absline; -pub mod alt; -pub mod cell; -pub mod erase; -pub mod feed; -pub mod move_cells; -pub mod new; -pub mod put; -pub mod scroll; -pub mod scroll_region; -pub mod scroll_view; -pub mod types; -pub mod view; -pub mod width; +//! The marketplace window, as the kernel spawns it. + +mod embed; +mod spawn; +mod state; + +pub use spawn::spawn_app_store_capsule; +pub use state::shared_state; diff --git a/src/userspace/capsule_app_store/spawn.rs b/src/userspace/capsule_app_store/spawn.rs new file mode 100644 index 0000000000..b50e719c9e --- /dev/null +++ b/src/userspace/capsule_app_store/spawn.rs @@ -0,0 +1,62 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +use super::embed::{ + APP_STORE_ATTESTATION_BYTES, APP_STORE_ELF, APP_STORE_MANIFEST_BYTES, + APP_STORE_NONOS_ID_CERT_BYTES, +}; +use super::state; +use crate::capabilities::Capability; +use crate::kernel_core::process_spawn::capsule_spawn::{ + self, CapsuleSpecVerified, SpawnError, +}; +use crate::security::nonos_id_cert::IdCertVerifyError; +use crate::security::nonos_trust_anchor::{ + decode as decode_trust_anchor, BAKED_TRUST_ANCHOR_POLICY, +}; + +const SERVICE_NAME: &str = "app.store"; +const SERVICE_PORT: u32 = 4940; +const REPLY_INBOX: &str = "endpoint.app.store.reply"; +const REPLY_PORT: u32 = 4941; +const TARGET_TRIPLE: &str = env!("NONOS_USER_TARGET"); + +pub fn spawn_app_store_capsule() -> Result<(), SpawnError> { + let trust_anchor = decode_trust_anchor(BAKED_TRUST_ANCHOR_POLICY) + .map_err(|_| SpawnError::NonosIdCertRejected(IdCertVerifyError::TrustAnchorPolicy))?; + let spec = CapsuleSpecVerified { + name: SERVICE_NAME, + service_port: SERVICE_PORT, + reply_inbox: REPLY_INBOX, + reply_port: REPLY_PORT, + elf: APP_STORE_ELF, + nonos_id_cert_bytes: APP_STORE_NONOS_ID_CERT_BYTES, + manifest_bytes: APP_STORE_MANIFEST_BYTES, + attestation_trailer: APP_STORE_ATTESTATION_BYTES, + target_triple: TARGET_TRIPLE, + // It reads one service, paints, and may ask for an install. + requested_caps: Capability::CoreExec.bit() + | Capability::IPC.bit() + | Capability::Memory.bit() + | Capability::GraphicsDisplayQuery.bit() + | Capability::GraphicsSurfaceCreate.bit() + | Capability::AppInstall.bit(), + debug_tag: b"", + }; + let pid = capsule_spawn::spawn_verified(&spec, &trust_anchor, None)?; + state::set_alive(pid); + Ok(()) +} diff --git a/src/userspace/capsule_app_store/state.rs b/src/userspace/capsule_app_store/state.rs new file mode 100644 index 0000000000..f18bb639bc --- /dev/null +++ b/src/userspace/capsule_app_store/state.rs @@ -0,0 +1,27 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +use crate::services::lifecycle::CapsuleState; + +static STATE: CapsuleState = CapsuleState::new(); + +pub(super) fn set_alive(pid: u32) { + STATE.set_alive(pid); +} + +pub fn shared_state() -> &'static CapsuleState { + &STATE +} diff --git a/src/userspace/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..ece73938db 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, TERMINAL}; 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,40 @@ 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)]) +} + +/// Spawn the personality for the terminal's `linux` command. `argv` is the +/// command as typed: `linux`, the program, then its arguments. +pub fn spawn_terminal(argv: Vec) -> Result { + spawn(&TERMINAL, argv) +} -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..c693e21e95 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, spawn_terminal}; 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..893ff7fb39 --- /dev/null +++ b/src/userspace/capsule_linux/roles.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 . + +//! 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, +}; + +/// The personality the terminal's `linux` command starts, parented to the +/// terminal so the program's output reaches its window through the +/// personality's mirrored stdout. One runs at a time: a second is refused its +/// endpoint while the first holds it. +pub(super) const TERMINAL: Role = Role { + name: "app.linux.term", + port: 5100, + inbox: "endpoint.app.linux.term.reply", + reply_port: 5101, + tag: b"[LINUX-TERM] elf error:", + 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/entry.rs b/src/userspace/init/entry.rs index afb63339f2..44e17a16ba 100644 --- a/src/userspace/init/entry.rs +++ b/src/userspace/init/entry.rs @@ -112,7 +112,7 @@ fn run_tool_selftest() { ]; for (service, argv, label) in TESTS { boot_log::ok("TOOL-SELFTEST run", label); - if crate::userspace::tool_capsules::run_named(service, argv).is_none() { + if crate::userspace::tool_capsules::run_named(service, argv).is_err() { boot_log::error("tool self-test spawn failed"); } // Let the scheduler run the tool to completion before the next one, so 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/src/userspace/tool_capsules/linux_terminal.rs b/src/userspace/tool_capsules/linux_terminal.rs new file mode 100644 index 0000000000..3d0bfefbcd --- /dev/null +++ b/src/userspace/tool_capsules/linux_terminal.rs @@ -0,0 +1,44 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// SPDX-License-Identifier: AGPL-3.0-or-later + +//! `tool.linux`: the terminal's `linux` command runs the Linux personality +//! rather than an embedded tool. + +extern crate alloc; + +use crate::kernel_core::process_spawn::capsule_spawn::SpawnError; +use crate::syscall::microkernel::errnos::{ERRNO_ACCES, ERRNO_EXIST}; + +/// The Linux personality, for the terminal's `linux` command. The program it +/// runs is read from the store and must carry its own proof, so naming one +/// grants nothing an unproven binary could use. +#[cfg(feature = "nonos-capsule-linux")] +pub(super) fn run(argv: &[u8]) -> Result { + /* Every argument as typed, an empty one included. */ + let argv = argv + .split(|&b| b == 0) + .map(|s| alloc::string::String::from_utf8_lossy(s).into_owned()) + .collect(); + crate::userspace::capsule_linux::spawn_terminal(argv).map_err(|e| { + let line = alloc::format!("linux terminal spawn failed: {e:?}"); + crate::sys::boot_log::error(&line); + spawn_errno(&e) + }) +} + +/// A build without the personality has no `linux` to run. +#[cfg(not(feature = "nonos-capsule-linux"))] +pub(super) fn run(_argv: &[u8]) -> Result { + Err(crate::syscall::microkernel::errnos::ERRNO_NOENT) +} + +/// The errno a tool's refused spawn is reported by, as a capsule load reports +/// it: a live instance holding the role's endpoints is EEXIST, which the +/// terminal says is one already running, and any other refusal is EACCES. +pub(super) fn spawn_errno(e: &SpawnError) -> i64 { + match e { + SpawnError::EndpointCollision => ERRNO_EXIST, + _ => ERRNO_ACCES, + } +} diff --git a/src/userspace/tool_capsules/mod.rs b/src/userspace/tool_capsules/mod.rs index d2fbfde391..aa77c37ef5 100644 --- a/src/userspace/tool_capsules/mod.rs +++ b/src/userspace/tool_capsules/mod.rs @@ -11,6 +11,7 @@ #[cfg(feature = "nonos-tool-capsules")] #[macro_use] mod embed_macro; +mod linux_terminal; mod registry; mod spec; diff --git a/src/userspace/tool_capsules/registry.rs b/src/userspace/tool_capsules/registry.rs index 2d197aef0c..fffa12da3c 100644 --- a/src/userspace/tool_capsules/registry.rs +++ b/src/userspace/tool_capsules/registry.rs @@ -109,15 +109,18 @@ fn embedded_tools() -> Vec { /// Run the embedded tool whose service name matches `name`, parented to the /// caller so it can drive the tool's stdin and stdout. `argv` is the NUL -/// separated argument blob. Returns the tool's pid, or `None`. Tools run on +/// separated argument blob. Returns the tool's pid, or why not as an errno: +/// ENOENT for no such tool, EEXIST for one already running. Tools run on /// demand, not at boot: a command-line tool has nothing to do until invoked. -pub fn run_named(name: &[u8], argv: &[u8]) -> Option { - let tool = embedded_tools().into_iter().find(|t| t.name.as_bytes() == name)?; - match tool.spawn_with_args(argv) { - Ok(pid) => Some(pid), - Err(_) => { - boot_log::error("tool capsule spawn failed"); - None - } +pub fn run_named(name: &[u8], argv: &[u8]) -> Result { + if name == b"tool.linux" { + return super::linux_terminal::run(argv); } + let Some(tool) = embedded_tools().into_iter().find(|t| t.name.as_bytes() == name) else { + return Err(crate::syscall::microkernel::errnos::ERRNO_NOENT); + }; + tool.spawn_with_args(argv).map_err(|e| { + boot_log::error("tool capsule spawn failed"); + super::linux_terminal::spawn_errno(&e) + }) } 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/toolchain/nonos-std/README.md b/toolchain/nonos-std/README.md index 46755ac219..3460aa4f29 100644 --- a/toolchain/nonos-std/README.md +++ b/toolchain/nonos-std/README.md @@ -24,6 +24,7 @@ returns `Unsupported` loudly; nothing pretends. | heap (`alloc`) | real | dlmalloc over `MMAP`, spin-locked (thread-safe) | | `println!` / stdout / stderr | real | `MDBG` serial sink, mirrored to `proc.` inbox | | stdin | real | blocking read of this process's kernel stdin channel (`MSRD`), fed by a launcher (the terminal); no EOF-on-close yet | +| `IsTerminal` | real | `MTTQ`: true for a standard stream the launcher said reaches its screen (`MTTY`); a stage feeding a pipe or a file, or a process no terminal started, gets false | | `args` | real | `MKAR` | | env vars | real, process-local | in-process map; nothing is inherited across spawns yet | | `current_dir` | fixed `/` | capsules see the VFS from its root; `chdir` unsupported | diff --git a/toolchain/nonos-std/apply.sh b/toolchain/nonos-std/apply.sh index 3c15a44d09..35b38b0a9b 100755 --- a/toolchain/nonos-std/apply.sh +++ b/toolchain/nonos-std/apply.sh @@ -15,6 +15,7 @@ SYS="$STD/src/sys" # 1. copy the platform modules cp "$HERE/sys/alloc/nonos.rs" "$SYS/alloc/nonos.rs" cp "$HERE/sys/io/error/nonos.rs" "$SYS/io/error/nonos.rs" +cp "$HERE/sys/io/is_terminal/nonos.rs" "$SYS/io/is_terminal/nonos.rs" cp "$HERE/sys/random/nonos.rs" "$SYS/random/nonos.rs" cp "$HERE/sys/stdio/nonos.rs" "$SYS/stdio/nonos.rs" cp "$HERE/sys/args/nonos.rs" "$SYS/args/nonos.rs" @@ -72,6 +73,11 @@ ARM_PAL = ' target_vendor = "nonos" => {\n mod nonos;\n pub use insert_before(f"{sysdir}/alloc/mod.rs", ' any(\n target_family = "unix",', ARM_BARE, 'mod nonos') insert_before(f"{sysdir}/io/error/mod.rs", ' target_os = "hermit" => {', ARM, 'target_vendor = "nonos"') +# IsTerminal: the arm sits inside `mod is_terminal`, one level deeper. +ARM_TTY = (' target_vendor = "nonos" => {\n mod nonos;\n' + ' pub use nonos::*;\n }\n') +insert_before(f"{sysdir}/io/mod.rs", ' target_os = "hermit" => {\n mod hermit;', ARM_TTY, + 'target_vendor = "nonos" => {\n mod nonos;') insert_before(f"{sysdir}/random/mod.rs", ' // Tier 1\n', ARM_FILL, 'target_vendor = "nonos"') insert_before(f"{sysdir}/stdio/mod.rs", ' any(target_family = "unix"', ARM, 'target_vendor = "nonos"') insert_before(f"{sysdir}/fs/mod.rs", ' any(target_family = "unix", target_os = "wasi") => {', ARM_IMP, 'target_vendor = "nonos"') diff --git a/toolchain/nonos-std/sys/io/is_terminal/nonos.rs b/toolchain/nonos-std/sys/io/is_terminal/nonos.rs new file mode 100644 index 0000000000..ee68ed84b8 --- /dev/null +++ b/toolchain/nonos-std/sys/io/is_terminal/nonos.rs @@ -0,0 +1,33 @@ +// NONOS std PAL: IsTerminal asks the kernel (MTTQ) whether this process's +// stdin, stdout or stderr reaches a terminal. The launcher that renders the +// process's output says so when it starts it, and says nothing for a stage +// whose output feeds a pipe or a file, so a program picks colour and columns +// for a person and plain bytes for a pipe, as it would on any other system. + +use crate::os::fd::{AsFd, AsRawFd}; + +const fn tag4(b: &[u8; 4]) -> i64 { + (b[0] as i64) | ((b[1] as i64) << 8) | ((b[2] as i64) << 16) | ((b[3] as i64) << 24) +} + +const N_MK_TTY_QUERY: i64 = tag4(b"MTTQ"); + +pub fn is_terminal(fd: &impl AsFd) -> bool { + let fd = fd.as_fd().as_raw_fd(); + // Only the three standard streams can reach a terminal; files and + // sockets sit in the descriptor table above them. + if !(0..=2).contains(&fd) { + return false; + } + let r: i64; + unsafe { + core::arch::asm!( + "syscall", + inout("rax") N_MK_TTY_QUERY => r, + in("rdi") fd as u64, + out("rcx") _, + out("r11") _, + ); + } + r >= 0 +} 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..fcaadf448e 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 = 48 * 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/app/behavior.rs b/userland/app_skeleton/src/app/behavior.rs index 32d84781cb..f67e25b269 100644 --- a/userland/app_skeleton/src/app/behavior.rs +++ b/userland/app_skeleton/src/app/behavior.rs @@ -58,4 +58,12 @@ pub trait App { fn on_accessory_event(&mut self, _event: InputEvent) -> EventOutcome { EventOutcome::Idle } + + /// Asked when the window's close button is pressed. An app holding work + /// the user would lose returns false, says so in its own window, and the + /// window stays open; pressing close again is the user's answer. Defaults + /// to closing. + fn close_requested(&mut self) -> bool { + true + } } 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/input/keys.rs b/userland/app_skeleton/src/input/keys.rs index 5023b38b6e..d4c8573f2a 100644 --- a/userland/app_skeleton/src/input/keys.rs +++ b/userland/app_skeleton/src/input/keys.rs @@ -29,4 +29,8 @@ pub const KEY_HOME: u32 = 0x1205; pub const KEY_END: u32 = 0x1206; pub const KEY_PAGE_UP: u32 = 0x1207; pub const KEY_PAGE_DOWN: u32 = 0x1208; +pub const KEY_INSERT: u32 = 0x1209; pub const KEY_DELETE: u32 = 0x120A; +/// F1; F2 to F12 follow in order. +pub const KEY_F1: u32 = 0x1101; +pub const KEY_F12: u32 = 0x110C; diff --git a/userland/app_skeleton/src/input/mod.rs b/userland/app_skeleton/src/input/mod.rs index 410bd6d17e..14d61840eb 100644 --- a/userland/app_skeleton/src/input/mod.rs +++ b/userland/app_skeleton/src/input/mod.rs @@ -22,9 +22,8 @@ mod modifiers; mod pointer; pub use event::InputEvent; -pub use keys::{ - KEY_BACKSPACE, KEY_DELETE, KEY_DOWN, KEY_END, KEY_ENTER, KEY_ESC, KEY_HOME, KEY_LEFT, - KEY_PAGE_DOWN, KEY_PAGE_UP, KEY_RIGHT, KEY_TAB, KEY_UP, -}; +pub use keys::{KEY_BACKSPACE, KEY_DELETE, KEY_DOWN, KEY_END, KEY_ENTER, KEY_ESC, KEY_HOME}; +pub use keys::{KEY_F1, KEY_F12, KEY_INSERT}; +pub use keys::{KEY_LEFT, KEY_PAGE_DOWN, KEY_PAGE_UP, KEY_RIGHT, KEY_TAB, KEY_UP}; pub use kind::InputKind; pub use modifiers::{MOD_ALT, MOD_ALTGR, MOD_CAPS, MOD_CTRL, MOD_META, MOD_NUM, MOD_SHIFT}; diff --git a/userland/app_skeleton/src/lib.rs b/userland/app_skeleton/src/lib.rs index c494c98bc9..c6ed3d5418 100644 --- a/userland/app_skeleton/src/lib.rs +++ b/userland/app_skeleton/src/lib.rs @@ -32,8 +32,8 @@ pub use app::{App, AppManifest, EventOutcome, WindowKind}; pub use clients::clipboard::{clipboard_copy, clipboard_paste}; pub use input::{ InputEvent, InputKind, KEY_BACKSPACE, KEY_DELETE, KEY_DOWN, KEY_END, KEY_ENTER, KEY_ESC, - KEY_HOME, KEY_LEFT, KEY_PAGE_DOWN, KEY_PAGE_UP, KEY_RIGHT, KEY_TAB, KEY_UP, MOD_ALT, MOD_CAPS, - MOD_CTRL, MOD_META, MOD_NUM, MOD_SHIFT, + KEY_F1, KEY_F12, KEY_HOME, KEY_INSERT, KEY_LEFT, KEY_PAGE_DOWN, KEY_PAGE_UP, KEY_RIGHT, + KEY_TAB, KEY_UP, MOD_ALT, MOD_ALTGR, MOD_CAPS, MOD_CTRL, MOD_META, MOD_NUM, MOD_SHIFT, }; pub use paint::font_advance; pub use paint::PaintBuffer; diff --git a/userland/app_skeleton/src/runner/drain_ipc.rs b/userland/app_skeleton/src/runner/drain_ipc.rs index a0604063cc..234269ea69 100644 --- a/userland/app_skeleton/src/runner/drain_ipc.rs +++ b/userland/app_skeleton/src/runner/drain_ipc.rs @@ -70,9 +70,21 @@ 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) { + /* + * An app with work the user would lose keeps its window and says + * why; the next press on close is the answer. + */ + Some(EventOutcome::Close) if !app.close_requested() => { + repaint = true; + continue; + } Some(EventOutcome::Close) => { return DrainResult { repaint, diff --git a/userland/arch_paging_proofs/src/descriptor_tests.rs b/userland/arch_paging_proofs/src/descriptor_tests.rs index 3cc85dddd8..c6c278ed72 100644 --- a/userland/arch_paging_proofs/src/descriptor_tests.rs +++ b/userland/arch_paging_proofs/src/descriptor_tests.rs @@ -87,6 +87,15 @@ macro_rules! properties_for { } } + /// An interior entry grants user access exactly when it was asked + /// to. On aarch64 that is APTable[0], a bit the encoder once left + /// clear on every table, so every interior entry granted EL0. + #[test] + fn table_grants_user_exactly_when_asked() { + assert!(d::table_grants_user(d::table(PA, true))); + assert!(!d::table_grants_user(d::table(PA, false))); + } + /// A kernel mapping is never reachable from EL0. This is the bit /// whose polarity was inverted, and the failure mode is the whole /// kernel readable from userspace. diff --git a/userland/arch_paging_proofs/src/kani_proofs.rs b/userland/arch_paging_proofs/src/kani_proofs.rs index 6162047641..946fb2690e 100644 --- a/userland/arch_paging_proofs/src/kani_proofs.rs +++ b/userland/arch_paging_proofs/src/kani_proofs.rs @@ -81,6 +81,17 @@ macro_rules! harnesses_for { assert!(!d::is_block(entry)); assert_eq!(d::address(entry), pa & d::ADDR_MASK); } + + /// For every address, an interior entry grants user access if and + /// only if it was built to. The walk and the usercopy check both + /// read this bit, so a table that granted by default would open + /// every subtree below it to user mode. + #[kani::proof] + fn tables_grant_user_exactly_when_asked() { + let pa: u64 = kani::any(); + let user: bool = kani::any(); + assert_eq!(d::table_grants_user(d::table(pa, user)), user); + } } }; } 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/attest_doc_proofs/src/lib.rs b/userland/attest_doc_proofs/src/lib.rs index 803a62e07e..ea4d2f59a7 100644 --- a/userland/attest_doc_proofs/src/lib.rs +++ b/userland/attest_doc_proofs/src/lib.rs @@ -7,8 +7,17 @@ //! did not check is a document an attacker gets to shape. These drive it with //! the malformed cases rather than the happy one. +extern crate alloc; + /// The real parser, unchanged. pub mod doc_parse; +/// The kernel's encoder, unchanged. The tests build every document with it, so +/// a layout the kernel writes and the parser does not read fails here instead +/// of on the machine. +#[cfg(test)] +#[path = "../../../src/security/attest_doc/document.rs"] +mod kernel_document; + #[cfg(test)] mod parse_tests; diff --git a/userland/attest_doc_proofs/src/parse_tests.rs b/userland/attest_doc_proofs/src/parse_tests.rs index c8e3e30787..e8030f09b9 100644 --- a/userland/attest_doc_proofs/src/parse_tests.rs +++ b/userland/attest_doc_proofs/src/parse_tests.rs @@ -2,24 +2,47 @@ //! What the attestation document parser must refuse. use crate::doc_parse::parse; +use crate::kernel_document::{AttestationDoc, DOC_VERSION, IOMMU_INTEL_VTD}; const CHALLENGE: [u8; 32] = [7u8; 32]; -/// Build a document the way the kernel encodes one: magic, version, challenge, -/// registry root, capsule count, the completeness byte, then the two -/// length-prefixed blobs. Big-endian throughout, matching the TPM structures. +/// Where the completeness byte sits, for the tests that write a byte the kernel +/// never would. +const COMPLETE_AT: usize = 8 + 4 + 32 + 32 + 4; +const VENDOR_AT: usize = COMPLETE_AT + 1; +const ENFORCING_AT: usize = VENDOR_AT + 1; +const ATTEST_LEN_AT: usize = ENFORCING_AT + 1 + 4; + +/// A document exactly as the kernel encodes one, from the kernel's own encoder. +fn encoded( + challenge: &[u8; 32], + attest: &[u8], + sig: &[u8], + count: u32, + vendor: u8, + enforcing: bool, + grants: u32, +) -> Vec { + AttestationDoc { + challenge: *challenge, + registry_root: [0xAB; 32], + capsule_count: count, + registry_complete: true, + iommu_vendor: vendor, + iommu_enforcing: enforcing, + unconfined_grants: grants, + attest: attest.to_vec(), + signature: sig.to_vec(), + ak_public: [0xCD; 64], + } + .encode() +} + +/// The same, with the completeness byte set to `complete` rather than a bool, +/// so the tests can write the bytes the kernel never does. fn doc(challenge: &[u8; 32], attest: &[u8], sig: &[u8], complete: u8, count: u32) -> Vec { - let mut v = Vec::new(); - v.extend_from_slice(b"NONOSATT"); - v.extend_from_slice(&1u32.to_be_bytes()); - v.extend_from_slice(challenge); - v.extend_from_slice(&[0xAB; 32]); - v.extend_from_slice(&count.to_be_bytes()); - v.push(complete); - v.extend_from_slice(&(attest.len() as u32).to_be_bytes()); - v.extend_from_slice(attest); - v.extend_from_slice(&(sig.len() as u32).to_be_bytes()); - v.extend_from_slice(sig); + let mut v = encoded(challenge, attest, sig, count, IOMMU_INTEL_VTD, true, 13); + v[COMPLETE_AT] = complete; v } @@ -74,7 +97,7 @@ fn wrong_magic_is_refused() { #[test] fn an_unknown_version_is_refused() { let mut d = good(); - d[8..12].copy_from_slice(&2u32.to_be_bytes()); + d[8..12].copy_from_slice(&(DOC_VERSION + 1).to_be_bytes()); assert!(parse(&d, &CHALLENGE).is_none()); } @@ -103,7 +126,7 @@ fn trailing_bytes_are_refused() { #[test] fn an_overlong_attest_length_is_refused_and_does_not_panic() { let mut d = good(); - let at = 8 + 4 + 32 + 32 + 4 + 1; + let at = ATTEST_LEN_AT; for claimed in [u32::MAX, 0x7FFF_FFFF, 5000, d.len() as u32] { d[at..at + 4].copy_from_slice(&claimed.to_be_bytes()); assert!(parse(&d, &CHALLENGE).is_none(), "attest_len {claimed} was accepted"); @@ -113,7 +136,7 @@ fn an_overlong_attest_length_is_refused_and_does_not_panic() { #[test] fn an_overlong_signature_length_is_refused() { let mut d = good(); - let sig_at = 8 + 4 + 32 + 32 + 4 + 1 + 4 + 4; + let sig_at = ATTEST_LEN_AT + 4 + 4; d[sig_at..sig_at + 4].copy_from_slice(&u32::MAX.to_be_bytes()); assert!(parse(&d, &CHALLENGE).is_none()); } @@ -135,3 +158,51 @@ fn arbitrary_noise_is_refused() { assert!(parse(&noise, &CHALLENGE).is_none(), "{len} bytes of noise parsed"); } } + +/// The DMA posture comes through as the kernel wrote it: which IOMMU, whether it +/// enforces, and how many mappings go around it. +#[test] +fn the_dma_posture_is_read_as_written() { + let d = parse(&good(), &CHALLENGE).unwrap(); + assert_eq!(d.iommu_vendor, IOMMU_INTEL_VTD); + assert!(d.iommu_enforcing); + assert_eq!(d.unconfined_grants, 13); + let none = encoded(&CHALLENGE, &[1], &[9; 8], 3, 0, false, 0); + let d = parse(&none, &CHALLENGE).unwrap(); + assert_eq!((d.iommu_vendor, d.iommu_enforcing, d.unconfined_grants), (0, false, 0)); +} + +/// A vendor the parser does not know is a layout it would be guessing at. +#[test] +fn an_unknown_vendor_is_refused() { + for vendor in [3u8, 0x7F, 0xFF] { + let mut d = good(); + d[VENDOR_AT] = vendor; + assert!(parse(&d, &CHALLENGE).is_none(), "vendor {vendor} was accepted"); + } +} + +/// As with completeness, only a 1 says the unit enforces. +#[test] +fn an_enforcing_byte_that_is_not_one_is_not_yes() { + for byte in [0u8, 2, 0xFF] { + let mut d = good(); + d[ENFORCING_AT] = byte; + let d = parse(&d, &CHALLENGE).unwrap(); + assert!(!d.iommu_enforcing, "byte {byte} must not read as enforcing"); + } +} + +/// The key is a P-256 point, 64 bytes. A key blob of any other length is a +/// document the parser does not understand. +#[test] +fn a_key_of_any_other_length_is_refused() { + let d = good(); + let key_len_at = d.len() - 64 - 4; + assert_eq!(&d[key_len_at..key_len_at + 4], &64u32.to_be_bytes()); + for claimed in [0u32, 63, 65, u32::MAX] { + let mut v = d.clone(); + v[key_len_at..key_len_at + 4].copy_from_slice(&claimed.to_be_bytes()); + assert!(parse(&v, &CHALLENGE).is_none(), "key length {claimed} was accepted"); + } +} diff --git a/userland/attest_key_proofs/Cargo.lock b/userland/attest_key_proofs/Cargo.lock index 8b5e96be2b..005b40d78e 100644 --- a/userland/attest_key_proofs/Cargo.lock +++ b/userland/attest_key_proofs/Cargo.lock @@ -2,6 +2,77 @@ # 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 = "attest_key_proofs" version = "0.1.0" +dependencies = [ + "blake3", +] + +[[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 = "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.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" diff --git a/userland/attest_key_proofs/Cargo.toml b/userland/attest_key_proofs/Cargo.toml index c99b714403..5483d87b63 100644 --- a/userland/attest_key_proofs/Cargo.toml +++ b/userland/attest_key_proofs/Cargo.toml @@ -13,3 +13,8 @@ license = "AGPL-3.0-or-later" [lib] path = "src/lib.rs" + +# The kernel's quote binding hashes with BLAKE3; the tests compile that file +# unchanged, so they need the same crate. +[dev-dependencies] +blake3 = { version = "1.5", default-features = false } diff --git a/userland/attest_key_proofs/src/binding_tests.rs b/userland/attest_key_proofs/src/binding_tests.rs new file mode 100644 index 0000000000..33ac5f5725 --- /dev/null +++ b/userland/attest_key_proofs/src/binding_tests.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 DMA posture is inside what the TPM signs. +//! +//! The quote covers the PCRs and the qualifying data, nothing else. A field of +//! the document that does not change the qualifying data could be edited after +//! the quote without breaking the signature, so each part of the posture must. + +use crate::security::attest_doc::binding::{qualifying_data, DmaPosture}; + +const CHALLENGE: [u8; 32] = [7; 32]; +const ROOT: [u8; 32] = [0xAB; 32]; + +fn posture() -> DmaPosture { + DmaPosture { vendor: 1, enforcing: true, unconfined_grants: 13 } +} + +#[test] +fn the_same_inputs_bind_to_the_same_value() { + assert_eq!( + qualifying_data(&CHALLENGE, &ROOT, &posture()), + qualifying_data(&CHALLENGE, &ROOT, &posture()) + ); +} + +#[test] +fn every_part_of_the_posture_changes_what_is_signed() { + let base = qualifying_data(&CHALLENGE, &ROOT, &posture()); + let variants = [ + DmaPosture { unconfined_grants: 0, ..posture() }, + DmaPosture { unconfined_grants: 12, ..posture() }, + DmaPosture { unconfined_grants: u32::MAX, ..posture() }, + DmaPosture { enforcing: false, ..posture() }, + DmaPosture { vendor: 0, ..posture() }, + DmaPosture { vendor: 2, ..posture() }, + ]; + for v in variants { + assert_ne!( + base, + qualifying_data(&CHALLENGE, &ROOT, &v), + "{v:?} binds like {:?}", + posture() + ); + } +} + +#[test] +fn the_challenge_and_the_root_still_change_what_is_signed() { + let base = qualifying_data(&CHALLENGE, &ROOT, &posture()); + assert_ne!(base, qualifying_data(&[8; 32], &ROOT, &posture())); + assert_ne!(base, qualifying_data(&CHALLENGE, &[0xAC; 32], &posture())); +} diff --git a/userland/attest_key_proofs/src/document_tests.rs b/userland/attest_key_proofs/src/document_tests.rs index ec41588ef4..dceee52036 100644 --- a/userland/attest_key_proofs/src/document_tests.rs +++ b/userland/attest_key_proofs/src/document_tests.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 . -//! The document carries the key last, under version two. +//! The document carries the DMA posture after the completeness byte and the +//! key last, under version three. -use crate::security::attest_doc::document::{AttestationDoc, DOC_VERSION}; +use crate::security::attest_doc::document::{AttestationDoc, DOC_VERSION, IOMMU_INTEL_VTD}; fn doc() -> AttestationDoc { AttestationDoc { @@ -24,23 +25,38 @@ fn doc() -> AttestationDoc { registry_root: [0xAB; 32], capsule_count: 3, registry_complete: true, + iommu_vendor: IOMMU_INTEL_VTD, + iommu_enforcing: true, + unconfined_grants: 13, attest: vec![1, 2, 3, 4], signature: vec![9; 72], ak_public: [0xCD; 64], } } +/// Where the DMA posture ends and the length-prefixed blobs begin. +const ATTEST_LEN_AT: usize = 8 + 4 + 32 + 32 + 4 + 1 + 1 + 1 + 4; + +#[test] +fn the_version_is_three_because_the_layout_grew() { + assert_eq!(DOC_VERSION, 3); + let b = doc().encode(); + assert_eq!(&b[8..12], &3u32.to_be_bytes()); +} + #[test] -fn the_version_is_two_because_the_layout_grew() { - assert_eq!(DOC_VERSION, 2); +fn the_dma_posture_follows_the_completeness_byte() { let b = doc().encode(); - assert_eq!(&b[8..12], &2u32.to_be_bytes()); + assert_eq!(b[81], IOMMU_INTEL_VTD); + assert_eq!(b[82], 1); + assert_eq!(&b[83..87], &13u32.to_be_bytes()); + assert_eq!(&b[ATTEST_LEN_AT..ATTEST_LEN_AT + 4], &4u32.to_be_bytes()); } #[test] fn the_key_is_the_last_blob_and_the_document_ends_with_it() { let b = doc().encode(); - let sig_len_at = 8 + 4 + 32 + 32 + 4 + 1 + 4 + 4; + let sig_len_at = ATTEST_LEN_AT + 4 + 4; let key_len_at = sig_len_at + 4 + 72; assert_eq!(&b[key_len_at..key_len_at + 4], &64u32.to_be_bytes()); assert_eq!(&b[key_len_at + 4..], &[0xCD; 64]); @@ -48,7 +64,7 @@ fn the_key_is_the_last_blob_and_the_document_ends_with_it() { } #[test] -fn everything_before_the_key_is_where_version_one_put_it() { +fn everything_before_the_posture_is_where_version_one_put_it() { let b = doc().encode(); assert_eq!(&b[..8], b"NONOSATT"); assert_eq!(&b[44..76], &[0xAB; 32]); diff --git a/userland/attest_key_proofs/src/lib.rs b/userland/attest_key_proofs/src/lib.rs index 23624ea840..9800f3b90d 100644 --- a/userland/attest_key_proofs/src/lib.rs +++ b/userland/attest_key_proofs/src/lib.rs @@ -22,6 +22,8 @@ extern crate alloc; pub mod security; +#[cfg(test)] +mod binding_tests; #[cfg(test)] mod document_tests; #[cfg(test)] diff --git a/userland/attest_key_proofs/src/security/attest_doc/mod.rs b/userland/attest_key_proofs/src/security/attest_doc/mod.rs index fbf96cc6fb..7d65dff541 100644 --- a/userland/attest_key_proofs/src/security/attest_doc/mod.rs +++ b/userland/attest_key_proofs/src/security/attest_doc/mod.rs @@ -14,5 +14,8 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . +#[cfg(test)] +#[path = "../../../../../src/security/attest_doc/binding.rs"] +pub mod binding; #[path = "../../../../../src/security/attest_doc/document.rs"] pub mod document; diff --git a/userland/capsule_about/src/about/data/doc_parse/layout.rs b/userland/capsule_about/src/about/data/doc_parse/layout.rs index 0c9972b8f1..f75d595ced 100644 --- a/userland/capsule_about/src/about/data/doc_parse/layout.rs +++ b/userland/capsule_about/src/about/data/doc_parse/layout.rs @@ -18,7 +18,7 @@ // Big-endian throughout, matching the TPM structures the document carries. pub(super) const MAGIC: &[u8; 8] = b"NONOSATT"; -pub(super) const VERSION: u32 = 1; +pub(super) const VERSION: u32 = 3; // A TPMS_ATTEST with an ECDSA signature lands well inside this. The syscall // refuses rather than truncating if it does not fit, so a short read here is @@ -28,4 +28,13 @@ pub const DOC_CAP: usize = 2048; pub(super) const ROOT_AT: usize = 8 + 4 + 32; pub(super) const COUNT_AT: usize = ROOT_AT + 32; pub(super) const COMPLETE_AT: usize = COUNT_AT + 4; -pub(super) const ATTEST_LEN_AT: usize = COMPLETE_AT + 1; +pub(super) const VENDOR_AT: usize = COMPLETE_AT + 1; +pub(super) const ENFORCING_AT: usize = VENDOR_AT + 1; +pub(super) const UNCONFINED_AT: usize = ENFORCING_AT + 1; +pub(super) const ATTEST_LEN_AT: usize = UNCONFINED_AT + 4; + +/// Highest `iommu_vendor` value the kernel writes: none, VT-d, AMD-Vi. +pub(super) const VENDOR_MAX: u8 = 2; + +/// The attestation key closes the document: an uncompressed P-256 point, x then y. +pub(super) const KEY_LEN: u32 = 64; diff --git a/userland/capsule_about/src/about/data/doc_parse/parse.rs b/userland/capsule_about/src/about/data/doc_parse/parse.rs index e5d44182f6..8d9ac08e05 100644 --- a/userland/capsule_about/src/about/data/doc_parse/parse.rs +++ b/userland/capsule_about/src/about/data/doc_parse/parse.rs @@ -16,7 +16,10 @@ //! Reading one, strictly. -use super::layout::{ATTEST_LEN_AT, COMPLETE_AT, COUNT_AT, MAGIC, ROOT_AT, VERSION}; +use super::layout::{ + ATTEST_LEN_AT, COMPLETE_AT, COUNT_AT, ENFORCING_AT, KEY_LEN, MAGIC, ROOT_AT, UNCONFINED_AT, + VENDOR_AT, VENDOR_MAX, VERSION, +}; use super::types::Doc; // Strict: every length is checked against what is actually there before it is @@ -34,11 +37,21 @@ pub fn parse(b: &[u8], sent: &[u8; 32]) -> Option { registry_root.copy_from_slice(b.get(ROOT_AT..ROOT_AT + 32)?); let challenge_echoed = b.get(12..12 + 32)? == sent; + // A vendor this parser does not know is a layout it would be guessing at. + let iommu_vendor = *b.get(VENDOR_AT)?; + if iommu_vendor > VENDOR_MAX { + return None; + } + let attest_len = be32(b, ATTEST_LEN_AT)?; - let sig_len_at = ATTEST_LEN_AT + 4 + attest_len as usize; + let sig_len_at = ATTEST_LEN_AT.checked_add(4)?.checked_add(attest_len as usize)?; let signature_len = be32(b, sig_len_at)?; + let key_len_at = sig_len_at.checked_add(4)?.checked_add(signature_len as usize)?; + if be32(b, key_len_at)? != KEY_LEN { + return None; + } // The document must end exactly where its own lengths say it does. - if sig_len_at + 4 + signature_len as usize != b.len() { + if key_len_at + 4 + KEY_LEN as usize != b.len() { return None; } @@ -46,6 +59,9 @@ pub fn parse(b: &[u8], sent: &[u8; 32]) -> Option { registry_root, capsule_count: be32(b, COUNT_AT)?, registry_complete: *b.get(COMPLETE_AT)? == 1, + iommu_vendor, + iommu_enforcing: *b.get(ENFORCING_AT)? == 1, + unconfined_grants: be32(b, UNCONFINED_AT)?, challenge_echoed, attest_len, signature_len, diff --git a/userland/capsule_about/src/about/data/doc_parse/types.rs b/userland/capsule_about/src/about/data/doc_parse/types.rs index dd3e71d08e..bc2e196821 100644 --- a/userland/capsule_about/src/about/data/doc_parse/types.rs +++ b/userland/capsule_about/src/about/data/doc_parse/types.rs @@ -22,6 +22,14 @@ pub struct Doc { /// Clear once any running capsule could not be recorded, which is the /// machine saying it no longer knows everything it is running. pub registry_complete: bool, + /// 0 no IOMMU, 1 Intel VT-d, 2 AMD-Vi. + pub iommu_vendor: u8, + /// Whether the IOMMU translates with the kernel's tables. + pub iommu_enforcing: bool, + /// Mappings a device could reach with no IOMMU domain confining them. With + /// enforcing set and this above zero, the unit is in service and DMA still + /// goes around it. + pub unconfined_grants: u32, /// Whether the challenge came back unchanged. This is the anti-replay check /// and the one part of the document this capsule can verify on its own. pub challenge_echoed: bool, diff --git a/userland/capsule_about/src/about/ui/screens/verify_doc.rs b/userland/capsule_about/src/about/ui/screens/verify_doc.rs index 083a2d1c41..17ee51ca36 100644 --- a/userland/capsule_about/src/about/ui/screens/verify_doc.rs +++ b/userland/capsule_about/src/about/ui/screens/verify_doc.rs @@ -26,7 +26,7 @@ use super::super::kv::ROW_H; use super::super::metrics::{BODY_PX, CARD_PAD, PAIR_H}; use super::super::text::{line, top_of}; -const ROWS: u32 = 3; +const ROWS: u32 = 4; pub const HEIGHT: u32 = card::OVERHEAD + ROW_H + PAIR_H + ROW_H * ROWS + 4; diff --git a/userland/capsule_about/src/about/ui/screens/verify_doc_body.rs b/userland/capsule_about/src/about/ui/screens/verify_doc_body.rs index 3e647a81bf..2c3f4921ca 100644 --- a/userland/capsule_about/src/about/ui/screens/verify_doc_body.rs +++ b/userland/capsule_about/src/about/ui/screens/verify_doc_body.rs @@ -58,5 +58,5 @@ pub(super) fn body(fb: &mut PaintBuffer, top: i32, inner: u32, doc: &Doc) { u64_decimal(doc.attest_len as u64, &mut signed), u64_decimal(doc.signature_len as u64, &mut sig), ); - kv(fb, CARD_PAD, rows_y + (ROW_H * 2) as i32, inner, b"Covered by the key", &cell[..n], true); + kv(fb, CARD_PAD, rows_y + (ROW_H * 3) as i32, inner, b"Covered by the key", &cell[..n], true); } diff --git a/userland/capsule_about/src/about/ui/screens/verify_doc_rows.rs b/userland/capsule_about/src/about/ui/screens/verify_doc_rows.rs index 3b5b85b120..8b420bf19a 100644 --- a/userland/capsule_about/src/about/ui/screens/verify_doc_rows.rs +++ b/userland/capsule_about/src/about/ui/screens/verify_doc_rows.rs @@ -14,16 +14,19 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -//! The two claims the document settles on its own. +//! The three claims the document settles on its own. //! //! The challenge echo is the anti-replay check and the only part of the document //! this capsule can verify without the key; the completeness flag is the machine -//! saying whether it still knows everything it is running. +//! saying whether it still knows everything it is running; the DMA row says +//! whether device DMA is held by the IOMMU, which needs the unit enforcing and +//! no mapping going around it, not the first alone. use nonos_app_skeleton::PaintBuffer; use crate::about::data::doc_parse::Doc; use crate::about::data::verify::Verdict; +use crate::about::format::u64_decimal; use super::super::kv::ROW_H; use super::super::metrics::CARD_PAD; @@ -48,4 +51,24 @@ pub(super) fn rows(fb: &mut PaintBuffer, rows_y: i32, inner: u32, doc: &Doc) { b"machine recorded everything it runs", b"", ); + let mut digits = [0u8; 20]; + let mut ev = [0u8; 40]; + let n = unconfined(&mut ev, u64_decimal(u64::from(doc.unconfined_grants), &mut digits)); + row( + fb, + CARD_PAD, + rows_y + (ROW_H * 2) as i32, + inner, + Verdict::from_bool(doc.iommu_enforcing && doc.unconfined_grants == 0), + b"device DMA held by the IOMMU", + &ev[..n], + ); +} + +fn unconfined(out: &mut [u8; 40], count: &[u8]) -> usize { + const SUFFIX: &[u8] = b" unconfined"; + let n = count.len().min(out.len() - SUFFIX.len()); + out[..n].copy_from_slice(&count[..n]); + out[n..n + SUFFIX.len()].copy_from_slice(SUFFIX); + n + SUFFIX.len() } 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_linux/src/linux/install/provenance.rs b/userland/capsule_app_store/src/store/event_tab.rs similarity index 69% rename from userland/capsule_linux/src/linux/install/provenance.rs rename to userland/capsule_app_store/src/store/event_tab.rs index 3750b5ccd9..0bd9530c55 100644 --- a/userland/capsule_linux/src/linux/install/provenance.rs +++ b/userland/capsule_app_store/src/store/event_tab.rs @@ -13,14 +13,12 @@ // // 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. -//! Where a package's bytes came from, and whether anything vouches for them -//! arriving intact. +use super::state::{State, TABS}; -#[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, +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_driver_e1000/src/setup/irq.rs b/userland/capsule_app_store/src/store/ui/wrap.rs similarity index 51% rename from userland/capsule_driver_e1000/src/setup/irq.rs rename to userland/capsule_app_store/src/store/ui/wrap.rs index 6934759091..a8b44318b7 100644 --- a/userland/capsule_driver_e1000/src/setup/irq.rs +++ b/userland/capsule_app_store/src/store/ui/wrap.rs @@ -13,23 +13,30 @@ // // 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. -//! IRQ phase. Bind the device's INTx line to a broker IRQ slot. -//! On failure the prior MMIO grant is unmapped and the device -//! claim released so the broker is never left holding a partial -//! setup. MSI-X migration is a separate slice. +use alloc::vec::Vec; -use nonos_libc::{mk_device_release, mk_irq_bind, mk_mmio_unmap, IrqBindOut, MmioMapOut}; +use super::text::width_of; -use crate::discover::Found; - -pub fn bind(dev: Found, claim_epoch: u64, mmio: &MmioMapOut) -> Result { - let mut out = IrqBindOut { grant_id: 0, vector: 0 }; - let r = mk_irq_bind(dev.device_id, claim_epoch, dev.irq_line as u32, 0, 0, &mut out); - if r < 0 { - let _ = mk_mmio_unmap(mmio.grant_id); - let _ = mk_device_release(dev.device_id); - return Err("irq bind failed"); +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); } - Ok(out) + 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_e1000/Capsule.mk b/userland/capsule_driver_e1000/Capsule.mk index 7ba1f5b022..611958a84e 100644 --- a/userland/capsule_driver_e1000/Capsule.mk +++ b/userland/capsule_driver_e1000/Capsule.mk @@ -1,4 +1,4 @@ -# e1000 — Intel 8254x gigabit NIC. PCI MMIO + INTx + DMA with +# e1000 — Intel 8254x gigabit NIC. PCI MMIO + DMA, polled, with # separate RX and TX rings (four DMA grants total). Frame-level # transport over IPC; no socket or routing policy. `Network` cap # is intentionally absent — that authority belongs to a future @@ -15,11 +15,12 @@ CAPSULE_FEATURE := nonos-capsule-driver-e1000 CAPSULE_NAMESPACE := systems.nonos.driver.e1000_0 CAPSULE_SERVICE_ENDPOINT := service:4210:driver.e1000_0 CAPSULE_REPLY_ENDPOINT := reply:4211:endpoint.4294967308 -# IPC|Memory|Crypto|Driver|DeviceEnum|Mmio|Irq|Dma = 0xF8039 +# IPC|Memory|Crypto|Driver|DeviceEnum|Mmio|Dma = 0xB8039. No Irq: the driver +# polls and binds no line. # Crypto (0x20) is what the CryptoRandom syscall is gated on. The station address # is drawn rather than read out of the EEPROM, and that draw fails closed, so # without this the card has no address to transmit under. -CAPSULE_REQUIRED_CAPS := 0xF8039 +CAPSULE_REQUIRED_CAPS := 0xB8039 CAPSULE_KERNEL_MIRROR := src/hardware/e1000_capsule include nonos-mk/capsule.mk diff --git a/userland/capsule_driver_e1000/src/constants/frame.rs b/userland/capsule_driver_e1000/src/constants/frame.rs index d56622a152..cd258ba70c 100644 --- a/userland/capsule_driver_e1000/src/constants/frame.rs +++ b/userland/capsule_driver_e1000/src/constants/frame.rs @@ -27,5 +27,8 @@ const ETH_HEADER_LEN: usize = 14; const MTU: usize = 1500; pub const MAC_LEN: usize = 6; -pub const MIN_ETHERNET_FRAME: usize = 60; +/// A bare header is the shortest frame taken. TCTL.PSP has the part pad +/// anything under 60 bytes, and an ARP (42) or a bare TCP ACK (54) is shorter +/// than that: refusing them stranded IPv4 right after DHCP. +pub const MIN_ETHERNET_FRAME: usize = ETH_HEADER_LEN; pub const MAX_ETHERNET_FRAME: usize = MTU + ETH_HEADER_LEN; diff --git a/userland/capsule_driver_e1000/src/discover.rs b/userland/capsule_driver_e1000/src/discover.rs index 9219638ae1..9dfd3f6942 100644 --- a/userland/capsule_driver_e1000/src/discover.rs +++ b/userland/capsule_driver_e1000/src/discover.rs @@ -25,7 +25,6 @@ const PCI_SUBCLASS_ETHERNET: u8 = 0x00; #[derive(Clone, Copy)] pub struct Found { pub device_id: u64, - pub irq_line: u8, pub bar0_size: u64, } @@ -40,14 +39,17 @@ pub fn find_e1000() -> Option { if !is_match(r) { continue; } - if r.irq_pin == 0 || r.irq_line == 0xFF || r.bar_count == 0 { + // Interrupt routing is not asked for: the driver polls. UEFI firmware + // often leaves Interrupt Line at 0xFF, and filtering on it skipped a + // working NIC on exactly the machines this driver is for. + if r.bar_count == 0 { continue; } let bar0 = r.bars[0]; if bar0.kind != BAR_KIND_MMIO || bar0.size == 0 { continue; } - return Some(Found { device_id: r.device_id, irq_line: r.irq_line, bar0_size: bar0.size }); + return Some(Found { device_id: r.device_id, bar0_size: bar0.size }); } None } diff --git a/userland/capsule_driver_e1000/src/init/reset.rs b/userland/capsule_driver_e1000/src/init/reset.rs index e50fc5d781..9f0c73d5c5 100644 --- a/userland/capsule_driver_e1000/src/init/reset.rs +++ b/userland/capsule_driver_e1000/src/init/reset.rs @@ -14,34 +14,61 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -//! Hardware reset + IRQ quiesce + link bring-up. CTRL.RST is -//! self-clearing; the loop bound is generous because the device -//! takes a few microseconds to settle. After reset the firmware -//! restores most defaults but leaves all IMS bits set, so the -//! capsule masks every cause through IMC and reads ICR to clear -//! any latched bits before enabling the link. - -use crate::constants::regs::{REG_CTRL, REG_ICR, REG_IMC}; +//! Hardware reset + IRQ quiesce + link bring-up, in the order the 8254x +//! needs on silicon: +//! +//! 1. Mask every cause and stop both DMA engines, then give bus-master +//! cycles already in flight time to drain. Firmware (PXE, UEFI UNDI) or a +//! previous instance can leave RCTL.EN set, and a reset landing mid-DMA +//! is a known hang on PCI-X parts. +//! 2. Set CTRL.RST and poll for it to self-clear, reading nothing in the +//! first microsecond the manual says the part is unreachable. +//! 3. Wait out the EEPROM auto-load the reset starts. It rewrites RAL0/RAH0 +//! and parts of CTRL, so a MAC or SLU written before it finishes can be +//! put back to the factory value: unicast then goes to the wrong filter. +//! 4. Mask again, clear latched causes, and bring the link up. + +use nonos_libc::Deadline; + +use crate::constants::regs::{REG_CTRL, REG_ICR, REG_IMC, REG_RCTL, REG_STATUS, REG_TCTL}; use crate::constants::status::{CTRL_ASDE, CTRL_LRST, CTRL_RST, CTRL_SLU}; use crate::regs::Regs; -const RESET_POLL_BUDGET: u32 = 100_000; +/// Linux e1000_reset_hw's drain before the reset. +const DMA_DRAIN_MS: u64 = 10; +/// The part is not addressable for about a microsecond after RST is set. +const RST_SETTLE_MS: u64 = 1; +/// Bound on RST self-clearing; it takes microseconds on a working part. +const RST_CLEAR_MS: u64 = 50; +/// EEPROM auto-load after a global reset: 5 ms on 82540/82545/82546, +/// 20 ms on 82541/82547, so the longer one covers every listed part. +const EEPROM_RELOAD_MS: u64 = 20; pub fn run(regs: &Regs) -> Result<(), &'static str> { // SAFETY: eK@nonos.systems — `regs` carries a base from a // valid broker MmioMap grant; offsets are 32-bit aligned per // the 8254x manual. unsafe { + // Both engines off. Nothing else is set here: a card that never gets a + // station address is left with neither enable bit ever written. + regs.w32(REG_IMC, 0xFFFF_FFFF); + regs.w32(REG_RCTL, 0); + regs.w32(REG_TCTL, 0); + let _ = regs.r32(REG_STATUS); + hold_ms(DMA_DRAIN_MS); + let ctrl = regs.r32(REG_CTRL); regs.w32(REG_CTRL, ctrl | CTRL_RST); - let mut spins = 0u32; + hold_ms(RST_SETTLE_MS); + let deadline = Deadline::after_ms(RST_CLEAR_MS); while regs.r32(REG_CTRL) & CTRL_RST != 0 { - spins += 1; - if spins > RESET_POLL_BUDGET { + if deadline.expired() { return Err("CTRL.RST did not self-clear"); } core::hint::spin_loop(); } + hold_ms(EEPROM_RELOAD_MS); + regs.w32(REG_IMC, 0xFFFF_FFFF); let _ = regs.r32(REG_ICR); let mut ctrl = regs.r32(REG_CTRL); @@ -51,3 +78,12 @@ pub fn run(regs: &Regs) -> Result<(), &'static str> { } Ok(()) } + +// At least `ms` milliseconds: uptime counts whole milliseconds, so a deadline +// `ms` ahead can fall due up to one early. +fn hold_ms(ms: u64) { + let until = Deadline::after_ms(ms + 1); + while !until.expired() { + core::hint::spin_loop(); + } +} diff --git a/userland/capsule_driver_e1000/src/init/rx_setup.rs b/userland/capsule_driver_e1000/src/init/rx_setup.rs index 2e5120112b..8e5438fd1b 100644 --- a/userland/capsule_driver_e1000/src/init/rx_setup.rs +++ b/userland/capsule_driver_e1000/src/init/rx_setup.rs @@ -19,6 +19,8 @@ //! and finally enables the receiver via RCTL. RDT points at the //! last valid descriptor index per the 8254x manual. +use core::sync::atomic::{fence, Ordering}; + use crate::constants::queue::{RX_DESC_COUNT, RX_RING_BYTES}; use crate::constants::regs::{REG_RCTL, REG_RDBAH, REG_RDBAL, REG_RDH, REG_RDLEN, REG_RDT}; use crate::constants::status::{RCTL_BAM, RCTL_BSIZE_2048, RCTL_EN, RCTL_SECRC}; @@ -37,6 +39,8 @@ pub fn program(regs: &Regs, rx: &RxRing, ring_phys: u64) { *d = RxDesc::default(); d.buffer_addr = rx.buffer_phys(i as u16); } + // The ring was written with plain stores; the part reads it from here on. + fence(Ordering::Release); regs.w32(REG_RDBAL, (ring_phys & 0xFFFF_FFFF) as u32); regs.w32(REG_RDBAH, (ring_phys >> 32) as u32); regs.w32(REG_RDLEN, RX_RING_BYTES as u32); diff --git a/userland/capsule_driver_e1000/src/init/tx_setup.rs b/userland/capsule_driver_e1000/src/init/tx_setup.rs index fd030aa2eb..b6e6710fee 100644 --- a/userland/capsule_driver_e1000/src/init/tx_setup.rs +++ b/userland/capsule_driver_e1000/src/init/tx_setup.rs @@ -19,6 +19,8 @@ //! (`0x00602008`), and enables the transmitter via TCTL with the //! pad-short-packet bit and a 16-retry collision threshold. +use core::sync::atomic::{fence, Ordering}; + use crate::constants::queue::{TX_DESC_COUNT, TX_RING_BYTES}; use crate::constants::regs::{ REG_TCTL, REG_TDBAH, REG_TDBAL, REG_TDH, REG_TDLEN, REG_TDT, REG_TIPG, @@ -39,6 +41,8 @@ pub fn program(regs: &Regs, tx: &TxRing, ring_phys: u64) { for i in 0..TX_DESC_COUNT { *descs.add(i) = TxDesc::default(); } + // The ring was written with plain stores; the part reads it from here on. + fence(Ordering::Release); regs.w32(REG_TDBAL, (ring_phys & 0xFFFF_FFFF) as u32); regs.w32(REG_TDBAH, (ring_phys >> 32) as u32); regs.w32(REG_TDLEN, TX_RING_BYTES as u32); diff --git a/userland/capsule_driver_e1000/src/protocol/header.rs b/userland/capsule_driver_e1000/src/protocol/header.rs index 8a0162b7ff..db8682373f 100644 --- a/userland/capsule_driver_e1000/src/protocol/header.rs +++ b/userland/capsule_driver_e1000/src/protocol/header.rs @@ -14,7 +14,10 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -pub const MAGIC: u32 = 0x4E45_3130; +/// "NNET", the NIC protocol net_core and net_l2 speak (virtio-net's too). +/// The per-driver tag this replaced made every request from the stack +/// undecodable, so the wired NICs never served it. +pub const MAGIC: u32 = 0x4E4E_4554; pub const VERSION: u16 = 1; pub const HDR_LEN: usize = 20; diff --git a/userland/capsule_driver_e1000/src/protocol/mod.rs b/userland/capsule_driver_e1000/src/protocol/mod.rs index 8f11313832..adce81f355 100644 --- a/userland/capsule_driver_e1000/src/protocol/mod.rs +++ b/userland/capsule_driver_e1000/src/protocol/mod.rs @@ -16,7 +16,6 @@ mod decode; mod encode; -mod endpoint; mod errno; mod header; mod limits; @@ -24,7 +23,6 @@ mod ops; pub use decode::decode_request; pub use encode::{encode_response_header, write_status}; -pub use endpoint::KERNEL_REPLY_ENDPOINT; pub use errno::{E_AGAIN, E_INVAL, E_IO, E_MSGSIZE}; pub use header::{Request, HDR_LEN, RESP_HDR_LEN}; pub use limits::{ diff --git a/userland/capsule_driver_e1000/src/queue/rx.rs b/userland/capsule_driver_e1000/src/queue/rx.rs index 62d0e6f489..1636f1c8d9 100644 --- a/userland/capsule_driver_e1000/src/queue/rx.rs +++ b/userland/capsule_driver_e1000/src/queue/rx.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 . +use core::sync::atomic::{fence, Ordering}; + use crate::constants::queue::{RX_BUFFER_LEN, RX_DESC_COUNT, RX_STATUS_DD, RX_STATUS_EOP}; use crate::constants::MAX_ETHERNET_FRAME; @@ -60,6 +62,8 @@ impl RxRing { if status & RX_STATUS_DD == 0 { return None; } + // Length, errors and the frame are only the part's once DD is seen. + fence(Ordering::Acquire); let errors = unsafe { read_volatile(addr_of!((*desc).errors)) }; let len = unsafe { read_volatile(addr_of!((*desc).length)) }; let idx = self.head; diff --git a/userland/capsule_driver_e1000/src/queue/tx.rs b/userland/capsule_driver_e1000/src/queue/tx.rs index df4539ea73..90aac9af95 100644 --- a/userland/capsule_driver_e1000/src/queue/tx.rs +++ b/userland/capsule_driver_e1000/src/queue/tx.rs @@ -15,9 +15,16 @@ // along with this program. If not, see . //! TX ring state. `post` programs the next descriptor with -//! `EOP|IFCS|RS` and bumps the tail; `done(idx)` polls the -//! per-slot DD bit so the server loop knows the descriptor and -//! its buffer can be reused. +//! `EOP|IFCS|RS` and bumps the tail; `reclaim` walks `clean` forward +//! over descriptors the part has marked DD, and `full` refuses a post +//! that would land on one it still owns. +//! +//! The part holds descriptors it cannot send: with the link down it stops +//! DMA and sets no DD, so a slot is only reusable once `reclaim` has seen +//! it done. One slot always stays empty, or a full ring would move TDT +//! onto TDH, which the part reads as an empty one. + +use core::sync::atomic::{fence, Ordering}; use crate::constants::queue::{ TX_BUFFER_LEN, TX_CMD_EOP, TX_CMD_IFCS, TX_CMD_RS, TX_DESC_COUNT, TX_STATUS_DD, @@ -31,6 +38,8 @@ pub struct TxRing { pub buffer_user_va: u64, pub buffer_device_addr: u64, pub tail: u16, + /// Oldest descriptor not yet seen done; `clean == tail` is an empty ring. + pub clean: u16, } /* @@ -41,7 +50,7 @@ pub struct TxRing { */ impl TxRing { pub fn new(ring_user_va: u64, buffer_user_va: u64, buffer_device_addr: u64) -> Self { - Self { ring_user_va, buffer_user_va, buffer_device_addr, tail: 0 } + Self { ring_user_va, buffer_user_va, buffer_device_addr, tail: 0, clean: 0 } } /// # Safety @@ -78,6 +87,20 @@ impl TxRing { } pub fn done(&self, idx: u16) -> bool { - unsafe { read_volatile(addr_of!((*self.descriptor(idx)).status)) & TX_STATUS_DD != 0 } + let dd = unsafe { read_volatile(addr_of!((*self.descriptor(idx)).status)) } & TX_STATUS_DD; + fence(Ordering::Acquire); + dd != 0 + } + + /// Advance `clean` over every descriptor the part has finished, in order. + pub fn reclaim(&mut self) { + while self.clean != self.tail && self.done(self.clean) { + self.clean = (self.clean + 1) % (TX_DESC_COUNT as u16); + } + } + + /// Whether posting one more descriptor would reach one the part still owns. + pub fn full(&self) -> bool { + (self.tail + 1) % (TX_DESC_COUNT as u16) == self.clean } } diff --git a/userland/capsule_driver_e1000/src/server/error.rs b/userland/capsule_driver_e1000/src/server/error.rs index ae8968207b..c171238450 100644 --- a/userland/capsule_driver_e1000/src/server/error.rs +++ b/userland/capsule_driver_e1000/src/server/error.rs @@ -18,19 +18,24 @@ //! the response shape stays uniform: 20-byte echo header followed //! by a four-byte status code. -use nonos_libc::mk_ipc_send; +use nonos_libc::mk_ipc_reply; -use crate::protocol::{ - encode_response_header, write_status, Request, KERNEL_REPLY_ENDPOINT, RESP_HDR_LEN, STATUS_LEN, -}; +use crate::protocol::{encode_response_header, write_status, Request, RESP_HDR_LEN, STATUS_LEN}; -pub fn reply_with_status(tx: &mut [u8], req: &Request, status: i32) { +/// Answer the capsule that sent the request. Replies used to go to a fixed +/// kernel-side inbox, which nothing reads now that the stack is a capsule, +/// so every call from net_core timed out. +pub fn reply(sender: u32, tx: &[u8], len: usize) { + let _ = mk_ipc_reply(sender, tx.as_ptr(), len); +} + +pub fn reply_with_status(sender: u32, tx: &mut [u8], req: &Request, status: i32) { encode_response_header(tx, req, STATUS_LEN as u32); write_status(&mut tx[RESP_HDR_LEN..], status); - let _ = mk_ipc_send(KERNEL_REPLY_ENDPOINT, tx.as_ptr(), RESP_HDR_LEN + STATUS_LEN); + reply(sender, tx, RESP_HDR_LEN + STATUS_LEN); } -pub fn reply_decode_failed(tx: &mut [u8], status: i32) { +pub fn reply_decode_failed(sender: u32, tx: &mut [u8], status: i32) { let req = Request { op: 0, flags: 0, request_id: 0, payload_len: 0 }; - reply_with_status(tx, &req, status); + reply_with_status(sender, tx, &req, status); } diff --git a/userland/capsule_driver_e1000/src/server/handlers/health.rs b/userland/capsule_driver_e1000/src/server/handlers/health.rs index 74921d372c..bf7e99f5ff 100644 --- a/userland/capsule_driver_e1000/src/server/handlers/health.rs +++ b/userland/capsule_driver_e1000/src/server/handlers/health.rs @@ -19,6 +19,6 @@ use crate::protocol::Request; use crate::server::error::reply_with_status; -pub fn handle(req: &Request, tx: &mut [u8]) { - reply_with_status(tx, req, 0); +pub fn handle(sender: u32, req: &Request, tx: &mut [u8]) { + reply_with_status(sender, tx, req, 0); } diff --git a/userland/capsule_driver_e1000/src/server/handlers/link_status.rs b/userland/capsule_driver_e1000/src/server/handlers/link_status.rs index 6c3afa4da5..1360ee74e4 100644 --- a/userland/capsule_driver_e1000/src/server/handlers/link_status.rs +++ b/userland/capsule_driver_e1000/src/server/handlers/link_status.rs @@ -19,17 +19,16 @@ //! sampled on every call so a topology change between two probes //! is observable to the kernel client. -use nonos_libc::mk_ipc_send; - use crate::constants::regs::REG_STATUS; use crate::constants::status::STATUS_LU; use crate::protocol::{ - encode_response_header, write_status, Request, KERNEL_REPLY_ENDPOINT, LINK_STATUS_PAYLOAD_LEN, - RESP_HDR_LEN, STATUS_LEN, + encode_response_header, write_status, Request, LINK_STATUS_PAYLOAD_LEN, RESP_HDR_LEN, + STATUS_LEN, }; +use crate::server::error::reply; use crate::setup::Driver; -pub fn handle(driver: &Driver, req: &Request, tx: &mut [u8]) { +pub fn handle(sender: u32, driver: &Driver, req: &Request, tx: &mut [u8]) { // SAFETY: eK@nonos.systems — `driver.regs` carries the broker // MmioMap base for BAR0; `REG_STATUS` is a 4-byte-aligned offset // documented in the 8254x manual. @@ -39,9 +38,5 @@ pub fn handle(driver: &Driver, req: &Request, tx: &mut [u8]) { encode_response_header(tx, req, payload_len); write_status(&mut tx[RESP_HDR_LEN..], 0); tx[RESP_HDR_LEN + STATUS_LEN] = if up { 1 } else { 0 }; - let _ = mk_ipc_send( - KERNEL_REPLY_ENDPOINT, - tx.as_ptr(), - RESP_HDR_LEN + STATUS_LEN + LINK_STATUS_PAYLOAD_LEN, - ); + reply(sender, tx, RESP_HDR_LEN + STATUS_LEN + LINK_STATUS_PAYLOAD_LEN); } diff --git a/userland/capsule_driver_e1000/src/server/handlers/mac_address.rs b/userland/capsule_driver_e1000/src/server/handlers/mac_address.rs index 87e758c55b..a7f826e97c 100644 --- a/userland/capsule_driver_e1000/src/server/handlers/mac_address.rs +++ b/userland/capsule_driver_e1000/src/server/handlers/mac_address.rs @@ -18,23 +18,18 @@ //! bring-up. The kernel client treats an all-zero MAC as a hard //! error so a misprogrammed RAL/RAH never silently passes a validation. -use nonos_libc::mk_ipc_send; - use crate::protocol::{ - encode_response_header, write_status, Request, KERNEL_REPLY_ENDPOINT, MAC_ADDRESS_PAYLOAD_LEN, - RESP_HDR_LEN, STATUS_LEN, + encode_response_header, write_status, Request, MAC_ADDRESS_PAYLOAD_LEN, RESP_HDR_LEN, + STATUS_LEN, }; +use crate::server::error::reply; use crate::setup::Driver; -pub fn handle(driver: &Driver, req: &Request, tx: &mut [u8]) { +pub fn handle(sender: u32, driver: &Driver, req: &Request, tx: &mut [u8]) { let payload_len = STATUS_LEN as u32 + MAC_ADDRESS_PAYLOAD_LEN as u32; encode_response_header(tx, req, payload_len); write_status(&mut tx[RESP_HDR_LEN..], 0); tx[RESP_HDR_LEN + STATUS_LEN..RESP_HDR_LEN + STATUS_LEN + MAC_ADDRESS_PAYLOAD_LEN] .copy_from_slice(&driver.mac); - let _ = mk_ipc_send( - KERNEL_REPLY_ENDPOINT, - tx.as_ptr(), - RESP_HDR_LEN + STATUS_LEN + MAC_ADDRESS_PAYLOAD_LEN, - ); + reply(sender, tx, RESP_HDR_LEN + STATUS_LEN + MAC_ADDRESS_PAYLOAD_LEN); } diff --git a/userland/capsule_driver_e1000/src/server/handlers/rx_packet.rs b/userland/capsule_driver_e1000/src/server/handlers/rx_packet.rs index 8e8edc94c7..d6a26a1175 100644 --- a/userland/capsule_driver_e1000/src/server/handlers/rx_packet.rs +++ b/userland/capsule_driver_e1000/src/server/handlers/rx_packet.rs @@ -14,21 +14,21 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -use nonos_libc::mk_ipc_send; +use core::sync::atomic::{fence, Ordering}; use crate::constants::regs::REG_RDT; use crate::protocol::{ - encode_response_header, write_status, Request, E_AGAIN, E_IO, KERNEL_REPLY_ENDPOINT, - RESP_HDR_LEN, RX_PAYLOAD_PREFIX_LEN, STATUS_LEN, + encode_response_header, write_status, Request, E_AGAIN, E_IO, RESP_HDR_LEN, + RX_PAYLOAD_PREFIX_LEN, STATUS_LEN, }; -use crate::server::error::reply_with_status; +use crate::server::error::{reply, reply_with_status}; use crate::setup::Driver; -pub fn handle(driver: &mut Driver, req: &Request, tx: &mut [u8]) { +pub fn handle(sender: u32, driver: &mut Driver, req: &Request, tx: &mut [u8]) { let (idx, len) = match driver.rx.consume() { Some(p) => p, None => { - reply_with_status(tx, req, E_AGAIN); + reply_with_status(sender, tx, req, E_AGAIN); return; } }; @@ -36,7 +36,7 @@ pub fn handle(driver: &mut Driver, req: &Request, tx: &mut [u8]) { unsafe { driver.regs.w32(REG_RDT, idx as u32); } - reply_with_status(tx, req, E_IO); + reply_with_status(sender, tx, req, E_IO); return; } let body_len = RX_PAYLOAD_PREFIX_LEN + len as usize; @@ -55,8 +55,10 @@ pub fn handle(driver: &mut Driver, req: &Request, tx: &mut [u8]) { unsafe { core::ptr::copy_nonoverlapping(src, tx[body_off..].as_mut_ptr(), n); } + // The copy out of the buffer ends before the part may write it again. + fence(Ordering::Release); unsafe { driver.regs.w32(REG_RDT, idx as u32); } - let _ = mk_ipc_send(KERNEL_REPLY_ENDPOINT, tx.as_ptr(), prefix_off + body_len); + reply(sender, tx, prefix_off + body_len); } diff --git a/userland/capsule_driver_e1000/src/server/handlers/stats.rs b/userland/capsule_driver_e1000/src/server/handlers/stats.rs index f644243a21..d4b56c9713 100644 --- a/userland/capsule_driver_e1000/src/server/handlers/stats.rs +++ b/userland/capsule_driver_e1000/src/server/handlers/stats.rs @@ -14,17 +14,15 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -use nonos_libc::mk_ipc_send; - use crate::constants::queue::{RX_DESC_COUNT, TX_DESC_COUNT}; use crate::constants::regs::{REG_RCTL, REG_RDH, REG_RDT, REG_STATUS, REG_TCTL, REG_TDH, REG_TDT}; use crate::protocol::{ - encode_response_header, write_status, Request, KERNEL_REPLY_ENDPOINT, RESP_HDR_LEN, - STATS_PAYLOAD_LEN, STATUS_LEN, + encode_response_header, write_status, Request, RESP_HDR_LEN, STATS_PAYLOAD_LEN, STATUS_LEN, }; +use crate::server::error::reply; use crate::setup::Driver; -pub fn handle(driver: &Driver, req: &Request, tx: &mut [u8]) { +pub fn handle(sender: u32, driver: &Driver, req: &Request, tx: &mut [u8]) { let payload_len = STATUS_LEN as u32 + STATS_PAYLOAD_LEN as u32; encode_response_header(tx, req, payload_len); write_status(&mut tx[RESP_HDR_LEN..], 0); @@ -32,7 +30,7 @@ pub fn handle(driver: &Driver, req: &Request, tx: &mut [u8]) { for v in live_regs(driver) { put32(tx, &mut o, v); } - let _ = mk_ipc_send(KERNEL_REPLY_ENDPOINT, tx.as_ptr(), RESP_HDR_LEN + payload_len as usize); + reply(sender, tx, RESP_HDR_LEN + payload_len as usize); } fn live_regs(driver: &Driver) -> [u32; 12] { diff --git a/userland/capsule_driver_e1000/src/server/handlers/tx_packet.rs b/userland/capsule_driver_e1000/src/server/handlers/tx_packet.rs index 434b876f60..c0db5d640f 100644 --- a/userland/capsule_driver_e1000/src/server/handlers/tx_packet.rs +++ b/userland/capsule_driver_e1000/src/server/handlers/tx_packet.rs @@ -14,25 +14,37 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . +use core::sync::atomic::{fence, Ordering}; + use crate::constants::queue::TX_DESC_COUNT; use crate::constants::regs::REG_TDT; use crate::constants::{MAX_ETHERNET_FRAME, MIN_ETHERNET_FRAME}; -use crate::protocol::{Request, E_INVAL, E_IO, E_MSGSIZE, MAX_TX_PAYLOAD_BYTES}; +use crate::protocol::{Request, E_AGAIN, E_INVAL, E_MSGSIZE, MAX_TX_PAYLOAD_BYTES}; use crate::server::error::reply_with_status; use crate::setup::Driver; -const TX_DD_POLL_BUDGET: u32 = 1_000_000; - -pub fn handle(driver: &mut Driver, req: &Request, body: &[u8], tx: &mut [u8]) { +/* + * A frame is answered once it is queued, not once it is on the wire. Waiting + * for DD held the caller for as long as the link was down, reported E_IO for a + * frame the part still sent later (so a retry sent it twice), and left the + * descriptor posted for the next call to overwrite. Completion is now what + * `reclaim` observes, and a ring with no free slot says so. + */ +pub fn handle(sender: u32, driver: &mut Driver, req: &Request, body: &[u8], tx: &mut [u8]) { if req.payload_len as usize != body.len() { - reply_with_status(tx, req, E_MSGSIZE); + reply_with_status(sender, tx, req, E_MSGSIZE); return; } if body.len() < MIN_ETHERNET_FRAME || body.len() > MAX_ETHERNET_FRAME || body.len() as u32 > MAX_TX_PAYLOAD_BYTES { - reply_with_status(tx, req, E_INVAL); + reply_with_status(sender, tx, req, E_INVAL); + return; + } + driver.tx.reclaim(); + if driver.tx.full() { + reply_with_status(sender, tx, req, E_AGAIN); return; } let dst = driver.tx.buffer_va(driver.tx.tail) as *mut u8; @@ -41,17 +53,10 @@ pub fn handle(driver: &mut Driver, req: &Request, body: &[u8], tx: &mut [u8]) { } let idx = driver.tx.post(body.len() as u16); let next_tdt = ((idx as u32) + 1) % (TX_DESC_COUNT as u32); + // The frame bytes are plain stores; the tail write is what lets the part read them. + fence(Ordering::Release); unsafe { driver.regs.w32(REG_TDT, next_tdt); } - let mut spins = 0u32; - while !driver.tx.done(idx) { - spins += 1; - if spins > TX_DD_POLL_BUDGET { - reply_with_status(tx, req, E_IO); - return; - } - core::hint::spin_loop(); - } - reply_with_status(tx, req, 0); + reply_with_status(sender, tx, req, 0); } diff --git a/userland/capsule_driver_e1000/src/server/runner.rs b/userland/capsule_driver_e1000/src/server/runner.rs index e12d35eaa0..2b8037790f 100644 --- a/userland/capsule_driver_e1000/src/server/runner.rs +++ b/userland/capsule_driver_e1000/src/server/runner.rs @@ -16,7 +16,7 @@ use alloc::vec; -use nonos_libc::mk_ipc_recv; +use nonos_libc::mk_ipc_recv_from; use crate::constants::MAX_ETHERNET_FRAME; use crate::protocol::{ @@ -39,32 +39,33 @@ pub fn run(driver: &mut Driver) -> ! { let mut tx = vec![0u8; tx_len]; loop { - let n = mk_ipc_recv(SERVICE_INBOX, rx.as_mut_ptr(), rx_len, 0); - if n <= 0 { + let mut sender: u32 = 0; + let n = mk_ipc_recv_from(SERVICE_INBOX, rx.as_mut_ptr(), rx_len, 0, &mut sender); + if n <= 0 || sender == 0 { continue; } let len = n as usize; let req = match decode_request(&rx[..len]) { Some(r) => r, None => { - reply_decode_failed(&mut tx, E_INVAL); + reply_decode_failed(sender, &mut tx, E_INVAL); continue; } }; let body_end = HDR_LEN.saturating_add(req.payload_len as usize); if body_end != len { - reply_with_status(&mut tx, &req, E_MSGSIZE); + reply_with_status(sender, &mut tx, &req, E_MSGSIZE); continue; } let body = &rx[HDR_LEN..body_end]; match req.op { - OP_HEALTHCHECK => handlers::health::handle(&req, &mut tx), - OP_LINK_STATUS => handlers::link_status::handle(driver, &req, &mut tx), - OP_MAC_ADDRESS => handlers::mac_address::handle(driver, &req, &mut tx), - OP_TX_PACKET => handlers::tx_packet::handle(driver, &req, body, &mut tx), - OP_RX_PACKET => handlers::rx_packet::handle(driver, &req, &mut tx), - OP_STATS => handlers::stats::handle(driver, &req, &mut tx), - _ => reply_with_status(&mut tx, &req, E_INVAL), + OP_HEALTHCHECK => handlers::health::handle(sender, &req, &mut tx), + OP_LINK_STATUS => handlers::link_status::handle(sender, driver, &req, &mut tx), + OP_MAC_ADDRESS => handlers::mac_address::handle(sender, driver, &req, &mut tx), + OP_TX_PACKET => handlers::tx_packet::handle(sender, driver, &req, body, &mut tx), + OP_RX_PACKET => handlers::rx_packet::handle(sender, driver, &req, &mut tx), + OP_STATS => handlers::stats::handle(sender, driver, &req, &mut tx), + _ => reply_with_status(sender, &mut tx, &req, E_INVAL), } } } diff --git a/userland/capsule_driver_e1000/src/setup/dma.rs b/userland/capsule_driver_e1000/src/setup/dma.rs index 3a1cea2ffa..ad572b6483 100644 --- a/userland/capsule_driver_e1000/src/setup/dma.rs +++ b/userland/capsule_driver_e1000/src/setup/dma.rs @@ -19,7 +19,7 @@ //! every prior grant in reverse on failure so the broker never //! holds a partial setup. -use nonos_libc::{mk_dma_map, DmaMapOut, IrqBindOut, MmioMapOut}; +use nonos_libc::{mk_dma_map, DmaMapOut, MmioMapOut}; use crate::constants::queue::{ RX_BUFFER_POOL_BYTES, RX_RING_BYTES, TX_BUFFER_POOL_BYTES, TX_RING_BYTES, @@ -48,27 +48,21 @@ pub fn map_rings_and_buffers( device_id: u64, claim_epoch: u64, mmio: &MmioMapOut, - irq: &IrqBindOut, ) -> Result<(DmaMapOut, DmaMapOut, DmaMapOut, DmaMapOut), &'static str> { let rx_ring = alloc(device_id, claim_epoch, RX_RING_BYTES as u64).ok_or_else(|| { - rollback::after(device_id, mmio, irq, &[]); + rollback::after(device_id, mmio, &[]); "dma map failed (rx ring)" })?; let rx_buf = alloc(device_id, claim_epoch, RX_BUFFER_POOL_BYTES as u64).ok_or_else(|| { - rollback::after(device_id, mmio, irq, &[rx_ring.grant_id]); + rollback::after(device_id, mmio, &[rx_ring.grant_id]); "dma map failed (rx buffers)" })?; let tx_ring = alloc(device_id, claim_epoch, TX_RING_BYTES as u64).ok_or_else(|| { - rollback::after(device_id, mmio, irq, &[rx_buf.grant_id, rx_ring.grant_id]); + rollback::after(device_id, mmio, &[rx_buf.grant_id, rx_ring.grant_id]); "dma map failed (tx ring)" })?; let tx_buf = alloc(device_id, claim_epoch, TX_BUFFER_POOL_BYTES as u64).ok_or_else(|| { - rollback::after( - device_id, - mmio, - irq, - &[tx_ring.grant_id, rx_buf.grant_id, rx_ring.grant_id], - ); + rollback::after(device_id, mmio, &[tx_ring.grant_id, rx_buf.grant_id, rx_ring.grant_id]); "dma map failed (tx buffers)" })?; Ok((rx_ring, rx_buf, tx_ring, tx_buf)) diff --git a/userland/capsule_driver_e1000/src/setup/driver.rs b/userland/capsule_driver_e1000/src/setup/driver.rs index dfd9be9c89..e406025be1 100644 --- a/userland/capsule_driver_e1000/src/setup/driver.rs +++ b/userland/capsule_driver_e1000/src/setup/driver.rs @@ -19,7 +19,7 @@ //! shutdown releases the grants in reverse order so the broker //! sees a clean teardown even when the capsule exits voluntarily. -use nonos_libc::{mk_device_release, mk_dma_unmap, mk_irq_unbind, mk_mmio_unmap}; +use nonos_libc::{mk_device_release, mk_dma_unmap, mk_mmio_unmap}; use crate::constants::MAC_LEN; use crate::queue::{RxRing, TxRing}; @@ -28,7 +28,6 @@ use crate::regs::Regs; pub struct Driver { pub device_id: u64, pub mmio_grant: u64, - pub irq_grant: u64, pub rx_ring_grant: u64, pub rx_buffer_grant: u64, pub tx_ring_grant: u64, @@ -51,7 +50,6 @@ impl Driver { let _ = mk_dma_unmap(self.tx_ring_grant); let _ = mk_dma_unmap(self.rx_buffer_grant); let _ = mk_dma_unmap(self.rx_ring_grant); - let _ = mk_irq_unbind(self.irq_grant); let _ = mk_mmio_unmap(self.mmio_grant); let _ = mk_device_release(self.device_id); } diff --git a/userland/capsule_driver_e1000/src/setup/mod.rs b/userland/capsule_driver_e1000/src/setup/mod.rs index 0424c3f97e..3edb2f738f 100644 --- a/userland/capsule_driver_e1000/src/setup/mod.rs +++ b/userland/capsule_driver_e1000/src/setup/mod.rs @@ -17,7 +17,6 @@ mod claim; mod dma; mod driver; -mod irq; mod mmio; mod rollback; mod sequence; diff --git a/userland/capsule_driver_e1000/src/setup/rollback.rs b/userland/capsule_driver_e1000/src/setup/rollback.rs index 9836204b80..831df63335 100644 --- a/userland/capsule_driver_e1000/src/setup/rollback.rs +++ b/userland/capsule_driver_e1000/src/setup/rollback.rs @@ -18,15 +18,12 @@ //! fails partway. Best-effort: an `EINVAL` from a doubly-released //! grant is harmless because the broker has already revoked it. -use nonos_libc::{ - mk_device_release, mk_dma_unmap, mk_irq_unbind, mk_mmio_unmap, IrqBindOut, MmioMapOut, -}; +use nonos_libc::{mk_device_release, mk_dma_unmap, mk_mmio_unmap, MmioMapOut}; -pub fn after(device_id: u64, mmio: &MmioMapOut, irq: &IrqBindOut, dma_grants: &[u64]) { +pub fn after(device_id: u64, mmio: &MmioMapOut, dma_grants: &[u64]) { for &g in dma_grants.iter().rev() { let _ = mk_dma_unmap(g); } - let _ = mk_irq_unbind(irq.grant_id); let _ = mk_mmio_unmap(mmio.grant_id); let _ = mk_device_release(device_id); } diff --git a/userland/capsule_driver_e1000/src/setup/sequence.rs b/userland/capsule_driver_e1000/src/setup/sequence.rs index 93ea12ca45..b123c3c50b 100644 --- a/userland/capsule_driver_e1000/src/setup/sequence.rs +++ b/userland/capsule_driver_e1000/src/setup/sequence.rs @@ -14,11 +14,16 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -//! End-to-end broker handshake: discover -> claim -> MMIO -> IRQ -//! -> RX ring DMA -> RX buffer DMA -> TX ring DMA -> TX buffer -//! DMA. Returns a `Driver` with all grants taken and ring states -//! initialised; the hardware bring-up step in `init` programs the -//! device against those rings. +//! End-to-end broker handshake: discover -> claim -> MMIO -> RX ring +//! DMA -> RX buffer DMA -> TX ring DMA -> TX buffer DMA. Returns a +//! `Driver` with all grants taken and ring states initialised; the +//! hardware bring-up step in `init` programs the device against those +//! rings. +//! +//! No interrupt line is bound. The driver polls and never sets IMS, and a +//! bound INTx line is masked until acked: holding one it never services +//! starved any other device sharing that line, and a failed bind (line +//! already held, or reserved) took down a NIC that needed no interrupt. use crate::constants::MAC_LEN; use crate::discover::find_e1000; @@ -26,19 +31,17 @@ use crate::queue::{RxRing, TxRing}; use crate::regs::Regs; use super::driver::Driver; -use super::{claim, dma, irq, mmio}; +use super::{claim, dma, mmio}; pub fn run() -> Result { let dev = find_e1000().ok_or("no e1000 device")?; let claim_epoch = claim::claim(dev.device_id)?; let mmio_grant = mmio::map(dev, claim_epoch)?; - let irq_grant = irq::bind(dev, claim_epoch, &mmio_grant)?; let (rx_ring, rx_buf, tx_ring, tx_buf) = - dma::map_rings_and_buffers(dev.device_id, claim_epoch, &mmio_grant, &irq_grant)?; + dma::map_rings_and_buffers(dev.device_id, claim_epoch, &mmio_grant)?; Ok(Driver { device_id: dev.device_id, mmio_grant: mmio_grant.grant_id, - irq_grant: irq_grant.grant_id, rx_ring_grant: rx_ring.grant_id, rx_buffer_grant: rx_buf.grant_id, tx_ring_grant: tx_ring.grant_id, diff --git a/userland/capsule_driver_iwlwifi/src/constants/mod.rs b/userland/capsule_driver_iwlwifi/src/constants/mod.rs index 12c3ccb2ea..ceb6063ce4 100644 --- a/userland/capsule_driver_iwlwifi/src/constants/mod.rs +++ b/userland/capsule_driver_iwlwifi/src/constants/mod.rs @@ -53,10 +53,16 @@ pub const CSR_INT_MASK: usize = 0x00C; pub const CSR_FH_INT_STATUS: usize = 0x010; pub const CSR_GP_CNTRL: usize = 0x024; pub const CSR_HW_REV: usize = 0x028; -pub const GP_CNTRL_MAC_CLOCK_READY: u32 = 0x0000_0002; +// The CSR_GP_CNTRL layout of Linux iwl_csr_v1, which covers every family +// probed here up to AX210. Bit 1 is undefined there: polling it for the MAC +// clock timed out on every card, so setup never got past this register. +// Bz-family parts (BE200) use the v2 layout, which is not implemented. +pub const GP_CNTRL_MAC_CLOCK_READY: u32 = 0x0000_0001; pub const GP_CNTRL_INIT_DONE: u32 = 0x0000_0004; pub const GP_CNTRL_MAC_ACCESS_REQ: u32 = 0x0000_0008; pub const GP_CNTRL_XTAL_ON: u32 = 0x0000_0400; +/// Set while the hardware RF-kill switch lets the radio on. +pub const GP_CNTRL_HW_RF_KILL_SW: u32 = 0x0800_0000; pub const ALL_INTS_MASK: u32 = 0xFFFF_FFFF; pub const INT_MASK_DISABLED: u32 = 0; pub const INT_COALESCING_TIMEOUT: u32 = 64; @@ -66,7 +72,8 @@ pub const ALIVE_POLL_ITERS: usize = 2_000_000; pub const IWL_FW_MAGIC: u32 = 0x0A4C_5749; pub const FW_API_VERSION_MASK: u32 = 0xFFFF; pub const MIN_FW_API_VERSION: u16 = 22; -pub const MAX_FW_API_VERSION: u16 = 77; +/// The newest image bundled (so-a0-gf-a0-86); 77 refused it outright. +pub const MAX_FW_API_VERSION: u16 = 86; // Host-command / transmit-queue interface. Once the firmware is alive, the // driver hands it commands through a TFD ring per transmit queue. The diff --git a/userland/capsule_driver_iwlwifi/src/firmware/stage/stage_firmware.rs b/userland/capsule_driver_iwlwifi/src/firmware/stage/stage_firmware.rs index d084606842..8b1f0632ad 100644 --- a/userland/capsule_driver_iwlwifi/src/firmware/stage/stage_firmware.rs +++ b/userland/capsule_driver_iwlwifi/src/firmware/stage/stage_firmware.rs @@ -17,7 +17,9 @@ use super::count_section::count_section; use super::stage_section::stage_section; use super::state::FirmwareStageState; -use crate::firmware::tlv::{le32, parse_header, TLV_PAGING, TLV_SEC_INIT, TLV_SEC_RT}; +use crate::firmware::tlv::{ + le32, parse_header, TLV_HEADER_LEN, TLV_PAGING, TLV_SEC_INIT, TLV_SEC_RT, +}; pub fn stage_firmware(data: &[u8], dma_user_va: u64, dma_len: u64) -> Option { let h = parse_header(data)?; @@ -28,7 +30,7 @@ pub fn stage_firmware(data: &[u8], dma_user_va: u64, dma_len: u64) -> Option Option
{ - if data.len() < 20 { + if data.len() < TLV_HEADER_LEN { return None; } let zero = le32(data, 0)?; @@ -23,7 +35,7 @@ pub fn parse_header(data: &[u8]) -> Option
{ if zero != 0 || magic != IWL_FW_MAGIC { return None; } - let ver = le32(data, 8)?; + let ver = le32(data, VER_OFF)?; let api = (ver & FW_API_VERSION_MASK) as u16; if !(MIN_FW_API_VERSION..=MAX_FW_API_VERSION).contains(&api) { return None; @@ -32,7 +44,7 @@ pub fn parse_header(data: &[u8]) -> Option
{ major: ((ver >> 24) & 0xFF) as u16, minor: ((ver >> 16) & 0xFF) as u16, api, - build: le32(data, 12)?, + build: le32(data, BUILD_OFF)?, }) } diff --git a/userland/capsule_driver_iwlwifi/src/init.rs b/userland/capsule_driver_iwlwifi/src/init.rs index be4b28a087..9629ef0af4 100644 --- a/userland/capsule_driver_iwlwifi/src/init.rs +++ b/userland/capsule_driver_iwlwifi/src/init.rs @@ -8,8 +8,9 @@ use crate::constants::{ ALL_INTS_MASK, APM_POLL_ITERS, CSR_FH_INT_STATUS, CSR_GP_CNTRL, CSR_HW_REV, CSR_INT, - CSR_INT_COALESCING, CSR_INT_MASK, GP_CNTRL_INIT_DONE, GP_CNTRL_MAC_ACCESS_REQ, - GP_CNTRL_MAC_CLOCK_READY, GP_CNTRL_XTAL_ON, INT_COALESCING_TIMEOUT, INT_MASK_DISABLED, + CSR_INT_COALESCING, CSR_INT_MASK, GP_CNTRL_HW_RF_KILL_SW, GP_CNTRL_INIT_DONE, + GP_CNTRL_MAC_ACCESS_REQ, GP_CNTRL_MAC_CLOCK_READY, GP_CNTRL_XTAL_ON, INT_COALESCING_TIMEOUT, + INT_MASK_DISABLED, }; use crate::regs::Regs; @@ -34,6 +35,8 @@ pub fn bring_up(regs: Regs) -> Result { Ok(InitState { hw_rev: regs.read32(CSR_HW_REV), gp_cntrl, - rf_kill: gp_cntrl & GP_CNTRL_INIT_DONE == 0, + // INIT_DONE is the bit this function just set, so it said nothing + // about the airplane-mode switch; this is the bit that does. + rf_kill: gp_cntrl & GP_CNTRL_HW_RF_KILL_SW == 0, }) } diff --git a/userland/capsule_driver_rtl8139/Capsule.mk b/userland/capsule_driver_rtl8139/Capsule.mk index 4e73684ba8..b476afe6d3 100644 --- a/userland/capsule_driver_rtl8139/Capsule.mk +++ b/userland/capsule_driver_rtl8139/Capsule.mk @@ -1,4 +1,4 @@ -# RTL8139 — Realtek 8139 Fast Ethernet NIC. PCI PIO + INTx + DMA. +# RTL8139 — Realtek 8139 Fast Ethernet NIC. PCI PIO + DMA, polled. # Frame-level transport only: no socket, routing, ARP, or IP policy. # Signing, certificate, manifest, and trust-anchor flow are inherited # from nonos-mk/capsule.mk. @@ -12,7 +12,10 @@ CAPSULE_FEATURE := nonos-capsule-driver-rtl8139 CAPSULE_NAMESPACE := systems.nonos.driver.rtl8139_0 CAPSULE_SERVICE_ENDPOINT := service:4212:driver.rtl8139_0 CAPSULE_REPLY_ENDPOINT := reply:4213:endpoint.4294967309 -# IPC|Memory|Driver|DeviceEnum|Irq|Dma|Pio = 0x1D8019 -CAPSULE_REQUIRED_CAPS := 0x1D8019 +# IPC|Memory|Crypto|Driver|DeviceEnum|Dma|Pio = 0x198039 +# Crypto (0x20) is what the CryptoRandom syscall is gated on. The station address +# is drawn rather than read out of the IDR, and that draw fails closed, so +# without this the card never comes up. No Irq: the driver polls. +CAPSULE_REQUIRED_CAPS := 0x198039 include nonos-mk/capsule.mk diff --git a/userland/capsule_driver_rtl8139/Cargo.lock b/userland/capsule_driver_rtl8139/Cargo.lock index 4d4de1200e..4213cd65e0 100644 --- a/userland/capsule_driver_rtl8139/Cargo.lock +++ b/userland/capsule_driver_rtl8139/Cargo.lock @@ -24,9 +24,14 @@ dependencies = [ name = "nonos_capsule_driver_rtl8139" version = "0.3.0" dependencies = [ + "nonos_mac", "nonos_userland_libc", ] +[[package]] +name = "nonos_mac" +version = "0.3.0" + [[package]] name = "nonos_userland_libc" version = "0.3.0" diff --git a/userland/capsule_driver_rtl8139/Cargo.toml b/userland/capsule_driver_rtl8139/Cargo.toml index 549d0914cc..b0f682d287 100644 --- a/userland/capsule_driver_rtl8139/Cargo.toml +++ b/userland/capsule_driver_rtl8139/Cargo.toml @@ -16,6 +16,7 @@ path = "src/main.rs" [dependencies] nonos_libc = { package = "nonos_userland_libc", path = "../libc" } +nonos_mac = { path = "../nonos_mac" } [profile.release] panic = "abort" diff --git a/userland/capsule_driver_rtl8139/src/constants/frame.rs b/userland/capsule_driver_rtl8139/src/constants/frame.rs index 7414c0b3ad..3e464523c2 100644 --- a/userland/capsule_driver_rtl8139/src/constants/frame.rs +++ b/userland/capsule_driver_rtl8139/src/constants/frame.rs @@ -15,5 +15,10 @@ // along with this program. If not, see . pub const MAC_LEN: usize = 6; -pub const MIN_ETHERNET_FRAME: usize = 60; +/// A bare header is the shortest frame taken. ARP (42 bytes) and a bare TCP +/// ACK (54) are shorter than the wire minimum, and refusing them stranded +/// IPv4 right after DHCP. +pub const MIN_ETHERNET_FRAME: usize = 14; +/// The part does not pad short frames itself, so `send` does, to this. +pub const MIN_WIRE_FRAME: usize = 60; pub const MAX_ETHERNET_FRAME: usize = 1514; diff --git a/userland/capsule_driver_rtl8139/src/constants/mod.rs b/userland/capsule_driver_rtl8139/src/constants/mod.rs index 6a15762bdd..a6ce0b0994 100644 --- a/userland/capsule_driver_rtl8139/src/constants/mod.rs +++ b/userland/capsule_driver_rtl8139/src/constants/mod.rs @@ -19,4 +19,4 @@ mod frame; pub mod pci; pub mod regs; -pub use frame::{MAC_LEN, MAX_ETHERNET_FRAME, MIN_ETHERNET_FRAME}; +pub use frame::{MAC_LEN, MAX_ETHERNET_FRAME, MIN_ETHERNET_FRAME, MIN_WIRE_FRAME}; diff --git a/userland/capsule_driver_rtl8139/src/constants/regs.rs b/userland/capsule_driver_rtl8139/src/constants/regs.rs index 5735373bda..4359bce24b 100644 --- a/userland/capsule_driver_rtl8139/src/constants/regs.rs +++ b/userland/capsule_driver_rtl8139/src/constants/regs.rs @@ -24,8 +24,13 @@ pub const REG_IMR: u16 = 0x3C; pub const REG_ISR: u16 = 0x3E; pub const REG_TCR: u16 = 0x40; pub const REG_RCR: u16 = 0x44; +/// EEPROM command register, which holds the config-write lock over IDR. +pub const REG_CFG9346: u16 = 0x50; pub const REG_MSR: u16 = 0x58; +pub const CFG9346_UNLOCK: u8 = 0xC0; +pub const CFG9346_LOCK: u8 = 0x00; + pub const CMD_RESET: u8 = 0x10; pub const CMD_RX_ENABLE: u8 = 0x08; pub const CMD_TX_ENABLE: u8 = 0x04; @@ -47,8 +52,16 @@ pub const RCR_ACCEPT_MULTI: u32 = 1 << 2; pub const RCR_ACCEPT_BCAST: u32 = 1 << 3; pub const RCR_WRAP: u32 = 1 << 7; pub const RCR_MXDMA_UNLIMITED: u32 = 7 << 8; +/// RBLEN = 10, a 32K+16 ring. Left at 00 the part wraps at 8K while every +/// offset here is taken against 32K, and receive stops after about 8 KB. +pub const RCR_RBLEN_32K: u32 = 0b10 << 11; pub const TCR_MXDMA_UNLIMITED: u32 = 7 << 8; +/// Restarts a transmitter halted by an aborted frame. +pub const TCR_CLEAR_ABORT: u32 = 1 << 0; pub const TX_STATUS_OK: u32 = 1 << 15; pub const TX_STATUS_UNDERRUN: u32 = 1 << 14; pub const TX_STATUS_ABORT: u32 = 1 << 30; +/// TSD early-transmit threshold in 32-byte units: 8 is 256 bytes, where +/// Linux 8139too starts. Zero is 8 bytes, which underruns on a busy bus. +pub const TSD_ERTXTH_256: u32 = 8 << 16; diff --git a/userland/capsule_driver_rtl8139/src/discover.rs b/userland/capsule_driver_rtl8139/src/discover.rs index 3c139eeb72..a79bcb8d2a 100644 --- a/userland/capsule_driver_rtl8139/src/discover.rs +++ b/userland/capsule_driver_rtl8139/src/discover.rs @@ -28,7 +28,6 @@ const MAX_DEVICES: usize = 32; #[derive(Debug, Clone, Copy)] pub struct Found { pub device_id: u64, - pub irq_line: u8, pub pio_bar_index: u8, pub command_bits: u16, } @@ -43,13 +42,12 @@ pub fn find_rtl8139() -> Option { if !is_supported(r) { continue; } - if r.irq_pin == 0 || r.irq_line == 0xFF { - continue; - } + // Interrupt routing is not asked for: the driver polls. UEFI firmware + // often leaves Interrupt Line at 0xFF, and filtering on it skipped a + // present RTL8139 as absent. if let Some(pio_bar_index) = first_pio_bar(r) { return Some(Found { device_id: r.device_id, - irq_line: r.irq_line, pio_bar_index, command_bits: command_bits(r), }); diff --git a/userland/capsule_driver_rtl8139/src/init/mac.rs b/userland/capsule_driver_rtl8139/src/init/mac.rs index 5b55b88bf9..976ff4a405 100644 --- a/userland/capsule_driver_rtl8139/src/init/mac.rs +++ b/userland/capsule_driver_rtl8139/src/init/mac.rs @@ -14,18 +14,43 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -use crate::constants::regs::REG_MAC0; +use nonos_mac::apply; + +use crate::constants::regs::{CFG9346_LOCK, CFG9346_UNLOCK, REG_CFG9346, REG_MAC0}; use crate::constants::MAC_LEN; use crate::pio::Pio; -pub fn read(pio: &Pio) -> Result<[u8; MAC_LEN], &'static str> { +/// Draw a station address and program it into the IDR registers. +/// +/// Replaces reading the factory address out of them: that address is unique to +/// the chip and every network the machine joins would log it. Fails closed, as +/// the other drivers do; the factory address is not a fallback. +pub fn program(pio: &Pio) -> Result<[u8; MAC_LEN], &'static str> { let mut mac = [0u8; MAC_LEN]; - for (i, byte) in mac.iter_mut().enumerate() { + let rc = nonos_libc::crypto_random(mac.as_mut_ptr(), MAC_LEN); + if rc < 0 || (rc as usize) != MAC_LEN { + return Err("rtl8139 no entropy for station address"); + } + apply(&mut mac); + + // IDR takes writes only with the config lock open, as dwords, the way + // 8139too's set_mac_address writes it; the lock closes on every path. + pio.w8(REG_CFG9346, CFG9346_UNLOCK)?; + let wrote = write_idr(pio, &mac); + pio.w8(REG_CFG9346, CFG9346_LOCK)?; + wrote?; + + let mut readback = [0u8; MAC_LEN]; + for (i, byte) in readback.iter_mut().enumerate() { *byte = pio.r8(REG_MAC0 + i as u16)?; } - if mac == [0; MAC_LEN] || mac == [0xFF; MAC_LEN] { - Err("rtl8139 invalid mac") - } else { - Ok(mac) + if readback != mac { + return Err("rtl8139 station address did not take"); } + Ok(mac) +} + +fn write_idr(pio: &Pio, mac: &[u8; MAC_LEN]) -> Result<(), &'static str> { + pio.w32(REG_MAC0, u32::from_le_bytes([mac[0], mac[1], mac[2], mac[3]]))?; + pio.w32(REG_MAC0 + 4, mac[4] as u32 | (mac[5] as u32) << 8) } diff --git a/userland/capsule_driver_rtl8139/src/init/mod.rs b/userland/capsule_driver_rtl8139/src/init/mod.rs index 4b46526d8c..86000769a7 100644 --- a/userland/capsule_driver_rtl8139/src/init/mod.rs +++ b/userland/capsule_driver_rtl8139/src/init/mod.rs @@ -21,3 +21,5 @@ mod rx_setup; mod tx_setup; pub use run::bring_up; +pub use rx_setup::restart as restart_rx; +pub use tx_setup::TCR; diff --git a/userland/capsule_driver_rtl8139/src/init/run.rs b/userland/capsule_driver_rtl8139/src/init/run.rs index fe4b82d7db..b4d31f133e 100644 --- a/userland/capsule_driver_rtl8139/src/init/run.rs +++ b/userland/capsule_driver_rtl8139/src/init/run.rs @@ -15,18 +15,30 @@ // along with this program. If not, see . use crate::constants::regs::{ - CMD_RX_ENABLE, CMD_TX_ENABLE, ISR_ENABLED, REG_CMD, REG_IMR, REG_ISR, + CMD_RX_ENABLE, CMD_TX_ENABLE, REG_CMD, REG_IMR, REG_ISR, }; use crate::setup::Driver; use super::{mac, reset, rx_setup, tx_setup}; +/* + * Rx and Tx are enabled before RCR and TCR are written. Linux 8139too, and + * the BSD rl and rtk drivers, all do it in this order ("Must enable Tx/Rx + * before setting transfer thresholds!"): on silicon where those writes do + * not take while the engines are off, RCR keeps its reset value and accepts + * nothing. + */ pub fn bring_up(driver: &mut Driver) -> Result<(), &'static str> { reset::run(&driver.pio)?; - driver.mac = mac::read(&driver.pio)?; + driver.mac = mac::program(&driver.pio)?; rx_setup::program(driver)?; tx_setup::program(driver)?; + driver.pio.w8(REG_CMD, CMD_RX_ENABLE | CMD_TX_ENABLE)?; + rx_setup::configure(driver)?; + tx_setup::configure(driver)?; driver.pio.w16(REG_ISR, 0xFFFF)?; - driver.pio.w16(REG_IMR, ISR_ENABLED)?; - driver.pio.w8(REG_CMD, CMD_RX_ENABLE | CMD_TX_ENABLE) + // The driver polls and binds no line, so the part raises none: an + // unmasked source nobody services holds a shared INTx asserted for + // every other device on it. ISR still latches, which is all it reads. + driver.pio.w16(REG_IMR, 0) } diff --git a/userland/capsule_driver_rtl8139/src/init/rx_setup.rs b/userland/capsule_driver_rtl8139/src/init/rx_setup.rs index 59aa1b06c6..8b6f8a937f 100644 --- a/userland/capsule_driver_rtl8139/src/init/rx_setup.rs +++ b/userland/capsule_driver_rtl8139/src/init/rx_setup.rs @@ -16,19 +16,42 @@ use crate::constants::dma::RX_BUF_DATA_BYTES; use crate::constants::regs::{ - RCR_ACCEPT_BCAST, RCR_ACCEPT_MULTI, RCR_ACCEPT_PHYS, RCR_MXDMA_UNLIMITED, RCR_WRAP, REG_CAPR, - REG_RBSTART, REG_RCR, + CMD_RX_ENABLE, CMD_TX_ENABLE, RCR_ACCEPT_BCAST, RCR_ACCEPT_MULTI, RCR_ACCEPT_PHYS, + RCR_MXDMA_UNLIMITED, RCR_RBLEN_32K, RCR_WRAP, REG_CAPR, REG_CMD, REG_RBSTART, REG_RCR, }; use crate::setup::Driver; +/// Receive configuration. Written only once the receiver is enabled (see +/// `run`): on parts where RCR does not take while RE is clear, the accept +/// bits would otherwise fall back to their reset value and nothing arrives. +pub const RCR: u32 = RCR_ACCEPT_PHYS + | RCR_ACCEPT_MULTI + | RCR_ACCEPT_BCAST + | RCR_WRAP + | RCR_MXDMA_UNLIMITED + | RCR_RBLEN_32K; + pub fn program(driver: &mut Driver) -> Result<(), &'static str> { driver.rx_offset = 0; driver.pio.w32(REG_RBSTART, driver.rx_device_addr as u32)?; - driver.pio.w16(REG_CAPR, capr_for(0))?; - driver.pio.w32( - REG_RCR, - RCR_ACCEPT_PHYS | RCR_ACCEPT_MULTI | RCR_ACCEPT_BCAST | RCR_WRAP | RCR_MXDMA_UNLIMITED, - ) + driver.pio.w16(REG_CAPR, capr_for(0)) +} + +pub fn configure(driver: &Driver) -> Result<(), &'static str> { + driver.pio.w32(REG_RCR, RCR) +} + +/* + * Receive from the top of the ring again, the way Linux 8139too's rx_err + * does. Used when the header at the read position is one the part never + * writes for a good frame: after a FIFO overrun real silicon can leave the + * ring position lost, and waiting on that header would stop receive for good. + */ +pub fn restart(driver: &mut Driver) -> Result<(), &'static str> { + driver.pio.w8(REG_CMD, CMD_TX_ENABLE)?; + driver.pio.w8(REG_CMD, CMD_RX_ENABLE | CMD_TX_ENABLE)?; + configure(driver)?; + program(driver) } fn capr_for(offset: usize) -> u16 { diff --git a/userland/capsule_driver_rtl8139/src/init/tx_setup.rs b/userland/capsule_driver_rtl8139/src/init/tx_setup.rs index 1b5cb69a20..40845ec88a 100644 --- a/userland/capsule_driver_rtl8139/src/init/tx_setup.rs +++ b/userland/capsule_driver_rtl8139/src/init/tx_setup.rs @@ -18,11 +18,19 @@ use crate::constants::dma::{TX_SLOT_BYTES, TX_SLOT_COUNT}; use crate::constants::regs::{REG_TCR, REG_TXADDR0, TCR_MXDMA_UNLIMITED}; use crate::setup::Driver; +/// Transmit configuration, written once the transmitter is enabled (see `run`). +pub const TCR: u32 = TCR_MXDMA_UNLIMITED; + pub fn program(driver: &mut Driver) -> Result<(), &'static str> { for idx in 0..TX_SLOT_COUNT { let addr = driver.tx_device_addr + (idx * TX_SLOT_BYTES) as u64; driver.pio.w32(REG_TXADDR0 + (idx as u16 * 4), addr as u32)?; } driver.tx_cur = 0; - driver.pio.w32(REG_TCR, TCR_MXDMA_UNLIMITED) + driver.tx_dirty = 0; + Ok(()) +} + +pub fn configure(driver: &Driver) -> Result<(), &'static str> { + driver.pio.w32(REG_TCR, TCR) } diff --git a/userland/capsule_driver_rtl8139/src/protocol/header.rs b/userland/capsule_driver_rtl8139/src/protocol/header.rs index 83787ec166..f610b5fa5e 100644 --- a/userland/capsule_driver_rtl8139/src/protocol/header.rs +++ b/userland/capsule_driver_rtl8139/src/protocol/header.rs @@ -14,7 +14,10 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -pub const MAGIC: u32 = 0x4E52_3839; +/// "NNET", the NIC protocol net_core and net_l2 speak (virtio-net's too). +/// The per-driver tag this replaced made every request from the stack +/// undecodable, so the wired NICs never served it. +pub const MAGIC: u32 = 0x4E4E_4554; pub const VERSION: u16 = 1; pub const HDR_LEN: usize = 20; pub const RESP_HDR_LEN: usize = HDR_LEN; diff --git a/userland/capsule_driver_rtl8139/src/protocol/mod.rs b/userland/capsule_driver_rtl8139/src/protocol/mod.rs index 8f11313832..adce81f355 100644 --- a/userland/capsule_driver_rtl8139/src/protocol/mod.rs +++ b/userland/capsule_driver_rtl8139/src/protocol/mod.rs @@ -16,7 +16,6 @@ mod decode; mod encode; -mod endpoint; mod errno; mod header; mod limits; @@ -24,7 +23,6 @@ mod ops; pub use decode::decode_request; pub use encode::{encode_response_header, write_status}; -pub use endpoint::KERNEL_REPLY_ENDPOINT; pub use errno::{E_AGAIN, E_INVAL, E_IO, E_MSGSIZE}; pub use header::{Request, HDR_LEN, RESP_HDR_LEN}; pub use limits::{ diff --git a/userland/capsule_driver_rtl8139/src/rx/read_frame.rs b/userland/capsule_driver_rtl8139/src/rx/read_frame.rs index 3b7b812223..c3e0cae7d5 100644 --- a/userland/capsule_driver_rtl8139/src/rx/read_frame.rs +++ b/userland/capsule_driver_rtl8139/src/rx/read_frame.rs @@ -16,15 +16,22 @@ use core::sync::atomic::{compiler_fence, Ordering}; -use nonos_libc::mk_irq_ack; - use super::advance::advance; use super::copy_ring::copy_ring; use super::ring_u16::ring_u16; use crate::constants::regs::RX_STATUS_OK; use crate::constants::MAX_ETHERNET_FRAME; +use crate::init::restart_rx; use crate::setup::Driver; +/// The length the part shows while a frame is still being written (early RX). +const RX_STILL_ARRIVING: usize = 0xFFF0; +/// Longest frame plus CRC the part hands up with ROK set (Linux 8139too's +/// MAX_ETH_FRAME_SIZE + 4); a longer length is a corrupt header. +const RX_MAX_RAW: usize = 1792 + 4; +/// Shortest raw length a real header carries. +const RX_MIN_RAW: usize = 8; + pub(super) fn read_frame( driver: &mut Driver, out: &mut [u8], @@ -34,17 +41,25 @@ pub(super) fn read_frame( let off = driver.rx_offset; let status = ring_u16(base, off); let raw_len = ring_u16(base, off + 2) as usize; - if (status & RX_STATUS_OK) == 0 || raw_len <= 4 { - let _ = mk_irq_ack(driver.irq_grant); - return Err("rtl8139 rx descriptor error"); + if raw_len == RX_STILL_ARRIVING { + return Ok(None); + } + /* + * Returning here without moving on read the same header forever: one bad + * header ended receive until the capsule restarted. A header no good frame + * carries means the position is lost, so receive starts over. + */ + if (status & RX_STATUS_OK) == 0 || raw_len < RX_MIN_RAW || raw_len > RX_MAX_RAW { + restart_rx(driver)?; + return Err("rtl8139 rx ring restarted"); } let frame_len = raw_len - 4; + // A good frame the caller cannot take (a tagged full-size one): skip it. if frame_len > MAX_ETHERNET_FRAME || frame_len > out.len() { - let _ = mk_irq_ack(driver.irq_grant); + advance(driver, raw_len)?; return Err("rtl8139 rx frame too large"); } copy_ring(base, off + 4, out, frame_len); advance(driver, raw_len)?; - let _ = mk_irq_ack(driver.irq_grant); Ok(Some(frame_len)) } diff --git a/userland/capsule_driver_rtl8139/src/rx/recv_one.rs b/userland/capsule_driver_rtl8139/src/rx/recv_one.rs index 5ebb8dbd53..6b98b47344 100644 --- a/userland/capsule_driver_rtl8139/src/rx/recv_one.rs +++ b/userland/capsule_driver_rtl8139/src/rx/recv_one.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_libc::mk_irq_ack; - use super::read_frame::read_frame; use crate::constants::regs::{ CMD_RX_BUF_EMPTY, ISR_ENABLED, ISR_RX_ERR, ISR_RX_FIFO_OVERFLOW, ISR_RX_OVERFLOW, REG_CMD, @@ -29,11 +27,9 @@ pub fn recv_one(driver: &mut Driver, out: &mut [u8]) -> Result, &' driver.pio.w16(REG_ISR, isr & ISR_ENABLED)?; } if (isr & (ISR_RX_ERR | ISR_RX_OVERFLOW | ISR_RX_FIFO_OVERFLOW)) != 0 { - let _ = mk_irq_ack(driver.irq_grant); return Err("rtl8139 rx interrupt error"); } if (driver.pio.r8(REG_CMD)? & CMD_RX_BUF_EMPTY) != 0 { - let _ = mk_irq_ack(driver.irq_grant); return Ok(None); } read_frame(driver, out) diff --git a/userland/capsule_driver_rtl8139/src/rx/ring_u8.rs b/userland/capsule_driver_rtl8139/src/rx/ring_u8.rs index 2068a24101..54dc4ef59e 100644 --- a/userland/capsule_driver_rtl8139/src/rx/ring_u8.rs +++ b/userland/capsule_driver_rtl8139/src/rx/ring_u8.rs @@ -14,8 +14,18 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -use crate::constants::dma::RX_BUF_DATA_BYTES; +use crate::constants::dma::RX_BUF_BYTES; +/* + * Linear, not modulo the ring. RCR.WRAP is set, so a frame that crosses the + * end of the ring is written on past it into the slack after, not back at + * the start: taking its tail from offset 0 handed up stale bytes once a lap. + * Offsets stay below the allocation (the header is inside the ring and the + * frame is length-checked first), and anything that would not reads as zero. + */ pub(super) fn ring_u8(base: u64, off: usize) -> u8 { - unsafe { core::ptr::read_volatile((base + (off % RX_BUF_DATA_BYTES) as u64) as *const u8) } + if off >= RX_BUF_BYTES { + return 0; + } + unsafe { core::ptr::read_volatile((base + off as u64) as *const u8) } } diff --git a/userland/capsule_driver_rtl8139/src/server/error.rs b/userland/capsule_driver_rtl8139/src/server/error.rs index 7eedd252d3..6bebade1fc 100644 --- a/userland/capsule_driver_rtl8139/src/server/error.rs +++ b/userland/capsule_driver_rtl8139/src/server/error.rs @@ -14,19 +14,24 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -use nonos_libc::mk_ipc_send; +use nonos_libc::mk_ipc_reply; -use crate::protocol::{ - encode_response_header, write_status, Request, KERNEL_REPLY_ENDPOINT, RESP_HDR_LEN, STATUS_LEN, -}; +use crate::protocol::{encode_response_header, write_status, Request, RESP_HDR_LEN, STATUS_LEN}; -pub fn reply_with_status(tx: &mut [u8], req: &Request, status: i32) { +/// Answer the capsule that sent the request. Replies used to go to a fixed +/// kernel-side inbox, which nothing reads now that the stack is a capsule, +/// so every call from net_core timed out. +pub fn reply(sender: u32, tx: &[u8], len: usize) { + let _ = mk_ipc_reply(sender, tx.as_ptr(), len); +} + +pub fn reply_with_status(sender: u32, tx: &mut [u8], req: &Request, status: i32) { encode_response_header(tx, req, STATUS_LEN as u32); write_status(&mut tx[RESP_HDR_LEN..], status); - let _ = mk_ipc_send(KERNEL_REPLY_ENDPOINT, tx.as_ptr(), RESP_HDR_LEN + STATUS_LEN); + reply(sender, tx, RESP_HDR_LEN + STATUS_LEN); } -pub fn reply_decode_failed(tx: &mut [u8], status: i32) { +pub fn reply_decode_failed(sender: u32, tx: &mut [u8], status: i32) { let req = Request { op: 0, flags: 0, request_id: 0, payload_len: 0 }; - reply_with_status(tx, &req, status); + reply_with_status(sender, tx, &req, status); } diff --git a/userland/capsule_driver_rtl8139/src/server/handlers/health.rs b/userland/capsule_driver_rtl8139/src/server/handlers/health.rs index 09630f8dc9..4e484ff65e 100644 --- a/userland/capsule_driver_rtl8139/src/server/handlers/health.rs +++ b/userland/capsule_driver_rtl8139/src/server/handlers/health.rs @@ -17,6 +17,6 @@ use crate::protocol::Request; use crate::server::error::reply_with_status; -pub fn handle(req: &Request, tx: &mut [u8]) { - reply_with_status(tx, req, 0); +pub fn handle(sender: u32, req: &Request, tx: &mut [u8]) { + reply_with_status(sender, tx, req, 0); } diff --git a/userland/capsule_driver_rtl8139/src/server/handlers/link_status.rs b/userland/capsule_driver_rtl8139/src/server/handlers/link_status.rs index 9969ae8999..ed81993846 100644 --- a/userland/capsule_driver_rtl8139/src/server/handlers/link_status.rs +++ b/userland/capsule_driver_rtl8139/src/server/handlers/link_status.rs @@ -14,30 +14,24 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -use nonos_libc::mk_ipc_send; - use crate::constants::regs::{MSR_LINK_BAD, REG_MSR}; use crate::protocol::{ - encode_response_header, write_status, Request, KERNEL_REPLY_ENDPOINT, LINK_STATUS_PAYLOAD_LEN, - RESP_HDR_LEN, STATUS_LEN, + encode_response_header, write_status, Request, LINK_STATUS_PAYLOAD_LEN, RESP_HDR_LEN, + STATUS_LEN, }; -use crate::server::error::reply_with_status; +use crate::server::error::{reply, reply_with_status}; use crate::setup::Driver; -pub fn handle(driver: &Driver, req: &Request, tx: &mut [u8]) { +pub fn handle(sender: u32, driver: &Driver, req: &Request, tx: &mut [u8]) { let link_up = match driver.pio.r8(REG_MSR) { Ok(v) => ((v & MSR_LINK_BAD) == 0) as u8, Err(_) => { - reply_with_status(tx, req, -5); + reply_with_status(sender, tx, req, -5); return; } }; encode_response_header(tx, req, (STATUS_LEN + LINK_STATUS_PAYLOAD_LEN) as u32); write_status(&mut tx[RESP_HDR_LEN..], 0); tx[RESP_HDR_LEN + STATUS_LEN] = link_up; - let _ = mk_ipc_send( - KERNEL_REPLY_ENDPOINT, - tx.as_ptr(), - RESP_HDR_LEN + STATUS_LEN + LINK_STATUS_PAYLOAD_LEN, - ); + reply(sender, tx, RESP_HDR_LEN + STATUS_LEN + LINK_STATUS_PAYLOAD_LEN); } diff --git a/userland/capsule_driver_rtl8139/src/server/handlers/mac_address.rs b/userland/capsule_driver_rtl8139/src/server/handlers/mac_address.rs index 1a5711b4a3..35a232d54b 100644 --- a/userland/capsule_driver_rtl8139/src/server/handlers/mac_address.rs +++ b/userland/capsule_driver_rtl8139/src/server/handlers/mac_address.rs @@ -14,18 +14,17 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -use nonos_libc::mk_ipc_send; - use crate::protocol::{ - encode_response_header, write_status, Request, KERNEL_REPLY_ENDPOINT, MAC_ADDRESS_PAYLOAD_LEN, - RESP_HDR_LEN, STATUS_LEN, + encode_response_header, write_status, Request, MAC_ADDRESS_PAYLOAD_LEN, RESP_HDR_LEN, + STATUS_LEN, }; +use crate::server::error::reply; use crate::setup::Driver; -pub fn handle(driver: &Driver, req: &Request, tx: &mut [u8]) { +pub fn handle(sender: u32, driver: &Driver, req: &Request, tx: &mut [u8]) { encode_response_header(tx, req, (STATUS_LEN + MAC_ADDRESS_PAYLOAD_LEN) as u32); write_status(&mut tx[RESP_HDR_LEN..], 0); let off = RESP_HDR_LEN + STATUS_LEN; tx[off..off + MAC_ADDRESS_PAYLOAD_LEN].copy_from_slice(&driver.mac); - let _ = mk_ipc_send(KERNEL_REPLY_ENDPOINT, tx.as_ptr(), off + MAC_ADDRESS_PAYLOAD_LEN); + reply(sender, tx, off + MAC_ADDRESS_PAYLOAD_LEN); } diff --git a/userland/capsule_driver_rtl8139/src/server/handlers/rx_packet.rs b/userland/capsule_driver_rtl8139/src/server/handlers/rx_packet.rs index 3832172364..c07d8c76a8 100644 --- a/userland/capsule_driver_rtl8139/src/server/handlers/rx_packet.rs +++ b/userland/capsule_driver_rtl8139/src/server/handlers/rx_packet.rs @@ -14,26 +14,24 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -use nonos_libc::mk_ipc_send; - use crate::protocol::{ - encode_response_header, write_status, Request, E_AGAIN, E_IO, KERNEL_REPLY_ENDPOINT, - RESP_HDR_LEN, RX_PAYLOAD_PREFIX_LEN, STATUS_LEN, + encode_response_header, write_status, Request, E_AGAIN, E_IO, RESP_HDR_LEN, + RX_PAYLOAD_PREFIX_LEN, STATUS_LEN, }; use crate::rx::recv_one; -use crate::server::error::reply_with_status; +use crate::server::error::{reply, reply_with_status}; use crate::setup::Driver; -pub fn handle(driver: &mut Driver, req: &Request, tx: &mut [u8]) { +pub fn handle(sender: u32, driver: &mut Driver, req: &Request, tx: &mut [u8]) { let body_off = RESP_HDR_LEN + STATUS_LEN + RX_PAYLOAD_PREFIX_LEN; let frame_len = match recv_one(driver, &mut tx[body_off..]) { Ok(Some(n)) => n, Ok(None) => { - reply_with_status(tx, req, E_AGAIN); + reply_with_status(sender, tx, req, E_AGAIN); return; } Err(_) => { - reply_with_status(tx, req, E_IO); + reply_with_status(sender, tx, req, E_IO); return; } }; @@ -41,5 +39,5 @@ pub fn handle(driver: &mut Driver, req: &Request, tx: &mut [u8]) { encode_response_header(tx, req, STATUS_LEN as u32 + body_len as u32); write_status(&mut tx[RESP_HDR_LEN..], 0); tx[RESP_HDR_LEN + STATUS_LEN..body_off].copy_from_slice(&(frame_len as u32).to_le_bytes()); - let _ = mk_ipc_send(KERNEL_REPLY_ENDPOINT, tx.as_ptr(), body_off + frame_len); + reply(sender, tx, body_off + frame_len); } diff --git a/userland/capsule_driver_rtl8139/src/server/handlers/stats.rs b/userland/capsule_driver_rtl8139/src/server/handlers/stats.rs index 8351d20f06..6bec8e4829 100644 --- a/userland/capsule_driver_rtl8139/src/server/handlers/stats.rs +++ b/userland/capsule_driver_rtl8139/src/server/handlers/stats.rs @@ -14,23 +14,22 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -use nonos_libc::mk_ipc_send; - +use crate::constants::dma::TX_SLOT_COUNT; use crate::constants::regs::{ REG_CAPR, REG_CMD, REG_ISR, REG_MSR, REG_RCR, REG_TCR, REG_TXSTATUS0, }; use crate::protocol::{ - encode_response_header, write_status, Request, E_IO, KERNEL_REPLY_ENDPOINT, RESP_HDR_LEN, - STATS_PAYLOAD_LEN, STATUS_LEN, + encode_response_header, write_status, Request, E_IO, RESP_HDR_LEN, STATS_PAYLOAD_LEN, + STATUS_LEN, }; -use crate::server::error::reply_with_status; +use crate::server::error::{reply, reply_with_status}; use crate::setup::Driver; -pub fn handle(driver: &Driver, req: &Request, tx: &mut [u8]) { +pub fn handle(sender: u32, driver: &Driver, req: &Request, tx: &mut [u8]) { let regs = match live_regs(driver) { Ok(v) => v, Err(()) => { - reply_with_status(tx, req, E_IO); + reply_with_status(sender, tx, req, E_IO); return; } }; @@ -41,7 +40,7 @@ pub fn handle(driver: &Driver, req: &Request, tx: &mut [u8]) { for v in regs { put32(tx, &mut o, v); } - let _ = mk_ipc_send(KERNEL_REPLY_ENDPOINT, tx.as_ptr(), RESP_HDR_LEN + payload_len as usize); + reply(sender, tx, RESP_HDR_LEN + payload_len as usize); } fn live_regs(driver: &Driver) -> Result<[u32; 12], ()> { @@ -57,7 +56,7 @@ fn live_regs(driver: &Driver) -> Result<[u32; 12], ()> { driver.pio.r32(REG_TXSTATUS0 + 8).map_err(|_| ())?, driver.pio.r32(REG_TXSTATUS0 + 12).map_err(|_| ())?, driver.rx_offset as u32, - driver.tx_cur as u32, + (driver.tx_cur % TX_SLOT_COUNT) as u32, ]) } diff --git a/userland/capsule_driver_rtl8139/src/server/handlers/tx_packet.rs b/userland/capsule_driver_rtl8139/src/server/handlers/tx_packet.rs index 38185d2841..a90d6a0ada 100644 --- a/userland/capsule_driver_rtl8139/src/server/handlers/tx_packet.rs +++ b/userland/capsule_driver_rtl8139/src/server/handlers/tx_packet.rs @@ -15,25 +15,33 @@ // along with this program. If not, see . use crate::constants::{MAX_ETHERNET_FRAME, MIN_ETHERNET_FRAME}; -use crate::protocol::{Request, E_INVAL, E_IO, E_MSGSIZE, MAX_TX_PAYLOAD_BYTES}; +use crate::protocol::{Request, E_AGAIN, E_INVAL, E_IO, E_MSGSIZE, MAX_TX_PAYLOAD_BYTES}; use crate::server::error::reply_with_status; use crate::setup::Driver; -use crate::tx::send; +use crate::tx::{full, reclaim, send}; -pub fn handle(driver: &mut Driver, req: &Request, body: &[u8], tx: &mut [u8]) { +pub fn handle(sender: u32, driver: &mut Driver, req: &Request, body: &[u8], tx: &mut [u8]) { if req.payload_len as usize != body.len() { - reply_with_status(tx, req, E_MSGSIZE); + reply_with_status(sender, tx, req, E_MSGSIZE); return; } if body.len() < MIN_ETHERNET_FRAME || body.len() > MAX_ETHERNET_FRAME || body.len() as u32 > MAX_TX_PAYLOAD_BYTES { - reply_with_status(tx, req, E_INVAL); + reply_with_status(sender, tx, req, E_INVAL); + return; + } + if reclaim(driver).is_err() { + reply_with_status(sender, tx, req, E_IO); + return; + } + if full(driver) { + reply_with_status(sender, tx, req, E_AGAIN); return; } match send(driver, body) { - Ok(()) => reply_with_status(tx, req, 0), - Err(_) => reply_with_status(tx, req, E_IO), + Ok(()) => reply_with_status(sender, tx, req, 0), + Err(_) => reply_with_status(sender, tx, req, E_IO), } } diff --git a/userland/capsule_driver_rtl8139/src/server/runner.rs b/userland/capsule_driver_rtl8139/src/server/runner.rs index 5506760f42..b65613da2d 100644 --- a/userland/capsule_driver_rtl8139/src/server/runner.rs +++ b/userland/capsule_driver_rtl8139/src/server/runner.rs @@ -16,7 +16,7 @@ use alloc::vec; -use nonos_libc::mk_ipc_recv; +use nonos_libc::mk_ipc_recv_from; use crate::constants::MAX_ETHERNET_FRAME; use crate::protocol::{ @@ -42,26 +42,27 @@ pub fn run(driver: &mut Driver) -> ! { } fn dispatch_once(driver: &mut Driver, rx: &mut [u8], tx: &mut [u8]) { - let n = mk_ipc_recv(SERVICE_INBOX, rx.as_mut_ptr(), rx.len(), 0); - if n <= 0 { + let mut sender: u32 = 0; + let n = mk_ipc_recv_from(SERVICE_INBOX, rx.as_mut_ptr(), rx.len(), 0, &mut sender); + if n <= 0 || sender == 0 { return; } let len = n as usize; let req = match decode_request(&rx[..len]) { Some(r) => r, None => { - reply_decode_failed(tx, E_INVAL); + reply_decode_failed(sender, tx, E_INVAL); return; } }; let body = &rx[HDR_LEN..len]; match req.op { - OP_HEALTHCHECK => handlers::health::handle(&req, tx), - OP_LINK_STATUS => handlers::link_status::handle(driver, &req, tx), - OP_MAC_ADDRESS => handlers::mac_address::handle(driver, &req, tx), - OP_TX_PACKET => handlers::tx_packet::handle(driver, &req, body, tx), - OP_RX_PACKET => handlers::rx_packet::handle(driver, &req, tx), - OP_STATS => handlers::stats::handle(driver, &req, tx), - _ => reply_with_status(tx, &req, E_INVAL), + OP_HEALTHCHECK => handlers::health::handle(sender, &req, tx), + OP_LINK_STATUS => handlers::link_status::handle(sender, driver, &req, tx), + OP_MAC_ADDRESS => handlers::mac_address::handle(sender, driver, &req, tx), + OP_TX_PACKET => handlers::tx_packet::handle(sender, driver, &req, body, tx), + OP_RX_PACKET => handlers::rx_packet::handle(sender, driver, &req, tx), + OP_STATS => handlers::stats::handle(sender, driver, &req, tx), + _ => reply_with_status(sender, tx, &req, E_INVAL), } } diff --git a/userland/capsule_driver_rtl8139/src/setup/dma.rs b/userland/capsule_driver_rtl8139/src/setup/dma.rs index 0fe07ce0db..f1d795f1f7 100644 --- a/userland/capsule_driver_rtl8139/src/setup/dma.rs +++ b/userland/capsule_driver_rtl8139/src/setup/dma.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 nonos_libc::{mk_dma_map, DmaMapOut, IrqBindOut, PioGrantOut}; +use nonos_libc::{mk_dma_map, DmaMapOut, PioGrantOut}; use crate::constants::dma::{RX_BUF_BYTES, TX_BUF_BYTES}; @@ -40,18 +40,17 @@ pub fn map_all( device_id: u64, epoch: u64, pio: &PioGrantOut, - irq: &IrqBindOut, ) -> Result<(DmaMapOut, DmaMapOut), &'static str> { let rx = alloc(device_id, epoch, RX_BUF_BYTES as u64).ok_or_else(|| { - rollback::after_irq(device_id, pio, irq, &[]); + rollback::after_pio(device_id, pio, &[]); "rx dma failed" })?; let tx = alloc(device_id, epoch, TX_BUF_BYTES as u64).ok_or_else(|| { - rollback::after_irq(device_id, pio, irq, &[rx.grant_id]); + rollback::after_pio(device_id, pio, &[rx.grant_id]); "tx dma failed" })?; if rx.device_addr > u32::MAX as u64 || tx.device_addr > u32::MAX as u64 { - rollback::after_irq(device_id, pio, irq, &[tx.grant_id, rx.grant_id]); + rollback::after_pio(device_id, pio, &[tx.grant_id, rx.grant_id]); return Err("rtl8139 requires 32-bit dma"); } Ok((rx, tx)) diff --git a/userland/capsule_driver_rtl8139/src/setup/driver.rs b/userland/capsule_driver_rtl8139/src/setup/driver.rs index eaa658e542..f59afa0a6d 100644 --- a/userland/capsule_driver_rtl8139/src/setup/driver.rs +++ b/userland/capsule_driver_rtl8139/src/setup/driver.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 nonos_libc::{mk_device_release, mk_dma_unmap, mk_irq_unbind, mk_pio_release}; +use nonos_libc::{mk_device_release, mk_dma_unmap, mk_pio_release}; use crate::constants::MAC_LEN; use crate::pio::Pio; @@ -22,7 +22,6 @@ use crate::pio::Pio; pub struct Driver { pub device_id: u64, pub pio_grant: u64, - pub irq_grant: u64, pub rx_grant: u64, pub tx_grant: u64, pub rx_user_va: u64, @@ -30,7 +29,10 @@ pub struct Driver { pub tx_user_va: u64, pub tx_device_addr: u64, pub rx_offset: usize, + /// Frames handed to the part, counting up; the slot is `tx_cur % 4`. pub tx_cur: usize, + /// Frames the part has finished with; `tx_cur - tx_dirty` are in flight. + pub tx_dirty: usize, pub pio: Pio, pub mac: [u8; MAC_LEN], } @@ -39,7 +41,6 @@ impl Driver { pub fn release(&self) { let _ = mk_dma_unmap(self.tx_grant); let _ = mk_dma_unmap(self.rx_grant); - let _ = mk_irq_unbind(self.irq_grant); let _ = mk_pio_release(self.pio_grant); let _ = mk_device_release(self.device_id); } diff --git a/userland/capsule_driver_rtl8139/src/setup/mod.rs b/userland/capsule_driver_rtl8139/src/setup/mod.rs index c3dd5c6e41..a9315f5d1c 100644 --- a/userland/capsule_driver_rtl8139/src/setup/mod.rs +++ b/userland/capsule_driver_rtl8139/src/setup/mod.rs @@ -17,7 +17,6 @@ mod claim; mod dma; mod driver; -mod irq; mod pci; mod pio_grant; mod rollback; diff --git a/userland/capsule_driver_rtl8139/src/setup/rollback.rs b/userland/capsule_driver_rtl8139/src/setup/rollback.rs index 8b1a240c18..dba4df3083 100644 --- a/userland/capsule_driver_rtl8139/src/setup/rollback.rs +++ b/userland/capsule_driver_rtl8139/src/setup/rollback.rs @@ -14,15 +14,12 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -use nonos_libc::{ - mk_device_release, mk_dma_unmap, mk_irq_unbind, mk_pio_release, IrqBindOut, PioGrantOut, -}; +use nonos_libc::{mk_device_release, mk_dma_unmap, mk_pio_release, PioGrantOut}; -pub fn after_irq(device_id: u64, pio: &PioGrantOut, irq: &IrqBindOut, dma_grants: &[u64]) { +pub fn after_pio(device_id: u64, pio: &PioGrantOut, dma_grants: &[u64]) { for grant in dma_grants { let _ = mk_dma_unmap(*grant); } - let _ = mk_irq_unbind(irq.grant_id); let _ = mk_pio_release(pio.grant_id); let _ = mk_device_release(device_id); } diff --git a/userland/capsule_driver_rtl8139/src/setup/sequence.rs b/userland/capsule_driver_rtl8139/src/setup/sequence.rs index e44fc5460a..04743684d0 100644 --- a/userland/capsule_driver_rtl8139/src/setup/sequence.rs +++ b/userland/capsule_driver_rtl8139/src/setup/sequence.rs @@ -19,19 +19,17 @@ use crate::discover::find_rtl8139; use crate::pio::Pio; use super::driver::Driver; -use super::{claim, dma, irq, pci, pio_grant}; +use super::{claim, dma, pci, pio_grant}; pub fn run() -> Result { let dev = find_rtl8139().ok_or("no rtl8139 device")?; let epoch = claim::claim(dev.device_id)?; pci::enable(dev, epoch)?; let pio = pio_grant::grant(dev, epoch)?; - let irq = irq::bind(dev, epoch, &pio)?; - let (rx, tx) = dma::map_all(dev.device_id, epoch, &pio, &irq)?; + let (rx, tx) = dma::map_all(dev.device_id, epoch, &pio)?; Ok(Driver { device_id: dev.device_id, pio_grant: pio.grant_id, - irq_grant: irq.grant_id, rx_grant: rx.grant_id, tx_grant: tx.grant_id, rx_user_va: rx.user_va, @@ -40,6 +38,7 @@ pub fn run() -> Result { tx_device_addr: tx.device_addr, rx_offset: 0, tx_cur: 0, + tx_dirty: 0, pio: Pio::new(pio.grant_id), mac: [0u8; MAC_LEN], }) diff --git a/userland/capsule_driver_rtl8139/src/tx/mod.rs b/userland/capsule_driver_rtl8139/src/tx/mod.rs index 9ba7f0c1da..2afb0f51b8 100644 --- a/userland/capsule_driver_rtl8139/src/tx/mod.rs +++ b/userland/capsule_driver_rtl8139/src/tx/mod.rs @@ -14,7 +14,8 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -mod poll_done; +mod reclaim; mod send; +pub use reclaim::{full, reclaim}; pub use send::send; diff --git a/userland/capsule_driver_rtl8139/src/tx/reclaim.rs b/userland/capsule_driver_rtl8139/src/tx/reclaim.rs new file mode 100644 index 0000000000..b416352aaa --- /dev/null +++ b/userland/capsule_driver_rtl8139/src/tx/reclaim.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 . + +//! Walk `tx_dirty` over the descriptors the part has finished with. +//! +//! The part works through TSD0-3 strictly in order with a pointer of its own, +//! so the driver has to move with it: a descriptor is finished once its status +//! says the frame went (TOK), went after an underrun re-fetch (TUN), or was +//! given up on (TABT), and each of those moves the part on to the next one. +//! Staying on a slot the part has left made every later send wait on a +//! descriptor it would never look at again. An abort also halts the +//! transmitter until TCR.CLRABT is written, as Linux 8139too does. + +use crate::constants::dma::TX_SLOT_COUNT; +use crate::constants::regs::{ + REG_TCR, REG_TXSTATUS0, TCR_CLEAR_ABORT, TX_STATUS_ABORT, TX_STATUS_OK, TX_STATUS_UNDERRUN, +}; +use crate::init::TCR; +use crate::setup::Driver; + +pub fn reclaim(driver: &mut Driver) -> Result<(), &'static str> { + while driver.tx_dirty != driver.tx_cur { + let idx = driver.tx_dirty % TX_SLOT_COUNT; + let status = driver.pio.r32(REG_TXSTATUS0 + (idx as u16 * 4))?; + if status & (TX_STATUS_OK | TX_STATUS_UNDERRUN | TX_STATUS_ABORT) == 0 { + break; + } + if status & TX_STATUS_ABORT != 0 { + driver.pio.w32(REG_TCR, TCR | TCR_CLEAR_ABORT)?; + } + driver.tx_dirty = driver.tx_dirty.wrapping_add(1); + } + Ok(()) +} + +/// Whether every slot holds a frame the part has not finished with. +pub fn full(driver: &Driver) -> bool { + driver.tx_cur.wrapping_sub(driver.tx_dirty) >= TX_SLOT_COUNT +} diff --git a/userland/capsule_driver_rtl8139/src/tx/send.rs b/userland/capsule_driver_rtl8139/src/tx/send.rs index 97190566ec..151cfc6cae 100644 --- a/userland/capsule_driver_rtl8139/src/tx/send.rs +++ b/userland/capsule_driver_rtl8139/src/tx/send.rs @@ -16,21 +16,29 @@ use core::sync::atomic::{compiler_fence, Ordering}; -use super::poll_done::poll_done; use crate::constants::dma::{TX_SLOT_BYTES, TX_SLOT_COUNT}; -use crate::constants::regs::REG_TXSTATUS0; +use crate::constants::regs::{REG_TXSTATUS0, TSD_ERTXTH_256}; +use crate::constants::MIN_WIRE_FRAME; use crate::setup::Driver; +/* + * Hand one frame to the next slot. The caller has reclaimed and checked for + * room, so the part is finished with this slot. The frame is answered once it + * is queued: the part sends it when it can, and `reclaim` sees it done. The + * part does not pad, so a short frame is zero-filled to the 60-byte minimum. + */ pub fn send(driver: &mut Driver, frame: &[u8]) -> Result<(), &'static str> { - let idx = driver.tx_cur; + let idx = driver.tx_cur % TX_SLOT_COUNT; let va = driver.tx_user_va + (idx * TX_SLOT_BYTES) as u64; + let wire = frame.len().max(MIN_WIRE_FRAME); unsafe { - core::ptr::copy_nonoverlapping(frame.as_ptr(), va as *mut u8, frame.len()); + let dst = va as *mut u8; + core::ptr::copy_nonoverlapping(frame.as_ptr(), dst, frame.len()); + core::ptr::write_bytes(dst.add(frame.len()), 0, wire - frame.len()); } compiler_fence(Ordering::Release); let status_reg = REG_TXSTATUS0 + (idx as u16 * 4); - driver.pio.w32(status_reg, frame.len() as u32)?; - poll_done(driver, status_reg)?; - driver.tx_cur = (idx + 1) % TX_SLOT_COUNT; + driver.pio.w32(status_reg, wire as u32 | TSD_ERTXTH_256)?; + driver.tx_cur = driver.tx_cur.wrapping_add(1); Ok(()) } diff --git a/userland/capsule_driver_rtl8169/Capsule.mk b/userland/capsule_driver_rtl8169/Capsule.mk index a108f45f72..5b8cd895ae 100644 --- a/userland/capsule_driver_rtl8169/Capsule.mk +++ b/userland/capsule_driver_rtl8169/Capsule.mk @@ -1,4 +1,4 @@ -# RTL8169 — Realtek 8168/8169 gigabit NIC. PCI MMIO + INTx + DMA. +# RTL8169 — Realtek 8168/8169 gigabit NIC. PCI MMIO + DMA, polled. # Raw Ethernet frames only; network policy belongs to the net-stack # capsule. Signing, certificate, and manifest rules come from the # shared hybrid-signature capsule macro. @@ -12,7 +12,10 @@ CAPSULE_FEATURE := nonos-capsule-driver-rtl8169 CAPSULE_NAMESPACE := systems.nonos.driver.rtl8169_0 CAPSULE_SERVICE_ENDPOINT := service:4214:driver.rtl8169_0 CAPSULE_REPLY_ENDPOINT := reply:4215:endpoint.4294967310 -# IPC|Memory|Driver|DeviceEnum|Mmio|Irq|Dma = 0xF8019 -CAPSULE_REQUIRED_CAPS := 0xF8019 +# IPC|Memory|Crypto|Driver|DeviceEnum|Mmio|Dma = 0xB8039 +# Crypto (0x20) is what the CryptoRandom syscall is gated on. The station address +# is drawn rather than read out of the IDR, and that draw fails closed, so +# without this the card never comes up. No Irq: the driver polls. +CAPSULE_REQUIRED_CAPS := 0xB8039 include nonos-mk/capsule.mk diff --git a/userland/capsule_driver_rtl8169/src/constants/frame.rs b/userland/capsule_driver_rtl8169/src/constants/frame.rs index f7eac730bb..413cb0c280 100644 --- a/userland/capsule_driver_rtl8169/src/constants/frame.rs +++ b/userland/capsule_driver_rtl8169/src/constants/frame.rs @@ -18,5 +18,10 @@ const ETH_HEADER_LEN: usize = 14; const MTU: usize = 1500; pub const MAC_LEN: usize = 6; -pub const MIN_ETHERNET_FRAME: usize = 60; +/// A bare header is the shortest frame taken; ARP (42) and a bare TCP ACK (54) +/// are shorter than the wire minimum, and refusing them stranded IPv4. +pub const MIN_ETHERNET_FRAME: usize = ETH_HEADER_LEN; +/// `send` pads to this: several 8168 revisions do not pad short frames right +/// (Linux pads them in software for the same reason). +pub const MIN_WIRE_FRAME: usize = 60; pub const MAX_ETHERNET_FRAME: usize = MTU + ETH_HEADER_LEN; diff --git a/userland/capsule_driver_rtl8169/src/constants/mod.rs b/userland/capsule_driver_rtl8169/src/constants/mod.rs index 5c8fd24bcb..ec42a691d8 100644 --- a/userland/capsule_driver_rtl8169/src/constants/mod.rs +++ b/userland/capsule_driver_rtl8169/src/constants/mod.rs @@ -19,4 +19,4 @@ pub mod pci; pub mod queue; pub mod regs; -pub use frame::{MAC_LEN, MAX_ETHERNET_FRAME, MIN_ETHERNET_FRAME}; +pub use frame::{MAC_LEN, MAX_ETHERNET_FRAME, MIN_ETHERNET_FRAME, MIN_WIRE_FRAME}; diff --git a/userland/capsule_driver_rtl8169/src/constants/regs.rs b/userland/capsule_driver_rtl8169/src/constants/regs.rs index 12f0a47643..6a1351029a 100644 --- a/userland/capsule_driver_rtl8169/src/constants/regs.rs +++ b/userland/capsule_driver_rtl8169/src/constants/regs.rs @@ -36,7 +36,11 @@ pub const REG_RXDESC_ADDR_HI: usize = 0xE8; pub const CMD_RESET: u8 = 0x10; pub const CMD_RX_ENABLE: u8 = 0x08; pub const CMD_TX_ENABLE: u8 = 0x04; -pub const TX_POLL_HPQ: u8 = 0x80; +/// TPPoll bit 6 polls the normal-priority ring, the one TNPDS points at and +/// the only one set up. Bit 7 (0x80) is the high-priority ring, whose base +/// (THPDS) is never written: ringing it sent the part to fetch from zero, so +/// no frame was ever sent. +pub const TX_POLL_NPQ: u8 = 0x40; pub const TX_CONFIG_IFG: u32 = 3 << 24; pub const TX_CONFIG_DMA: u32 = 7 << 8; diff --git a/userland/capsule_driver_rtl8169/src/discover.rs b/userland/capsule_driver_rtl8169/src/discover.rs index f386712633..51c5583bcc 100644 --- a/userland/capsule_driver_rtl8169/src/discover.rs +++ b/userland/capsule_driver_rtl8169/src/discover.rs @@ -28,7 +28,6 @@ const MAX_DEVICES: usize = 32; #[derive(Debug, Clone, Copy)] pub struct Found { pub device_id: u64, - pub irq_line: u8, pub bar_index: u8, pub bar_size: u64, pub command_bits: u16, @@ -44,13 +43,12 @@ pub fn find_rtl8169() -> Option { if !is_supported(r) { continue; } - if r.irq_pin == 0 || r.irq_line == 0xFF { - continue; - } + // Interrupt routing is not asked for: the driver polls. UEFI firmware + // often leaves Interrupt Line at 0xFF, and filtering on it skipped a + // present card as absent on exactly the PCs this driver is for. if let Some((bar_index, bar_size)) = first_mmio_bar(r) { return Some(Found { device_id: r.device_id, - irq_line: r.irq_line, bar_index, bar_size, command_bits: command_bits(r), diff --git a/userland/capsule_driver_rtl8169/src/init/mac.rs b/userland/capsule_driver_rtl8169/src/init/mac.rs index 44bd950f4f..e8b7b6e9ff 100644 --- a/userland/capsule_driver_rtl8169/src/init/mac.rs +++ b/userland/capsule_driver_rtl8169/src/init/mac.rs @@ -32,12 +32,18 @@ pub fn program(regs: &Regs) -> Result<[u8; MAC_LEN], &'static str> { } apply(&mut mac); - // IDR writes are dropped while the config lock is set. + /* + * IDR writes are dropped while the config lock is set, and the part takes + * them only as whole dwords (reads may be any width): byte writes were + * dropped on silicon, the readback below failed, and the NIC never came up. + * High dword first, each read back to post it, as Linux rtl_rar_set does. + */ unsafe { regs.w8(REG_CFG9346, CFG9346_UNLOCK); - for (i, byte) in mac.iter().enumerate() { - regs.w8(REG_MAC0 + i, *byte); - } + regs.w32(REG_MAC0 + 4, mac[4] as u32 | (mac[5] as u32) << 8); + let _ = regs.r32(REG_MAC0 + 4); + regs.w32(REG_MAC0, u32::from_le_bytes([mac[0], mac[1], mac[2], mac[3]])); + let _ = regs.r32(REG_MAC0); regs.w8(REG_CFG9346, CFG9346_LOCK); } diff --git a/userland/capsule_driver_rtl8169/src/init/run.rs b/userland/capsule_driver_rtl8169/src/init/run.rs index f0dca67882..7b32d54472 100644 --- a/userland/capsule_driver_rtl8169/src/init/run.rs +++ b/userland/capsule_driver_rtl8169/src/init/run.rs @@ -15,21 +15,35 @@ // along with this program. If not, see . use crate::constants::regs::{ - CMD_RX_ENABLE, CMD_TX_ENABLE, ISR_ENABLED, REG_CMD, REG_IMR, REG_ISR, + CMD_RX_ENABLE, CMD_TX_ENABLE, REG_CMD, REG_IMR, REG_ISR, }; use crate::setup::Driver; use super::{mac, reset, rx_setup, tx_setup}; +/* + * TE|RE go on before RxConfig and TxConfig are written, as Linux rtl_hw_start + * does on every chip: on the 8169 and the 8168B-F an RxConfig written with + * the receiver off can be lost, the accept bits never take, and nothing is + * received. The station address is still programmed first, so the part is + * never enabled under the factory one. + */ pub fn bring_up(driver: &mut Driver) -> Result<(), &'static str> { reset::run(&driver.regs)?; driver.mac = mac::program(&driver.regs)?; rx_setup::program(&driver.regs, &driver.rx); tx_setup::program(&driver.regs, &driver.tx); unsafe { - driver.regs.w16(REG_ISR, 0xFFFF); - driver.regs.w16(REG_IMR, ISR_ENABLED); driver.regs.w8(REG_CMD, CMD_RX_ENABLE | CMD_TX_ENABLE); } + rx_setup::configure(&driver.regs); + tx_setup::configure(&driver.regs); + unsafe { + driver.regs.w16(REG_ISR, 0xFFFF); + // The driver polls and binds no line, so the part raises none: an + // unmasked source nobody services holds a shared INTx asserted for + // every other device on it. ISR still latches, which is all it reads. + driver.regs.w16(REG_IMR, 0); + } Ok(()) } diff --git a/userland/capsule_driver_rtl8169/src/init/rx_setup.rs b/userland/capsule_driver_rtl8169/src/init/rx_setup.rs index 2bfa444649..0b672fdd2c 100644 --- a/userland/capsule_driver_rtl8169/src/init/rx_setup.rs +++ b/userland/capsule_driver_rtl8169/src/init/rx_setup.rs @@ -42,6 +42,12 @@ pub fn program(regs: &Regs, rx: &RxRing) { regs.w16(REG_RMS, BUFFER_SIZE as u16); regs.w32(REG_RXDESC_ADDR_LO, rx.desc_da as u32); regs.w32(REG_RXDESC_ADDR_HI, (rx.desc_da >> 32) as u32); + } +} + +/// RxConfig, written once the receiver is enabled (see `run`). +pub fn configure(regs: &Regs) { + unsafe { regs.w32( REG_RX_CONFIG, RX_CONFIG_ACCEPT_PHYS diff --git a/userland/capsule_driver_rtl8169/src/init/tx_setup.rs b/userland/capsule_driver_rtl8169/src/init/tx_setup.rs index 2db4568624..a3d8c49216 100644 --- a/userland/capsule_driver_rtl8169/src/init/tx_setup.rs +++ b/userland/capsule_driver_rtl8169/src/init/tx_setup.rs @@ -35,6 +35,12 @@ pub fn program(regs: &Regs, tx: &TxRing) { unsafe { regs.w32(REG_TXDESC_ADDR_LO, tx.desc_da as u32); regs.w32(REG_TXDESC_ADDR_HI, (tx.desc_da >> 32) as u32); + } +} + +/// TxConfig, written once the transmitter is enabled (see `run`). +pub fn configure(regs: &Regs) { + unsafe { regs.w32(REG_TX_CONFIG, TX_CONFIG_IFG | TX_CONFIG_DMA); } } diff --git a/userland/capsule_driver_rtl8169/src/protocol/header.rs b/userland/capsule_driver_rtl8169/src/protocol/header.rs index dda75274ce..f610b5fa5e 100644 --- a/userland/capsule_driver_rtl8169/src/protocol/header.rs +++ b/userland/capsule_driver_rtl8169/src/protocol/header.rs @@ -14,7 +14,10 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -pub const MAGIC: u32 = 0x4E52_3639; +/// "NNET", the NIC protocol net_core and net_l2 speak (virtio-net's too). +/// The per-driver tag this replaced made every request from the stack +/// undecodable, so the wired NICs never served it. +pub const MAGIC: u32 = 0x4E4E_4554; pub const VERSION: u16 = 1; pub const HDR_LEN: usize = 20; pub const RESP_HDR_LEN: usize = HDR_LEN; diff --git a/userland/capsule_driver_rtl8169/src/protocol/mod.rs b/userland/capsule_driver_rtl8169/src/protocol/mod.rs index 8f11313832..adce81f355 100644 --- a/userland/capsule_driver_rtl8169/src/protocol/mod.rs +++ b/userland/capsule_driver_rtl8169/src/protocol/mod.rs @@ -16,7 +16,6 @@ mod decode; mod encode; -mod endpoint; mod errno; mod header; mod limits; @@ -24,7 +23,6 @@ mod ops; pub use decode::decode_request; pub use encode::{encode_response_header, write_status}; -pub use endpoint::KERNEL_REPLY_ENDPOINT; pub use errno::{E_AGAIN, E_INVAL, E_IO, E_MSGSIZE}; pub use header::{Request, HDR_LEN, RESP_HDR_LEN}; pub use limits::{ diff --git a/userland/capsule_driver_rtl8169/src/rx/recv_one.rs b/userland/capsule_driver_rtl8169/src/rx/recv_one.rs index 8e28a1494c..79de2a6dfa 100644 --- a/userland/capsule_driver_rtl8169/src/rx/recv_one.rs +++ b/userland/capsule_driver_rtl8169/src/rx/recv_one.rs @@ -16,8 +16,6 @@ use core::sync::atomic::{compiler_fence, Ordering}; -use nonos_libc::mk_irq_ack; - use super::rearm::rearm; use crate::constants::queue::{BUFFER_SIZE, RX_DESC_COUNT}; use crate::constants::regs::{ @@ -35,14 +33,12 @@ pub fn recv_one(driver: &mut Driver, out: &mut [u8]) -> Result, &' } } if (isr & ISR_RER) != 0 { - let _ = mk_irq_ack(driver.irq_grant); return Err("rtl8169 rx interrupt error"); } compiler_fence(Ordering::Acquire); let idx = driver.rx.cur; let d = unsafe { desc(driver.rx.desc_va, idx) }; if (d.opts1 & DESC_OWN) != 0 { - let _ = mk_irq_ack(driver.irq_grant); return Ok(None); } let len = (d.opts1 & DESC_LEN_MASK) as usize; @@ -52,8 +48,10 @@ pub fn recv_one(driver: &mut Driver, out: &mut [u8]) -> Result, &' || len - 4 > MAX_ETHERNET_FRAME || len - 4 > out.len() { + // The part has already moved past this slot; staying on it left the + // cursor one behind the part until the whole ring had filled again. rearm(driver, idx); - let _ = mk_irq_ack(driver.irq_grant); + driver.rx.cur = (idx + 1) % RX_DESC_COUNT; return Err("rtl8169 rx descriptor error"); } let frame_len = len - 4; @@ -66,6 +64,5 @@ pub fn recv_one(driver: &mut Driver, out: &mut [u8]) -> Result, &' } rearm(driver, idx); driver.rx.cur = (idx + 1) % RX_DESC_COUNT; - let _ = mk_irq_ack(driver.irq_grant); Ok(Some(frame_len)) } diff --git a/userland/capsule_driver_rtl8169/src/server/error.rs b/userland/capsule_driver_rtl8169/src/server/error.rs index 7eedd252d3..6bebade1fc 100644 --- a/userland/capsule_driver_rtl8169/src/server/error.rs +++ b/userland/capsule_driver_rtl8169/src/server/error.rs @@ -14,19 +14,24 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -use nonos_libc::mk_ipc_send; +use nonos_libc::mk_ipc_reply; -use crate::protocol::{ - encode_response_header, write_status, Request, KERNEL_REPLY_ENDPOINT, RESP_HDR_LEN, STATUS_LEN, -}; +use crate::protocol::{encode_response_header, write_status, Request, RESP_HDR_LEN, STATUS_LEN}; -pub fn reply_with_status(tx: &mut [u8], req: &Request, status: i32) { +/// Answer the capsule that sent the request. Replies used to go to a fixed +/// kernel-side inbox, which nothing reads now that the stack is a capsule, +/// so every call from net_core timed out. +pub fn reply(sender: u32, tx: &[u8], len: usize) { + let _ = mk_ipc_reply(sender, tx.as_ptr(), len); +} + +pub fn reply_with_status(sender: u32, tx: &mut [u8], req: &Request, status: i32) { encode_response_header(tx, req, STATUS_LEN as u32); write_status(&mut tx[RESP_HDR_LEN..], status); - let _ = mk_ipc_send(KERNEL_REPLY_ENDPOINT, tx.as_ptr(), RESP_HDR_LEN + STATUS_LEN); + reply(sender, tx, RESP_HDR_LEN + STATUS_LEN); } -pub fn reply_decode_failed(tx: &mut [u8], status: i32) { +pub fn reply_decode_failed(sender: u32, tx: &mut [u8], status: i32) { let req = Request { op: 0, flags: 0, request_id: 0, payload_len: 0 }; - reply_with_status(tx, &req, status); + reply_with_status(sender, tx, &req, status); } diff --git a/userland/capsule_driver_rtl8169/src/server/handlers/health.rs b/userland/capsule_driver_rtl8169/src/server/handlers/health.rs index 09630f8dc9..4e484ff65e 100644 --- a/userland/capsule_driver_rtl8169/src/server/handlers/health.rs +++ b/userland/capsule_driver_rtl8169/src/server/handlers/health.rs @@ -17,6 +17,6 @@ use crate::protocol::Request; use crate::server::error::reply_with_status; -pub fn handle(req: &Request, tx: &mut [u8]) { - reply_with_status(tx, req, 0); +pub fn handle(sender: u32, req: &Request, tx: &mut [u8]) { + reply_with_status(sender, tx, req, 0); } diff --git a/userland/capsule_driver_rtl8169/src/server/handlers/link_status.rs b/userland/capsule_driver_rtl8169/src/server/handlers/link_status.rs index 87bd1d720d..db582110f2 100644 --- a/userland/capsule_driver_rtl8169/src/server/handlers/link_status.rs +++ b/userland/capsule_driver_rtl8169/src/server/handlers/link_status.rs @@ -14,23 +14,18 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -use nonos_libc::mk_ipc_send; - use crate::constants::regs::{PHY_STATUS_LINK_UP, REG_PHY_STATUS}; use crate::protocol::{ - encode_response_header, write_status, Request, KERNEL_REPLY_ENDPOINT, LINK_STATUS_PAYLOAD_LEN, - RESP_HDR_LEN, STATUS_LEN, + encode_response_header, write_status, Request, LINK_STATUS_PAYLOAD_LEN, RESP_HDR_LEN, + STATUS_LEN, }; +use crate::server::error::reply; use crate::setup::Driver; -pub fn handle(driver: &Driver, req: &Request, tx: &mut [u8]) { +pub fn handle(sender: u32, driver: &Driver, req: &Request, tx: &mut [u8]) { let link = unsafe { driver.regs.r8(REG_PHY_STATUS) } & PHY_STATUS_LINK_UP; encode_response_header(tx, req, STATUS_LEN as u32 + LINK_STATUS_PAYLOAD_LEN as u32); write_status(&mut tx[RESP_HDR_LEN..], 0); tx[RESP_HDR_LEN + STATUS_LEN] = if link != 0 { 1 } else { 0 }; - let _ = mk_ipc_send( - KERNEL_REPLY_ENDPOINT, - tx.as_ptr(), - RESP_HDR_LEN + STATUS_LEN + LINK_STATUS_PAYLOAD_LEN, - ); + reply(sender, tx, RESP_HDR_LEN + STATUS_LEN + LINK_STATUS_PAYLOAD_LEN); } diff --git a/userland/capsule_driver_rtl8169/src/server/handlers/mac_address.rs b/userland/capsule_driver_rtl8169/src/server/handlers/mac_address.rs index d843d0262b..b2db638743 100644 --- a/userland/capsule_driver_rtl8169/src/server/handlers/mac_address.rs +++ b/userland/capsule_driver_rtl8169/src/server/handlers/mac_address.rs @@ -14,18 +14,17 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -use nonos_libc::mk_ipc_send; - use crate::protocol::{ - encode_response_header, write_status, Request, KERNEL_REPLY_ENDPOINT, MAC_ADDRESS_PAYLOAD_LEN, - RESP_HDR_LEN, STATUS_LEN, + encode_response_header, write_status, Request, MAC_ADDRESS_PAYLOAD_LEN, RESP_HDR_LEN, + STATUS_LEN, }; +use crate::server::error::reply; use crate::setup::Driver; -pub fn handle(driver: &Driver, req: &Request, tx: &mut [u8]) { +pub fn handle(sender: u32, driver: &Driver, req: &Request, tx: &mut [u8]) { encode_response_header(tx, req, STATUS_LEN as u32 + MAC_ADDRESS_PAYLOAD_LEN as u32); write_status(&mut tx[RESP_HDR_LEN..], 0); let off = RESP_HDR_LEN + STATUS_LEN; tx[off..off + MAC_ADDRESS_PAYLOAD_LEN].copy_from_slice(&driver.mac); - let _ = mk_ipc_send(KERNEL_REPLY_ENDPOINT, tx.as_ptr(), off + MAC_ADDRESS_PAYLOAD_LEN); + reply(sender, tx, off + MAC_ADDRESS_PAYLOAD_LEN); } diff --git a/userland/capsule_driver_rtl8169/src/server/handlers/rx_packet.rs b/userland/capsule_driver_rtl8169/src/server/handlers/rx_packet.rs index 3832172364..c07d8c76a8 100644 --- a/userland/capsule_driver_rtl8169/src/server/handlers/rx_packet.rs +++ b/userland/capsule_driver_rtl8169/src/server/handlers/rx_packet.rs @@ -14,26 +14,24 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -use nonos_libc::mk_ipc_send; - use crate::protocol::{ - encode_response_header, write_status, Request, E_AGAIN, E_IO, KERNEL_REPLY_ENDPOINT, - RESP_HDR_LEN, RX_PAYLOAD_PREFIX_LEN, STATUS_LEN, + encode_response_header, write_status, Request, E_AGAIN, E_IO, RESP_HDR_LEN, + RX_PAYLOAD_PREFIX_LEN, STATUS_LEN, }; use crate::rx::recv_one; -use crate::server::error::reply_with_status; +use crate::server::error::{reply, reply_with_status}; use crate::setup::Driver; -pub fn handle(driver: &mut Driver, req: &Request, tx: &mut [u8]) { +pub fn handle(sender: u32, driver: &mut Driver, req: &Request, tx: &mut [u8]) { let body_off = RESP_HDR_LEN + STATUS_LEN + RX_PAYLOAD_PREFIX_LEN; let frame_len = match recv_one(driver, &mut tx[body_off..]) { Ok(Some(n)) => n, Ok(None) => { - reply_with_status(tx, req, E_AGAIN); + reply_with_status(sender, tx, req, E_AGAIN); return; } Err(_) => { - reply_with_status(tx, req, E_IO); + reply_with_status(sender, tx, req, E_IO); return; } }; @@ -41,5 +39,5 @@ pub fn handle(driver: &mut Driver, req: &Request, tx: &mut [u8]) { encode_response_header(tx, req, STATUS_LEN as u32 + body_len as u32); write_status(&mut tx[RESP_HDR_LEN..], 0); tx[RESP_HDR_LEN + STATUS_LEN..body_off].copy_from_slice(&(frame_len as u32).to_le_bytes()); - let _ = mk_ipc_send(KERNEL_REPLY_ENDPOINT, tx.as_ptr(), body_off + frame_len); + reply(sender, tx, body_off + frame_len); } diff --git a/userland/capsule_driver_rtl8169/src/server/handlers/stats.rs b/userland/capsule_driver_rtl8169/src/server/handlers/stats.rs index 12cee3924f..be2a438388 100644 --- a/userland/capsule_driver_rtl8169/src/server/handlers/stats.rs +++ b/userland/capsule_driver_rtl8169/src/server/handlers/stats.rs @@ -14,19 +14,17 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -use nonos_libc::mk_ipc_send; - use crate::constants::queue::{RX_DESC_COUNT, TX_DESC_COUNT}; use crate::constants::regs::{ REG_CMD, REG_IMR, REG_ISR, REG_PHY_STATUS, REG_RMS, REG_RX_CONFIG, REG_TX_CONFIG, }; use crate::protocol::{ - encode_response_header, write_status, Request, KERNEL_REPLY_ENDPOINT, RESP_HDR_LEN, - STATS_PAYLOAD_LEN, STATUS_LEN, + encode_response_header, write_status, Request, RESP_HDR_LEN, STATS_PAYLOAD_LEN, STATUS_LEN, }; +use crate::server::error::reply; use crate::setup::Driver; -pub fn handle(driver: &Driver, req: &Request, tx: &mut [u8]) { +pub fn handle(sender: u32, driver: &Driver, req: &Request, tx: &mut [u8]) { let payload_len = STATUS_LEN as u32 + STATS_PAYLOAD_LEN as u32; encode_response_header(tx, req, payload_len); write_status(&mut tx[RESP_HDR_LEN..], 0); @@ -34,7 +32,7 @@ pub fn handle(driver: &Driver, req: &Request, tx: &mut [u8]) { for v in live_regs(driver) { put32(tx, &mut o, v); } - let _ = mk_ipc_send(KERNEL_REPLY_ENDPOINT, tx.as_ptr(), RESP_HDR_LEN + payload_len as usize); + reply(sender, tx, RESP_HDR_LEN + payload_len as usize); } fn live_regs(driver: &Driver) -> [u32; 12] { diff --git a/userland/capsule_driver_rtl8169/src/server/handlers/tx_packet.rs b/userland/capsule_driver_rtl8169/src/server/handlers/tx_packet.rs index 38185d2841..8ddf898b84 100644 --- a/userland/capsule_driver_rtl8169/src/server/handlers/tx_packet.rs +++ b/userland/capsule_driver_rtl8169/src/server/handlers/tx_packet.rs @@ -15,25 +15,27 @@ // along with this program. If not, see . use crate::constants::{MAX_ETHERNET_FRAME, MIN_ETHERNET_FRAME}; -use crate::protocol::{Request, E_INVAL, E_IO, E_MSGSIZE, MAX_TX_PAYLOAD_BYTES}; +use crate::protocol::{Request, E_AGAIN, E_INVAL, E_MSGSIZE, MAX_TX_PAYLOAD_BYTES}; use crate::server::error::reply_with_status; use crate::setup::Driver; -use crate::tx::send; +use crate::tx::{busy, send}; -pub fn handle(driver: &mut Driver, req: &Request, body: &[u8], tx: &mut [u8]) { +pub fn handle(sender: u32, driver: &mut Driver, req: &Request, body: &[u8], tx: &mut [u8]) { if req.payload_len as usize != body.len() { - reply_with_status(tx, req, E_MSGSIZE); + reply_with_status(sender, tx, req, E_MSGSIZE); return; } if body.len() < MIN_ETHERNET_FRAME || body.len() > MAX_ETHERNET_FRAME || body.len() as u32 > MAX_TX_PAYLOAD_BYTES { - reply_with_status(tx, req, E_INVAL); + reply_with_status(sender, tx, req, E_INVAL); return; } - match send(driver, body) { - Ok(()) => reply_with_status(tx, req, 0), - Err(_) => reply_with_status(tx, req, E_IO), + if busy(driver) { + reply_with_status(sender, tx, req, E_AGAIN); + return; } + send(driver, body); + reply_with_status(sender, tx, req, 0); } diff --git a/userland/capsule_driver_rtl8169/src/server/runner.rs b/userland/capsule_driver_rtl8169/src/server/runner.rs index 81e23510f2..bcbeccaf4c 100644 --- a/userland/capsule_driver_rtl8169/src/server/runner.rs +++ b/userland/capsule_driver_rtl8169/src/server/runner.rs @@ -16,7 +16,7 @@ use alloc::vec; -use nonos_libc::mk_ipc_recv; +use nonos_libc::mk_ipc_recv_from; use crate::constants::MAX_ETHERNET_FRAME; use crate::protocol::{ @@ -37,27 +37,28 @@ pub fn run(driver: &mut Driver) -> ! { let mut rx = vec![0u8; rx_len]; let mut tx = vec![0u8; tx_len]; loop { - let n = mk_ipc_recv(SERVICE_INBOX, rx.as_mut_ptr(), rx_len, 0); - if n <= 0 { + let mut sender: u32 = 0; + let n = mk_ipc_recv_from(SERVICE_INBOX, rx.as_mut_ptr(), rx_len, 0, &mut sender); + if n <= 0 || sender == 0 { continue; } let len = n as usize; let req = match decode_request(&rx[..len]) { Some(r) => r, None => { - reply_decode_failed(&mut tx, E_INVAL); + reply_decode_failed(sender, &mut tx, E_INVAL); continue; } }; let body = &rx[HDR_LEN..len]; match req.op { - OP_HEALTHCHECK => handlers::health::handle(&req, &mut tx), - OP_LINK_STATUS => handlers::link_status::handle(driver, &req, &mut tx), - OP_MAC_ADDRESS => handlers::mac_address::handle(driver, &req, &mut tx), - OP_TX_PACKET => handlers::tx_packet::handle(driver, &req, body, &mut tx), - OP_RX_PACKET => handlers::rx_packet::handle(driver, &req, &mut tx), - OP_STATS => handlers::stats::handle(driver, &req, &mut tx), - _ => reply_with_status(&mut tx, &req, E_INVAL), + OP_HEALTHCHECK => handlers::health::handle(sender, &req, &mut tx), + OP_LINK_STATUS => handlers::link_status::handle(sender, driver, &req, &mut tx), + OP_MAC_ADDRESS => handlers::mac_address::handle(sender, driver, &req, &mut tx), + OP_TX_PACKET => handlers::tx_packet::handle(sender, driver, &req, body, &mut tx), + OP_RX_PACKET => handlers::rx_packet::handle(sender, driver, &req, &mut tx), + OP_STATS => handlers::stats::handle(sender, driver, &req, &mut tx), + _ => reply_with_status(sender, &mut tx, &req, E_INVAL), } } } diff --git a/userland/capsule_driver_rtl8169/src/setup/dma.rs b/userland/capsule_driver_rtl8169/src/setup/dma.rs index 854593a715..1f70190a9e 100644 --- a/userland/capsule_driver_rtl8169/src/setup/dma.rs +++ b/userland/capsule_driver_rtl8169/src/setup/dma.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 nonos_libc::{mk_dma_map, DmaMapOut, IrqBindOut, MmioMapOut}; +use nonos_libc::{mk_dma_map, DmaMapOut, MmioMapOut}; use crate::constants::queue::{RX_BUFFER_BYTES, RX_RING_BYTES, TX_BUFFER_BYTES, TX_RING_BYTES}; @@ -40,27 +40,21 @@ pub fn map_all( device_id: u64, epoch: u64, mmio: &MmioMapOut, - irq: &IrqBindOut, ) -> Result<(DmaMapOut, DmaMapOut, DmaMapOut, DmaMapOut), &'static str> { let rx_ring = alloc(device_id, epoch, RX_RING_BYTES as u64).ok_or_else(|| { - rollback::after(device_id, mmio, irq, &[]); + rollback::after(device_id, mmio, &[]); "rx ring dma failed" })?; let rx_buf = alloc(device_id, epoch, RX_BUFFER_BYTES as u64).ok_or_else(|| { - rollback::after(device_id, mmio, irq, &[rx_ring.grant_id]); + rollback::after(device_id, mmio, &[rx_ring.grant_id]); "rx buffer dma failed" })?; let tx_ring = alloc(device_id, epoch, TX_RING_BYTES as u64).ok_or_else(|| { - rollback::after(device_id, mmio, irq, &[rx_buf.grant_id, rx_ring.grant_id]); + rollback::after(device_id, mmio, &[rx_buf.grant_id, rx_ring.grant_id]); "tx ring dma failed" })?; let tx_buf = alloc(device_id, epoch, TX_BUFFER_BYTES as u64).ok_or_else(|| { - rollback::after( - device_id, - mmio, - irq, - &[tx_ring.grant_id, rx_buf.grant_id, rx_ring.grant_id], - ); + rollback::after(device_id, mmio, &[tx_ring.grant_id, rx_buf.grant_id, rx_ring.grant_id]); "tx buffer dma failed" })?; Ok((rx_ring, rx_buf, tx_ring, tx_buf)) diff --git a/userland/capsule_driver_rtl8169/src/setup/driver.rs b/userland/capsule_driver_rtl8169/src/setup/driver.rs index 9841368998..61fd4455b4 100644 --- a/userland/capsule_driver_rtl8169/src/setup/driver.rs +++ b/userland/capsule_driver_rtl8169/src/setup/driver.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 nonos_libc::{mk_device_release, mk_dma_unmap, mk_irq_unbind, mk_mmio_unmap}; +use nonos_libc::{mk_device_release, mk_dma_unmap, mk_mmio_unmap}; use crate::constants::MAC_LEN; use crate::queue::{RxRing, TxRing}; @@ -23,7 +23,6 @@ use crate::regs::Regs; pub struct Driver { pub device_id: u64, pub mmio_grant: u64, - pub irq_grant: u64, pub rx_ring_grant: u64, pub rx_buffer_grant: u64, pub tx_ring_grant: u64, @@ -40,7 +39,6 @@ impl Driver { let _ = mk_dma_unmap(self.tx_ring_grant); let _ = mk_dma_unmap(self.rx_buffer_grant); let _ = mk_dma_unmap(self.rx_ring_grant); - let _ = mk_irq_unbind(self.irq_grant); let _ = mk_mmio_unmap(self.mmio_grant); let _ = mk_device_release(self.device_id); } diff --git a/userland/capsule_driver_rtl8169/src/setup/mod.rs b/userland/capsule_driver_rtl8169/src/setup/mod.rs index 6ef5e2e8a0..876531e5d4 100644 --- a/userland/capsule_driver_rtl8169/src/setup/mod.rs +++ b/userland/capsule_driver_rtl8169/src/setup/mod.rs @@ -17,7 +17,6 @@ mod claim; mod dma; mod driver; -mod irq; mod mmio; mod pci; mod rollback; diff --git a/userland/capsule_driver_rtl8169/src/setup/rollback.rs b/userland/capsule_driver_rtl8169/src/setup/rollback.rs index 146beeef3d..28d50174d0 100644 --- a/userland/capsule_driver_rtl8169/src/setup/rollback.rs +++ b/userland/capsule_driver_rtl8169/src/setup/rollback.rs @@ -14,15 +14,12 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -use nonos_libc::{ - mk_device_release, mk_dma_unmap, mk_irq_unbind, mk_mmio_unmap, IrqBindOut, MmioMapOut, -}; +use nonos_libc::{mk_device_release, mk_dma_unmap, mk_mmio_unmap, MmioMapOut}; -pub fn after(device_id: u64, mmio: &MmioMapOut, irq: &IrqBindOut, dma_grants: &[u64]) { +pub fn after(device_id: u64, mmio: &MmioMapOut, dma_grants: &[u64]) { for grant in dma_grants { let _ = mk_dma_unmap(*grant); } - let _ = mk_irq_unbind(irq.grant_id); let _ = mk_mmio_unmap(mmio.grant_id); let _ = mk_device_release(device_id); } diff --git a/userland/capsule_driver_rtl8169/src/setup/sequence.rs b/userland/capsule_driver_rtl8169/src/setup/sequence.rs index c8e84b69cf..6efef7fd55 100644 --- a/userland/capsule_driver_rtl8169/src/setup/sequence.rs +++ b/userland/capsule_driver_rtl8169/src/setup/sequence.rs @@ -20,19 +20,17 @@ use crate::queue::{RxRing, TxRing}; use crate::regs::Regs; use super::driver::Driver; -use super::{claim, dma, irq, mmio, pci}; +use super::{claim, dma, mmio, pci}; pub fn run() -> Result { let dev = find_rtl8169().ok_or("no rtl8169 device")?; let claim_epoch = claim::claim(dev.device_id)?; pci::enable_bus_master(dev, claim_epoch)?; let mmio = mmio::map(dev, claim_epoch)?; - let irq = irq::bind(dev, claim_epoch, &mmio)?; - let (rx_ring, rx_buf, tx_ring, tx_buf) = dma::map_all(dev.device_id, claim_epoch, &mmio, &irq)?; + let (rx_ring, rx_buf, tx_ring, tx_buf) = dma::map_all(dev.device_id, claim_epoch, &mmio)?; Ok(Driver { device_id: dev.device_id, mmio_grant: mmio.grant_id, - irq_grant: irq.grant_id, rx_ring_grant: rx_ring.grant_id, rx_buffer_grant: rx_buf.grant_id, tx_ring_grant: tx_ring.grant_id, diff --git a/userland/capsule_driver_rtl8169/src/tx/mod.rs b/userland/capsule_driver_rtl8169/src/tx/mod.rs index 9ba7f0c1da..66c892cd69 100644 --- a/userland/capsule_driver_rtl8169/src/tx/mod.rs +++ b/userland/capsule_driver_rtl8169/src/tx/mod.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 . -mod poll_done; mod send; -pub use send::send; +pub use send::{busy, send}; diff --git a/userland/capsule_driver_rtl8169/src/tx/send.rs b/userland/capsule_driver_rtl8169/src/tx/send.rs index faaf7d0662..d7f8c15b26 100644 --- a/userland/capsule_driver_rtl8169/src/tx/send.rs +++ b/userland/capsule_driver_rtl8169/src/tx/send.rs @@ -16,49 +16,46 @@ use core::sync::atomic::{compiler_fence, Ordering}; -use super::poll_done::poll_done; use crate::constants::queue::TX_DESC_COUNT; -use crate::constants::regs::{ - DESC_EOR, DESC_FS, DESC_LS, DESC_OWN, ISR_ENABLED, ISR_TER, REG_ISR, REG_TX_POLL, TX_POLL_HPQ, -}; +use crate::constants::regs::{DESC_EOR, DESC_FS, DESC_LS, DESC_OWN, REG_TX_POLL, TX_POLL_NPQ}; +use crate::constants::MIN_WIRE_FRAME; use crate::queue::desc::{desc, desc_mut, Descriptor}; use crate::setup::Driver; -pub fn send(driver: &mut Driver, frame: &[u8]) -> Result<(), &'static str> { +/// Whether the part still owns the next slot, so a frame has nowhere to go. +pub fn busy(driver: &Driver) -> bool { + (unsafe { desc(driver.tx.desc_va, driver.tx.cur) }.opts1 & DESC_OWN) != 0 +} + +/* + * Hand one frame to the part and answer once it is queued. The cursor moves + * on the moment OWN goes over: the part walks the ring with a pointer of its + * own and moves past the slot when it finishes, so a cursor held back on a + * TX error or a slow completion (link still negotiating, PAUSE frames) was + * one slot behind the part for good, and every later send saw "busy". + * The caller checks `busy` first; OWN still set on the next slot is a full + * ring. + */ +pub fn send(driver: &mut Driver, frame: &[u8]) { let idx = driver.tx.cur; - if (unsafe { desc(driver.tx.desc_va, idx) }.opts1 & DESC_OWN) != 0 { - return Err("rtl8169 tx descriptor busy"); - } + let wire = frame.len().max(MIN_WIRE_FRAME); unsafe { - core::ptr::copy_nonoverlapping( - frame.as_ptr(), - driver.tx.buffer_va(idx) as *mut u8, - frame.len(), - ); + let dst = driver.tx.buffer_va(idx) as *mut u8; + core::ptr::copy_nonoverlapping(frame.as_ptr(), dst, frame.len()); + core::ptr::write_bytes(dst.add(frame.len()), 0, wire - frame.len()); } compiler_fence(Ordering::Release); let eor = if idx == TX_DESC_COUNT - 1 { DESC_EOR } else { 0 }; let addr = driver.tx.buffer_da(idx); let d = Descriptor { - opts1: DESC_OWN | DESC_FS | DESC_LS | eor | frame.len() as u32, + opts1: DESC_OWN | DESC_FS | DESC_LS | eor | wire as u32, opts2: 0, addr_lo: addr as u32, addr_hi: (addr >> 32) as u32, }; unsafe { desc_mut(driver.tx.desc_va, idx, d); - driver.regs.w8(REG_TX_POLL, TX_POLL_HPQ); - } - poll_done(driver, idx)?; - let isr = unsafe { driver.regs.r16(REG_ISR) }; - if isr != 0 { - unsafe { - driver.regs.w16(REG_ISR, isr & ISR_ENABLED); - } - } - if (isr & ISR_TER) != 0 { - return Err("rtl8169 tx interrupt error"); + driver.regs.w8(REG_TX_POLL, TX_POLL_NPQ); } driver.tx.cur = (idx + 1) % TX_DESC_COUNT; - Ok(()) } diff --git a/userland/capsule_driver_rtl8821ce/src/assoc.rs b/userland/capsule_driver_rtl8821ce/src/assoc.rs index 31e023e836..054c493b3d 100644 --- a/userland/capsule_driver_rtl8821ce/src/assoc.rs +++ b/userland/capsule_driver_rtl8821ce/src/assoc.rs @@ -58,7 +58,7 @@ pub trait Radio { /// How a join attempt ended. pub enum Outcome { /// Associated: the pairwise and group keys and the AP to install them for. - Joined { bssid: [u8; 6], channel: u8, ptk: [u8; 16], gtk: [u8; 16] }, + Joined { bssid: [u8; 6], channel: u8, ptk: [u8; 16], gtk: [u8; 16], gtk_id: u8 }, /// The association was refused or the handshake broke. Refused, /// The AP stopped responding before the join completed. @@ -351,7 +351,7 @@ fn report(mlme: &Mlme, outcome: Outcome, c: Counters) -> Report { // Pull the negotiated keys out of a connected machine. fn finish(mlme: &Mlme) -> Outcome { - let (Some(tk), Some(gtk)) = (mlme.tk(), mlme.gtk()) else { + let (Some(tk), Some(gtk), Some(gtk_id)) = (mlme.tk(), mlme.gtk(), mlme.gtk_id()) else { return Outcome::Refused; }; let mut ptk = [0u8; 16]; @@ -361,5 +361,5 @@ fn finish(mlme: &Mlme) -> Outcome { } ptk.copy_from_slice(&tk[..16]); group.copy_from_slice(>k[..16]); - Outcome::Joined { bssid: mlme.bssid(), channel: mlme.channel(), ptk, gtk: group } + Outcome::Joined { bssid: mlme.bssid(), channel: mlme.channel(), ptk, gtk: group, gtk_id } } diff --git a/userland/capsule_driver_rtl8821ce/src/link.rs b/userland/capsule_driver_rtl8821ce/src/link.rs index 15a67c0c3e..5236e39b7b 100644 --- a/userland/capsule_driver_rtl8821ce/src/link.rs +++ b/userland/capsule_driver_rtl8821ce/src/link.rs @@ -38,7 +38,7 @@ use crate::regs::Mmio; use crate::rx::ring::{RxState, RX_BUF_STRIDE, RX_DESC_COUNT}; use crate::rx::{poll_one, program as rx_program}; use crate::sec::{clear_cam, write_cam, Key, CAM_AES}; -use crate::tx::desc::{FrameMeta, DESC_RATE_6M, SEC_TYPE_CCMP}; +use crate::tx::desc::{FrameMeta, DESC_RATE_6M}; use crate::tx::regs::QSEL_BE; use crate::tx::ring::{TxState, TX_DESC_COUNT}; use crate::tx::{enqueue, program as tx_program}; @@ -308,13 +308,11 @@ impl LinkPort for RtlLink { // never registers one after association), so a rate-controlled data frame // is dropped before it reaches the air; a fixed rate transmits, exactly as // the fixed-rate handshake frames already do. - let meta = FrameMeta { - qsel: QSEL_BE, - bmc: false, - rate: Some(DESC_RATE_6M), - seq, - sec_type: SEC_TYPE_CCMP, - }; + // No security type: `tx_frame` has already encrypted the frame in + // software (header, CCMP header, MIC). A descriptor tagged CCMP asks + // the MAC to encrypt it again, as rtw88 does only for frames with a + // hardware key, and the AP then fails the MIC on every data frame. + let meta = FrameMeta { qsel: QSEL_BE, bmc: false, rate: Some(DESC_RATE_6M), seq, sec_type: 0 }; let ok = enqueue(&self.mmio, &self.tx_ring, &self.tx_buffers, &mut self.tx_state, &mpdu, &meta); if ok { diff --git a/userland/capsule_driver_rtl8821ce/src/serve/connect.rs b/userland/capsule_driver_rtl8821ce/src/serve/connect.rs index 31ecf50d1f..b934a7f820 100644 --- a/userland/capsule_driver_rtl8821ce/src/serve/connect.rs +++ b/userland/capsule_driver_rtl8821ce/src/serve/connect.rs @@ -34,9 +34,10 @@ use crate::status; use super::radio::read_mac; use super::{SCAN_CHANNELS, SCAN_FRAME_MAX}; -/// The pairwise and group key slots a connection installs into the CAM. +/// The pairwise key's index. The group key goes in the slot its own index +/// names: with the engine looking group-addressed frames up by the CCMP +/// KeyID, a fixed slot 1 went dark after the AP's first rekey moved it to 2. const PAIRWISE_KEY_ID: u8 = 0; -const GROUP_KEY_ID: u8 = 1; /// Receive passes to spend joining before giving up. The driver's clock is not /// reliable in this capsule (all its other timeouts are poll counts), so the /// join is bounded in passes. Large enough to cover authentication, association @@ -142,11 +143,11 @@ pub(super) fn connect( report.state, ); let code = match report.outcome { - Outcome::Joined { bssid, channel, ptk, gtk } => { + Outcome::Joined { bssid, channel, ptk, gtk, gtk_id } => { set_rf(regs, channel, Bw::W20); if !keys.install_ptk(&ptk, PAIRWISE_KEY_ID, &bssid) { -3 - } else if !keys.install_gtk(>k, GROUP_KEY_ID) { + } else if !keys.install_gtk(>k, gtk_id) { -4 } else { // The keys are in the CAM, so turn the sec engine on for receive @@ -157,7 +158,7 @@ pub(super) fn connect( // but unencrypted and the access point dropped them. crate::sec::enable_sec_engine(regs); link.associate(bssid, ptk); - *session = Some(Session { bssid, key_id: PAIRWISE_KEY_ID, gtk_id: GROUP_KEY_ID }); + *session = Some(Session { bssid, key_id: PAIRWISE_KEY_ID, gtk_id }); status::debug(b"[rtl8821ce] connect: associated\n"); 0 } 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/device/virtqueue/used.rs b/userland/capsule_driver_virtio_gpu/src/device/virtqueue/used.rs index 07bd58e1bc..b0f335b3ed 100644 --- a/userland/capsule_driver_virtio_gpu/src/device/virtqueue/used.rs +++ b/userland/capsule_driver_virtio_gpu/src/device/virtqueue/used.rs @@ -34,10 +34,11 @@ pub struct UsedEntry { pub fn read_entry(layout: QueueLayout, ring_slot: u16) -> UsedEntry { let slot = ring_slot % layout.queue_size; let p = used_ring_entry(layout, slot); + // Order these reads after the used index that proved them written. + fence(Ordering::Acquire); unsafe { let id = read_volatile(p); let len = read_volatile(p.add(1)); - fence(Ordering::Acquire); UsedEntry { id, len } } } 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 ! { if heap_init().is_err() { mk_exit(1); } + // The broker lists every PCI function before the first capsule starts, so + // with no virtio-gpu the retry below only spun through ten seconds of boot + // on real hardware. Leave at once (2, absent); the compositor takes GOP. + if discover::find_virtio_gpu().is_none() { + mk_exit(2); + } let start = mk_time_millis(); let driver = loop { match setup::run() { diff --git a/userland/capsule_driver_virtio_net/src/main.rs b/userland/capsule_driver_virtio_net/src/main.rs index 70930d07f1..67ba2e2a13 100644 --- a/userland/capsule_driver_virtio_net/src/main.rs +++ b/userland/capsule_driver_virtio_net/src/main.rs @@ -32,8 +32,7 @@ mod tx; use nonos_libc::{heap_init, mk_exit, mk_time_millis, mk_yield}; -// Bounded probe: exit cleanly if no virtio-net appears, instead of spinning -// forever on hardware that has none (real machines use their physical NIC). +// Bounded retry for a device that is present but fails a setup step. const PROBE_DEADLINE_MS: i64 = 10_000; #[no_mangle] @@ -42,6 +41,16 @@ pub unsafe extern "C" fn _start() -> ! { mk_exit(1); } + /* + * The broker lists every PCI function before the first capsule starts, so + * a machine without a virtio-net has none to wait for. Retrying discovery + * here spun for ten seconds of boot while net_core's link probes to this + * name went unanswered; leave the way the wired drivers do (2, absent). + */ + if discover::find_virtio_net().is_none() { + mk_exit(2); + } + let start = mk_time_millis(); let mut driver = loop { match setup::run() { diff --git a/userland/capsule_driver_virtio_net/src/queue/post.rs b/userland/capsule_driver_virtio_net/src/queue/post.rs index 4e0f7a8db7..2edc07f521 100644 --- a/userland/capsule_driver_virtio_net/src/queue/post.rs +++ b/userland/capsule_driver_virtio_net/src/queue/post.rs @@ -15,6 +15,7 @@ // along with this program. If not, see . use core::ptr::{read_volatile, write_volatile}; +use core::sync::atomic::{fence, Ordering}; use super::RxQueue; use crate::constants::{RING_SLOTS, VQ_AVAIL_OFFSET, VQ_DESC_OFFSET, VRING_DESC_F_WRITE}; @@ -35,6 +36,8 @@ impl RxQueue { write_volatile(slot.add(14).cast::(), 0u16); write_volatile(avail.add(2 + i as usize), i); } + // The device may read a slot the moment the index covers it. + fence(Ordering::Release); write_volatile(avail.add(1), self.buf_count); } } @@ -45,6 +48,7 @@ impl RxQueue { let idx = read_volatile(avail.add(1)); let pos = (idx % RING_SLOTS) as usize; write_volatile(avail.add(2 + pos), slot); + fence(Ordering::Release); write_volatile(avail.add(1), idx.wrapping_add(1)); } } diff --git a/userland/capsule_driver_virtio_net/src/queue/post_packet.rs b/userland/capsule_driver_virtio_net/src/queue/post_packet.rs index 86c3fe0902..707a8e7e97 100644 --- a/userland/capsule_driver_virtio_net/src/queue/post_packet.rs +++ b/userland/capsule_driver_virtio_net/src/queue/post_packet.rs @@ -15,6 +15,7 @@ // along with this program. If not, see . use core::ptr::{read_volatile, write_volatile}; +use core::sync::atomic::{fence, Ordering}; use super::TxQueue; use crate::constants::{RING_SLOTS, VQ_AVAIL_OFFSET, VQ_DESC_OFFSET}; @@ -34,6 +35,9 @@ impl TxQueue { let idx = read_volatile(avail.add(1)); let pos = (idx % RING_SLOTS) as usize; write_volatile(avail.add(AVAIL_RING_OFFSET / 2 + pos), slot); + // The frame bytes are plain stores; volatile alone would let them + // land after the index that tells the device to read them. + fence(Ordering::Release); write_volatile(avail.add(1), idx.wrapping_add(1)); } } diff --git a/userland/capsule_driver_virtio_net/src/rx.rs b/userland/capsule_driver_virtio_net/src/rx.rs index fdf89cbdf8..d296145c43 100644 --- a/userland/capsule_driver_virtio_net/src/rx.rs +++ b/userland/capsule_driver_virtio_net/src/rx.rs @@ -20,6 +20,8 @@ +use core::sync::atomic::{fence, Ordering}; + use crate::constants::{RING_SLOTS, VIRTIO_NET_HDR_LEN}; use crate::queue::RxQueue; @@ -39,6 +41,8 @@ pub unsafe fn take_one(rx: &mut RxQueue) -> Option> { if used == rx.last_used { return None; } + // The element and the frame are only valid once the index is seen. + fence(Ordering::Acquire); let ring_pos = rx.last_used % RING_SLOTS; let (desc_id, used_len) = rx.used_elem_at(ring_pos); diff --git a/userland/capsule_driver_xhci/src/setup/pci.rs b/userland/capsule_driver_xhci/src/setup/pci.rs index 222ce7237d..b235c7e8e3 100644 --- a/userland/capsule_driver_xhci/src/setup/pci.rs +++ b/userland/capsule_driver_xhci/src/setup/pci.rs @@ -14,12 +14,17 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -use nonos_libc::{mk_pci_config_write, MK_PCI_CFG_COMMAND, MK_PCI_CMD_BUS_MASTER}; +use nonos_libc::{ + mk_pci_config_write, MK_PCI_CFG_COMMAND, MK_PCI_CMD_BUS_MASTER, MK_PCI_CMD_MEMORY_SPACE, +}; use crate::error::{XhciError, XhciResult}; pub fn enable_bus_master(device_id: u64, claim_epoch: u64) -> XhciResult<()> { - let r = mk_pci_config_write(device_id, claim_epoch, MK_PCI_CFG_COMMAND, MK_PCI_CMD_BUS_MASTER); + // Memory Space too: firmware that never used the controller can leave it + // clear, and then every register access drops without an error. + let bits = MK_PCI_CMD_BUS_MASTER | MK_PCI_CMD_MEMORY_SPACE; + let r = mk_pci_config_write(device_id, claim_epoch, MK_PCI_CFG_COMMAND, bits); if r < 0 { return Err(XhciError::BrokerCallFailed(r)); } diff --git a/userland/capsule_linux/Capsule.mk b/userland/capsule_linux/Capsule.mk index f5fe147388..77e39d4470 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,17 @@ 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, run and terminal 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, every run of +# an installed package and every `linux` command in the terminal 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 service:5100:app.linux.term reply:5101:endpoint.app.linux.term.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..8a22c6a9dc 100644 --- a/userland/capsule_linux/src/linux/abi/errno.rs +++ b/userland/capsule_linux/src/linux/abi/errno.rs @@ -27,18 +27,16 @@ pub const ENOMEM: i64 = 12; pub const ESRCH: i64 = 3; pub const EACCES: i64 = 13; pub const EFAULT: i64 = 14; -pub const EBUSY: i64 = 16; pub const EEXIST: i64 = 17; pub const ENOTEMPTY: i64 = 39; -pub const ENODEV: i64 = 19; pub const ENOTDIR: i64 = 20; pub const ENOSPC: i64 = 28; pub const EISDIR: i64 = 21; pub const EINVAL: i64 = 22; -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; @@ -47,8 +45,9 @@ pub const ENOSYS: i64 = 38; pub const ECONNRESET: i64 = 104; pub const ENOTCONN: i64 = 107; pub const ENOTSOCK: i64 = 88; -pub const ENOTSUP: i64 = 95; pub const EAFNOSUPPORT: i64 = 97; +pub const ENETUNREACH: i64 = 101; +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..060bd2b9c3 100644 --- a/userland/capsule_linux/src/linux/abi/mod.rs +++ b/userland/capsule_linux/src/linux/abi/mod.rs @@ -16,10 +16,10 @@ //! The Linux contract a compiled binary was built against: its numbers, its //! errnos, and the names it knows them by. -#![allow(dead_code)] pub mod errno; 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..1f3a7abdda 100644 --- a/userland/capsule_linux/src/linux/abi/nr_path.rs +++ b/userland/capsule_linux/src/linux/abi/nr_path.rs @@ -27,7 +27,6 @@ pub const MKDIRAT: u64 = 258; pub const UNLINKAT: u64 = 263; pub const TIME: u64 = 201; pub const GETTIMEOFDAY: u64 = 96; -pub const NANOSLEEP: u64 = 35; pub const CLOCK_NANOSLEEP: u64 = 230; pub const GETPPID: u64 = 110; pub const SCHED_YIELD: u64 = 24; @@ -42,7 +41,6 @@ pub const SETUID: u64 = 105; pub const SETGID: u64 = 106; pub const READV: u64 = 19; pub const GETRLIMIT: u64 = 97; -pub const SETRLIMIT: u64 = 160; pub const PRLIMIT64: u64 = 302; pub const SELECT: u64 = 23; pub const PSELECT6: u64 = 270; @@ -56,7 +54,24 @@ pub const FSTATFS: u64 = 138; pub const STATX: u64 = 332; pub const KILL: u64 = 62; pub const TKILL: u64 = 200; -pub const GETRESUID: u64 = 118; -pub const GETRESGID: u64 = 120; +pub const TGKILL: u64 = 234; 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..907195a926 --- /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::say::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..64d1db85b9 --- /dev/null +++ b/userland/capsule_linux/src/linux/built_in.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 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(), + argv0: None, + } +} 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/console.rs b/userland/capsule_linux/src/linux/call/console.rs index f708846046..dcc903e853 100644 --- a/userland/capsule_linux/src/linux/call/console.rs +++ b/userland/capsule_linux/src/linux/call/console.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 guest's console output, carried to the host's log. use crate::linux::abi::errno; @@ -22,6 +21,9 @@ use crate::linux::guest::Guest; /// Cap on one transfer, matching the kernel's own peer-copy ceiling. const MAX_IO: u64 = 1 << 20; +/// The kernel takes a log line of at most 256 bytes and refuses a longer one +/// whole, so a longer write goes out in pieces. +const LINE_MAX: usize = 256; /// A guest's console output, carried to the host's log. The bytes are the /// guest's and are never interpreted, only forwarded. @@ -33,7 +35,8 @@ pub(super) fn console(guest: &Guest, buf: u64, len: u64) -> u64 { let Some(bytes) = guest.read(buf, take as usize) else { return errno::fail(errno::EFAULT); }; - let _ = nonos_libc::mk_debug(bytes.as_ptr(), bytes.len()); + for piece in bytes.chunks(LINE_MAX) { + let _ = nonos_libc::mk_debug(piece.as_ptr(), piece.len()); + } errno::ok(take) } - 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..c7cec3efee 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::say::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/advise.rs b/userland/capsule_linux/src/linux/call/mem/advise.rs new file mode 100644 index 0000000000..5544c39cd8 --- /dev/null +++ b/userland/capsule_linux/src/linux/call/mem/advise.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 . + +//! `madvise`. Advice a Linux program counts on is carried out, advice that +//! changes nothing a program can read is taken as the hint it is, and advice +//! this capsule cannot carry out is refused, never answered with a success. + +use crate::linux::abi::errno; +use crate::linux::guest::{Guest, PAGE}; + +use super::advise_drop::{drop_pages, parts}; + +const DONTNEED: u64 = 4; +const DONTNEED_LOCKED: u64 = 24; +/* Linux 6.1's hints, none of which changes what a program reads. DOFORK and + * KEEPONFORK undo advice this capsule never takes. */ +const HINTS: [u64; 18] = [0, 1, 2, 3, 8, 11, 12, 13, 14, 15, 16, 17, 19, 20, 21, 22, 23, 25]; +/* REMOVE, DONTFORK and WIPEONFORK would change what a program reads or what + * a child inherits, and this capsule does neither. */ +const REFUSED: [u64; 3] = [9, 10, 18]; +/* HWPOISON and SOFT_OFFLINE, which Linux keeps for a privileged caller. */ +const PRIVILEGED: [u64; 2] = [100, 101]; + +/// In Linux's order: unknown advice or a misaligned address is EINVAL, an +/// empty span is 0, privileged advice EPERM; then the advice, on the pages +/// that are mapped, and ENOMEM after it when some page of the span is not. +pub fn madvise(guest: &mut Guest, addr: u64, len: u64, advice: u64) -> u64 { + let dontneed = advice == DONTNEED || advice == DONTNEED_LOCKED; + let known = dontneed || [&HINTS[..], &REFUSED, &PRIVILEGED].iter().any(|s| s.contains(&advice)); + let end = len.checked_add(PAGE - 1).map(|l| l & !(PAGE - 1)).and_then(|l| addr.checked_add(l)); + let Some(end) = end.filter(|_| known && addr % PAGE == 0) else { + return errno::fail(errno::EINVAL); + }; + if end == addr { + return errno::ok(0); + } + if PRIVILEGED.contains(&advice) { + return errno::fail(errno::EPERM); + } + let (parts, hole) = parts(guest, addr, end); + let done = match advice { + a if REFUSED.contains(&a) => errno::fail(errno::EINVAL), + _ if dontneed => drop_pages(guest, &parts), + _ => errno::ok(0), + }; + if hole && done == errno::ok(0) { + return errno::fail(errno::ENOMEM); + } + done +} diff --git a/userland/capsule_linux/src/linux/call/mem/advise_drop.rs b/userland/capsule_linux/src/linux/call/mem/advise_drop.rs new file mode 100644 index 0000000000..b1d21218ff --- /dev/null +++ b/userland/capsule_linux/src/linux/call/mem/advise_drop.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 . + +//! MADV_DONTNEED: each page of the span reads zero from then on, as Linux +//! gives private anonymous memory back. + +use alloc::vec::Vec; + +use crate::linux::abi::errno; +use crate::linux::guest::{Guest, Region}; + +/// Zeroes written a piece at a time. +const CHUNK: usize = 64 << 10; + +/// The pieces of the guest's regions within [at, end), and whether any page +/// of it is not mapped, which Linux answers with ENOMEM once it has applied +/// the advice to the pages that are. +pub(super) fn parts(guest: &Guest, at: u64, end: u64) -> (Vec, bool) { + let (mut out, mut hole, mut reach) = (Vec::new(), false, at); + while reach < end { + let Some(r) = guest.regions.iter().find(|r| r.at <= reach && reach < r.at + r.len) else { + hole = true; + reach = guest.regions.iter().map(|r| r.at).filter(|&s| s > reach).min().unwrap_or(end); + continue; + }; + let stop = (r.at + r.len).min(end); + out.push(Region { at: reach, len: stop - reach, ..*r }); + reach = stop; + } + (out, hole) +} + +/// What Linux would reload rather than zero, a file's bytes or a shared +/// mapping's, this capsule cannot, so any of it refuses the whole span before +/// a byte changes. A backed page is zeroed where it is and keeps its +/// protection; a reservation's touched pages are given back, to be filled +/// with zeroes when next touched. +pub(super) fn drop_pages(guest: &mut Guest, parts: &[Region]) -> u64 { + if parts.iter().any(|p| p.kept) { + return errno::fail(errno::EINVAL); + } + let zeros = alloc::vec![0u8; CHUNK]; + for p in parts { + let rc = if p.backed { zero(guest, p, &zeros) } else { guest.drop_frames(p.at, p.len) }; + if rc < 0 { + return errno::fail(errno::EFAULT); + } + } + errno::ok(0) +} + +fn zero(guest: &Guest, p: &Region, zeros: &[u8]) -> i64 { + let mut at = p.at; + while at < p.at + p.len { + let n = (p.at + p.len - at).min(zeros.len() as u64); + if guest.write(at, &zeros[..n as usize]) < 0 { + return -1; + } + at += n; + } + 0 +} 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..7edccc6992 100644 --- a/userland/capsule_linux/src/linux/call/mem/map_anon.rs +++ b/userland/capsule_linux/src/linux/call/mem/map_anon.rs @@ -22,6 +22,8 @@ use crate::linux::guest::Guest; use super::map_req::MapReq; use super::prot::{PROT_EXEC, PROT_WRITE}; +const MAP_SHARED: u64 = 0x01; + /// A memfd has nothing to read in: it is pages, and the client is about to /// draw into them. pub fn memfd(guest: &mut Guest, req: &MapReq, at: u64, span: u64) -> u64 { @@ -29,6 +31,7 @@ pub fn memfd(guest: &mut Guest, req: &MapReq, at: u64, span: u64) -> u64 { if (out as i64) < 0 { return out; } + guest.mark_kept(at, span); crate::linux::file::set_mapped(guest, req.fd, at); /* * A descriptor this capsule staged content on, the keymap being the one @@ -44,11 +47,26 @@ 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 { + /* A fixed anonymous span reads as zeros; drop frames map would keep. */ + if req.fixed().is_some() { + guest.drop_frames(at, span); + } + guest.map(at, span, req.prot & PROT_WRITE != 0, req.prot & PROT_EXEC != 0) + }; + if backed < 0 { return errno::fail(errno::ENOMEM); } + if req.flags & MAP_SHARED != 0 { + /* Shared pages keep their bytes after MADV_DONTNEED on Linux. */ + guest.mark_kept(at, span); + } if req.fixed().is_none() { guest.mmap_next += span; } 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..c7ce839706 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,30 @@ pub fn file(guest: &mut Guest, req: &MapReq, at: u64, span: u64) -> u64 { * half-filled span left behind by a late refusal is memory the guest still * holds and did not ask to keep. */ - if req.prot & PROT_EXEC != 0 && !proven(guest, req.fd) { + let proved = (req.prot & PROT_EXEC != 0).then(|| proven(guest, req.fd)); + if let Some(None) = proved { return errno::fail(errno::EPERM); } if guest.map(at, span, true, false) < 0 { return errno::fail(errno::ENOMEM); } - let mut done = 0u64; - while done < req.len { - let n = pread64(guest, req.fd, at + done, req.len - done, req.off + done) as i64; - if n < 0 { + /* Linux reloads a file's bytes after MADV_DONTNEED; this capsule cannot. */ + guest.mark_kept(at, span); + 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..4c3360198c 100644 --- a/userland/capsule_linux/src/linux/call/mem/mod.rs +++ b/userland/capsule_linux/src/linux/call/mem/mod.rs @@ -17,16 +17,23 @@ //! The calls that shape a guest's address space: `mmap`, `munmap`, `brk` and //! `mprotect`. +mod advise; +mod advise_drop; 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 advise::madvise; 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..60da9c23f1 --- /dev/null +++ b/userland/capsule_linux/src/linux/call/mem/remap.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 . + +//! `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); + if guest.span_kept(old, old_len) { + guest.mark_kept(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..754a832b15 --- /dev/null +++ b/userland/capsule_linux/src/linux/call/mem/remap_move.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 . + +//! `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); + }; + let kept = guest.span_kept(old, old_len); + // 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 kept { + guest.mark_kept(at, 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..e7f2f2eb17 100644 --- a/userland/capsule_linux/src/linux/call/mod.rs +++ b/userland/capsule_linux/src/linux/call/mod.rs @@ -16,13 +16,20 @@ //! One file per family of Linux calls; declarations and re-exports only. +mod clock; mod console; mod ctl; mod cwd; +mod epoch; mod futex; +mod futex_requeue; +mod futex_time; +mod glibc; +mod glibc_sched; mod ident; mod io; mod io_socket; +mod ioctl; mod life; mod limits; mod limits_table; @@ -31,10 +38,15 @@ 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 clock::{clock_getres, clock_gettime, now_ms}; +pub use ctl::fcntl; pub use cwd::{chdir, fchdir, getcwd}; +pub use epoch::{family_ms, mark_start}; pub use futex::futex; +pub use glibc::prctl; +pub use glibc_sched::{clone3, getcpu, membarrier, sched_getaffinity}; pub use ident::{getppid, setuid}; pub use io::{close, read, write}; -pub use life::{exit, exit_thread}; +pub use ioctl::ioctl; +pub use life::{exit, exit_thread, set_tid_address}; pub use limits::{getrlimit, prlimit64}; -pub use mem::{brk, mmap, mprotect, munmap, MapReq}; +pub use mem::{brk, madvise, 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_send::kill; -pub use sleep::nanosleep; -pub use spawn::{clone, execve, fork, wait4}; -pub use thread::{arch_prctl, clock_gettime, getrandom}; +pub use signal_stack::sigaltstack; +pub use sigreturn::rt_sigreturn; +pub use sleep::{clock_nanosleep, nanosleep}; +pub use spawn::{clone, execve, fork, reap_one, wait4}; +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/build.rs b/userland/capsule_linux/src/linux/call/sigframe/build.rs new file mode 100644 index 0000000000..f747fa9b5b --- /dev/null +++ b/userland/capsule_linux/src/linux/call/sigframe/build.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 bytes of a signal frame, and the registers that enter the handler. + +use alloc::vec::Vec; + +use super::layout::{put, FRAME_SIZE, INFO_OFF, SIGCONTEXT_OFF, SIGMASK_OFF, STACK_OFF, UC_OFF}; +use super::layout::{SS_DISABLE, SS_ONSTACK, WORDS}; +use super::place::{on_alt, place}; + +/// rflags' direction flag. +const DF: u64 = 0x400; + +/// 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 frame = place(rsp, alt, onstack)?; + let mut buf = alloc::vec![0u8; FRAME_SIZE]; + put(&mut buf, 0, restorer); + /* uc_stack: the alternate stack, flagged as sas_ss_flags gives it for rsp. */ + let (ss_sp, ss_size, ss_flags) = match alt { + None => (0, 0, SS_DISABLE), + Some((sp, size)) => (sp, size, if on_alt(alt, rsp) { 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); + /* siginfo: si_signo alone. */ + put(&mut buf, INFO_OFF, u64::from(signum)); + Some((frame, buf, entry(regs, frame, handler, signum))) +} + +/// The handler's registers: rdi the signal, rsi the siginfo, rdx the +/// ucontext, rsp the frame and rip the handler. rflags is the interrupted +/// one with DF clear, as the System V ABI and Linux's handle_signal enter a +/// function; the kernel masks the rest. rax stays 0, no vector register set. +fn entry(regs: &[u64; WORDS], frame: u64, handler: u64, signum: u32) -> [u64; WORDS] { + let mut out = [0u64; WORDS]; + out[8] = u64::from(signum); + out[9] = frame + INFO_OFF as u64; + out[12] = frame + UC_OFF as u64; + out[15] = frame; + out[16] = handler; + out[17] = regs[17] & !DF; + out +} diff --git a/userland/capsule_linux/src/linux/call/sigframe/layout.rs b/userland/capsule_linux/src/linux/call/sigframe/layout.rs new file mode 100644 index 0000000000..0e65404dc0 --- /dev/null +++ b/userland/capsule_linux/src/linux/call/sigframe/layout.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 each part of Linux's `rt_sigframe` sits, and reading a returning +//! frame's registers back. + +/// The words `mk_foreign_context` uses: r8..r15, rdi, rsi, rbp, rbx, rdx, +/// rax, rcx, rsp, rip, rflags. +pub const WORDS: usize = 18; +pub(super) const FRAME_SIZE: usize = 440; +pub(super) const UC_OFF: usize = 8; +/// `uc_mcontext` within the ucontext. +pub const SIGCONTEXT_OFF: usize = 40; +/// `uc_sigmask` within the ucontext. +pub(super) const SIGMASK_OFF: usize = 296; +/// `uc_stack` within the ucontext. +pub(super) const STACK_OFF: usize = 16; +pub(super) const INFO_OFF: usize = 312; +pub(super) const SS_ONSTACK: u64 = 1; +pub(super) const SS_DISABLE: u64 = 2; + +pub(super) fn put(buf: &mut [u8], at: usize, v: u64) { + buf[at..at + 8].copy_from_slice(&v.to_le_bytes()); +} + +/// 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/sigframe/mod.rs b/userland/capsule_linux/src/linux/call/sigframe/mod.rs new file mode 100644 index 0000000000..9e7d460309 --- /dev/null +++ b/userland/capsule_linux/src/linux/call/sigframe/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 . + +//! 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. + +mod build; +mod layout; +mod place; + +pub use build::build; +pub use layout::{returned, SIGCONTEXT_OFF, WORDS}; diff --git a/userland/capsule_linux/src/linux/call/sigframe/place.rs b/userland/capsule_linux/src/linux/call/sigframe/place.rs new file mode 100644 index 0000000000..a78182a299 --- /dev/null +++ b/userland/capsule_linux/src/linux/call/sigframe/place.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 a signal frame lands on a thread's stack. + +use super::layout::FRAME_SIZE; + +/// The System V red zone below rsp. +const REDZONE: u64 = 128; + +/// True when `rsp` is on the alternate stack `(base, size)`. +pub(super) fn on_alt(alt: Option<(u64, u64)>, rsp: u64) -> bool { + alt.is_some_and(|(sp, size)| rsp > sp && rsp - sp <= size) +} + +/// The frame's address as Linux's `get_sigframe` picks it, or `None` when +/// the frame does not fit. +pub(super) fn place(rsp: u64, alt: Option<(u64, u64)>, onstack: bool) -> Option { + /* + * 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(alt, rsp) => 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, whether + * the handler enters it or the thread is on it already, is not written + * over whatever lies below it. + */ + if let Some((sp, _)) = alt.filter(|_| onstack || on_alt(alt, rsp)) { + if frame <= sp { + return None; + } + } + Some(frame) +} 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..61c54ec52e 100644 --- a/userland/capsule_linux/src/linux/call/signal_send.rs +++ b/userland/capsule_linux/src/linux/call/signal_send.rs @@ -14,39 +14,56 @@ // 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 { + /* An existence check, not a signal. */ return errno::ok(0); } - 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, or + * at its next tick when that answer cannot carry it. + */ + 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..3e1af0fc24 --- /dev/null +++ b/userland/capsule_linux/src/linux/call/signal_stack.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 . + +//! `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::sigstack_t::{decode, encode, SS_AUTODISARM, STACK_T}; +use crate::linux::guest::Guest; + +/// x86-64's MINSIGSTKSZ, the smallest stack the kernel accepts. +const MINSIGSTKSZ: u64 = 2048; +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::say::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 + } +} 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..98b88d90a8 --- /dev/null +++ b/userland/capsule_linux/src/linux/call/sigreturn.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 . + +//! `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 back where the signal interrupted, and a signal still waiting +//! for it is taken at its next tick. + +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); + guest.rearm(tid); + 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..9f3c7d31fa 100644 --- a/userland/capsule_linux/src/linux/call/spawn/clone.rs +++ b/userland/capsule_linux/src/linux/call/spawn/clone.rs @@ -24,13 +24,21 @@ 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 +56,20 @@ 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. The + * CLEARTID word is 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..2150dd41cd 100644 --- a/userland/capsule_linux/src/linux/call/spawn/fork_copy.rs +++ b/userland/capsule_linux/src/linux/call/spawn/fork_copy.rs @@ -14,21 +14,33 @@ // 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}; +use crate::linux::guest::{Guest, Region, MAX_SPAN}; use nonos_libc::peer::{mk_peer_map, mk_peer_write, PEER_PROT_EXEC, PEER_PROT_WRITE}; /// Every span, mapped into the child and then filled from the parent. pub(super) fn copy_spans(guest: &mut Guest, child: u32) -> bool { let spans = guest.regions.clone(); for span in spans { - if mk_peer_map(child, span.at, span.len, prot_of(&span)) < 0 { - return false; + // 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 !copy_one(guest, child, span.at, span.len) { - return false; + /* + * A piece at a time: the kernel takes at most MAX_SPAN a call, and a + * region past it, a megabyte of static buffer for one, failed the + * whole fork. + */ + let mut done = 0; + while done < span.len { + let (at, take) = (span.at + done, (span.len - done).min(MAX_SPAN)); + if mk_peer_map(child, at, take, prot_of(&span)) < 0 || !copy_one(guest, child, at, take) + { + return false; + } + done += take; } } true 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..ab38f005f5 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 store::{read as store_read, stat as store_stat, write as store_write}; +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..5d3261e904 100644 --- a/userland/capsule_linux/src/linux/file/store.rs +++ b/userland/capsule_linux/src/linux/file/store.rs @@ -26,20 +26,38 @@ use super::root::Key; type Fail = &'static str; +/// The file at `at`, up to `max` bytes, in one allocation of its size. Grown +/// as it arrived, a 4 MB program passed through an 8 MiB buffer, which the +/// 16 MiB heap of a run could not give beside what it already held. pub fn read(at: &Key, max: u32) -> Result, Fail> { - vfs::read_file(mk_getpid(), at.as_bytes(), max) + let (size, _) = vfs::stat(mk_getpid(), at.as_bytes())?; + let len = u32::try_from(size).unwrap_or(u32::MAX).min(max); + VfsStream::open(mk_getpid(), at.as_bytes())?.read_window(0, len) } 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_terminal/src/term/grid/cell.rs b/userland/capsule_linux/src/linux/guest/blocked.rs similarity index 54% rename from userland/capsule_terminal/src/term/grid/cell.rs rename to userland/capsule_linux/src/linux/guest/blocked.rs index 58e2797fca..257a063e0f 100644 --- a/userland/capsule_terminal/src/term/grid/cell.rs +++ b/userland/capsule_linux/src/linux/guest/blocked.rs @@ -14,29 +14,20 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -pub const F_BOLD: u8 = 1; -pub const F_UNDERLINE: u8 = 2; -pub const F_REVERSE: u8 = 4; -/// The right half of a character drawn two columns wide. It holds no glyph -/// of its own; the cell before it drew both halves. -pub const F_WIDE_TAIL: u8 = 8; +//! A call waiting on descriptors, left parked in its trap until it can +//! complete or its deadline passes. #[derive(Clone, Copy)] -pub struct Cell { - pub ch: char, - // Full ARGB foreground and background, so 24-bit colour is preserved. - pub fg: u32, - pub bg: u32, - pub flags: u8, -} - -impl Cell { - pub const fn blank() -> Cell { - Cell { - ch: ' ', - fg: crate::term::vt::color::DEFAULT_FG, - bg: crate::term::vt::color::DEFAULT_BG, - flags: 0, - } - } +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, + /// Bytes of a write already put in: Linux answers a write to a blocking + /// pipe only once the whole of it is, so it waits on for the rest. + pub done: u64, } 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..bbcff7d5ff --- /dev/null +++ b/userland/capsule_linux/src/linux/guest/links_load.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 . + +//! Reading the image's link table. + +use crate::linux::file::{key, store_read, visible}; + +use super::links::Links; + +const MAX_TABLE: u32 = 64 << 10; + +impl Links { + /// Where the image lists its links. + pub const TABLE: &'static [u8] = b"/etc/nonos-links"; + + /// The image's links, or none when the table cannot be read. + pub fn load() -> Links { + Self::try_load().unwrap_or_default() + } + + /// The image's links, or why the table could not be read. + pub fn try_load() -> Result { + let raw = store_read(&key(Self::TABLE), MAX_TABLE)?; + 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())) + }); + Ok(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..9b1bf96c8b 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; @@ -48,9 +49,11 @@ impl Guest { } /* * Remembered because fork copies a guest by walking what its - * supervisor gave it. + * supervisor gave it. The newest mapping is the only record of its + * span, as on Linux. */ - self.regions.push(Region { at: start, len: span, write, exec }); + self.regions = cut(&self.regions, start, span); + self.regions.push(Region::new(start, span, write, exec, true)); 0 } } diff --git a/userland/capsule_linux/src/linux/guest/mem_reserve.rs b/userland/capsule_linux/src/linux/guest/mem_reserve.rs new file mode 100644 index 0000000000..d1caef268d --- /dev/null +++ b/userland/capsule_linux/src/linux/guest/mem_reserve.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 . + +//! Address space taken ahead of use, and committing part of it. + +use nonos_libc::peer::{mk_peer_map, PEER_PROT_EXEC, PEER_PROT_WRITE}; + +use super::handle::Guest; +use super::layout::USER_MAX; +use super::mem::{span_within, MAX_SPAN}; +use super::region::Region; +use super::region_cut::cut; + +impl Guest { + /// 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; + } + let kept = self.span_kept(at, len); + self.regions = cut(&self.regions, at, len); + self.regions.push(Region { kept, ..Region::new(at, len, write, exec, 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::new(start, span, true, false, 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..219ecb34f6 100644 --- a/userland/capsule_linux/src/linux/guest/mem_unmap.rs +++ b/userland/capsule_linux/src/linux/guest/mem_unmap.rs @@ -19,16 +19,28 @@ 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 rc = self.drop_frames(start, span); + if rc < 0 { + return rc; + } + self.regions = cut(&self.regions, start, span); + 0 + } + + /// Take the frames under `[start, start + span)`, a page-aligned span, + /// back from the guest and leave its region list alone: a page not there + /// is skipped, and one touched again reads zero. + pub fn drop_frames(&self, start: u64, span: u64) -> i64 { let mut done = 0; while done < span { let take = (span - done).min(MAX_SPAN); @@ -38,7 +50,6 @@ impl Guest { } done += take; } - self.regions = cut(&self.regions, start, span); 0 } } diff --git a/userland/capsule_linux/src/linux/guest/mod.rs b/userland/capsule_linux/src/linux/guest/mod.rs index 19f20d91c8..8b99943fc5 100644 --- a/userland/capsule_linux/src/linux/guest/mod.rs +++ b/userland/capsule_linux/src/linux/guest/mod.rs @@ -17,29 +17,50 @@ //! 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; mod layout; +mod links; +mod links_add; +mod links_list; +mod links_load; mod mem; mod mem_copy; mod mem_map; +mod mem_reserve; mod mem_unmap; mod region; mod region_cut; mod region_find; +mod region_mark; +mod sigpending; +pub mod sigqueue; +pub mod sigstack; +pub mod sigstack_t; +pub mod sigstate; 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 layout::{ - BRK_BASE, BRK_LIMIT, EXEC_BASE, INTERP_BASE, MMAP_BASE, MMAP_LIMIT, STACK_SIZE, - STACK_TOP, + BRK_BASE, BRK_LIMIT, EXEC_BASE, INTERP_BASE, MMAP_BASE, MMAP_LIMIT, STACK_SIZE, STACK_TOP, + USER_MAX, }; +pub use links::Links; 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..23299d9a0f 100644 --- a/userland/capsule_linux/src/linux/guest/region.rs +++ b/userland/capsule_linux/src/linux/guest/region.rs @@ -22,4 +22,22 @@ 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, + /// Bytes Linux would give back after MADV_DONTNEED, not zero: a file's, + /// an ELF segment's or a shared mapping's. This capsule cannot give them + /// back, so it refuses that advice here. + pub kept: bool, +} + +impl Region { + /// A span this capsule laid down, anonymous until a mark says otherwise. + pub fn new(at: u64, len: u64, write: bool, exec: bool, backed: bool) -> Self { + Self { at, len, write, exec, unproven: false, backed, kept: false } + } } 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..87e0c5ca80 --- /dev/null +++ b/userland/capsule_linux/src/linux/guest/region_mark.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 . + +//! 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)) + } + + /// Every region overlapping [at, at + len) holds bytes Linux would give + /// back after MADV_DONTNEED rather than zero. + pub fn mark_kept(&mut self, at: u64, len: u64) { + let end = at.saturating_add(len); + for r in self.regions.iter_mut().filter(|r| r.at < end && at < r.at.saturating_add(r.len)) { + r.kept = true; + } + } + + /// Whether any region overlapping [at, at + len) is marked kept. + pub fn span_kept(&self, at: u64, len: u64) -> bool { + let end = at.saturating_add(len); + self.regions.iter().any(|r| r.kept && r.at < end && at < r.at.saturating_add(r.len)) + } +} diff --git a/userland/capsule_linux/src/linux/guest/sigpending.rs b/userland/capsule_linux/src/linux/guest/sigpending.rs new file mode 100644 index 0000000000..ef5b530ab9 --- /dev/null +++ b/userland/capsule_linux/src/linux/guest/sigpending.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 caught signal still waiting after a thread was answered without it: the +//! answer to a woken futex or to rt_sigreturn cannot carry a handler, so the +//! thread is marked instead, the kernel stops it at its next tick in its own +//! code, and it is delivered there. + +use super::handle::Guest; +use super::sigqueue::Signals; + +impl Signals { + /// Whether `take_caught` would find a signal for `tid`. + pub fn has_caught(&self, tid: u32) -> bool { + self.pending.iter().any(|(t, s)| *t == tid && self.actions[*s as usize - 1].catches()) + } +} + +impl Guest { + /// After an answer that entered no handler, mark `tid` for a tick stop if + /// a caught signal is still waiting for it. + pub fn rearm(&self, tid: u32) { + if self.signals.has_caught(tid) { + let _ = nonos_libc::mk_foreign_interrupt(tid); + } + } +} 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..204876d0fe --- /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 { + pub(super) actions: [SigAction; NSIG], + pub(super) 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/sigstack_t.rs b/userland/capsule_linux/src/linux/guest/sigstack_t.rs new file mode 100644 index 0000000000..813864871f --- /dev/null +++ b/userland/capsule_linux/src/linux/guest/sigstack_t.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 . + +//! Linux's `stack_t`, the form `sigaltstack` reads and writes a stack in. + +use super::sigstack::{AltStack, SS_DISABLE, SS_ONSTACK}; + +/// `stack_t` is 24 bytes: ss_sp, ss_flags (an int, then 4 bytes of padding), +/// ss_size. +pub const STACK_T: usize = 24; +/// Linux 4.7's flag bit that disarms the stack while a handler runs on it. +pub const SS_AUTODISARM: u32 = 1 << 31; + +/// The base, flags and size a `stack_t` holds. +pub 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/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..f2363c1fa4 100644 --- a/userland/capsule_linux/src/linux/guest/threads.rs +++ b/userland/capsule_linux/src/linux/guest/threads.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 guest's threads, and the ones parked on a futex. use nonos_libc::mk_foreign_reply; @@ -48,10 +47,20 @@ 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; + self.rearm(tid); } } 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/image/segment.rs b/userland/capsule_linux/src/linux/image/segment.rs index 76825b60fc..285e7514a5 100644 --- a/userland/capsule_linux/src/linux/image/segment.rs +++ b/userland/capsule_linux/src/linux/image/segment.rs @@ -42,6 +42,7 @@ pub(super) fn segment( if guest.map(at, ph.memsz, ph.flags & PF_W != 0, ph.flags & PF_X != 0) < 0 { return Err(LoadError::Map); } + guest.mark_kept(at, ph.memsz); if ph.filesz == 0 { return Ok(()); } 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_driver_e1000/src/protocol/endpoint.rs b/userland/capsule_linux/src/linux/install/deb/mod.rs similarity index 71% rename from userland/capsule_driver_e1000/src/protocol/endpoint.rs rename to userland/capsule_linux/src/linux/install/deb/mod.rs index 25488de1b5..a4662e1dbc 100644 --- a/userland/capsule_driver_e1000/src/protocol/endpoint.rs +++ b/userland/capsule_linux/src/linux/install/deb/mod.rs @@ -14,9 +14,18 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -//! Reply inbox the kernel-side client owns. Slot 12 in the -//! per-service reply numbering (ramfs=1, keyring=2, entropy=3, -//! crypto=4, vfs=5, virtio_rng=6, market=7, virtio_blk=8, -//! virtio_net=9, ps2_input=A, xhci=B, e1000=C). +//! 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. -pub const KERNEL_REPLY_ENDPOINT: u64 = 0x1_0000_000C; +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_driver_rtl8139/src/tx/poll_done.rs b/userland/capsule_linux/src/linux/install/pgp/verifier.rs similarity index 53% rename from userland/capsule_driver_rtl8139/src/tx/poll_done.rs rename to userland/capsule_linux/src/linux/install/pgp/verifier.rs index f3cd58557a..07e9ac0550 100644 --- a/userland/capsule_driver_rtl8139/src/tx/poll_done.rs +++ b/userland/capsule_linux/src/linux/install/pgp/verifier.rs @@ -14,24 +14,25 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -use core::sync::atomic::{compiler_fence, Ordering}; +//! OpenPGP's arithmetic on this machine: RSA through the crypto service, and +//! Ed25519 in this capsule. -use crate::constants::regs::{TX_STATUS_ABORT, TX_STATUS_OK, TX_STATUS_UNDERRUN}; -use crate::setup::Driver; +use nonos_openpgp::Verifier; -const TX_POLL_BUDGET: u32 = 1_000_000; +use super::request::request; -pub(super) fn poll_done(driver: &Driver, status_reg: u16) -> Result<(), &'static str> { - for _ in 0..TX_POLL_BUDGET { - compiler_fence(Ordering::Acquire); - let status = driver.pio.r32(status_reg)?; - if (status & (TX_STATUS_ABORT | TX_STATUS_UNDERRUN)) != 0 { - return Err("rtl8139 tx error"); - } - if (status & TX_STATUS_OK) != 0 { - return Ok(()); - } - core::hint::spin_loop(); +/// 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)) } - Err("rtl8139 tx timeout") } 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..6c7b7c352f --- /dev/null +++ b/userland/capsule_linux/src/linux/launch.rs @@ -0,0 +1,40 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! What the personality is about to run. + +use alloc::vec::Vec; + +use super::origin::Origin; + +pub struct Launch { + /// The guest-visible path, proved before it runs. + pub path: Vec, + pub bytes: Vec, + pub origin: Origin, + /// Arguments after argv[0]. + pub args: Vec>, + /// argv[0] when it is not `path`: the name of a link, which a multi-call + /// program such as busybox reads to tell which of its programs to be. + pub argv0: Option>, +} + +impl Launch { + /// A program read from the store, under its own path. + pub fn store(path: Vec, bytes: Vec, args: Vec>) -> Self { + Self { path, bytes, origin: Origin::Store, args, argv0: None } + } +} diff --git a/userland/capsule_linux/src/linux/mod.rs b/userland/capsule_linux/src/linux/mod.rs index cde2e2126d..c52d00ee07 100644 --- a/userland/capsule_linux/src/linux/mod.rs +++ b/userland/capsule_linux/src/linux/mod.rs @@ -22,19 +22,28 @@ 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; +mod say; pub mod serve; +mod settle; mod source; mod start; mod start_guest; +mod terminal; +mod terminal_launch; +mod terminal_path; mod unix; mod wayland; diff --git a/userland/capsule_linux/src/linux/net/connect.rs b/userland/capsule_linux/src/linux/net/connect.rs index ce5deac6d9..97e85a6f34 100644 --- a/userland/capsule_linux/src/linux/net/connect.rs +++ b/userland/capsule_linux/src/linux/net/connect.rs @@ -24,7 +24,7 @@ use crate::linux::guest::{Guest, Kind}; use super::addr::inet; use super::call::call; use super::dns::host_for; -use super::ops::{OP_CONNECT, OP_CONNECT_HOST}; +use super::ops::{NET_E_NO_TRANSPORT, OP_CONNECT, OP_CONNECT_HOST}; pub fn connect(guest: &mut Guest, fd: u64, at: u64, len: u64) -> u64 { /* @@ -48,24 +48,25 @@ pub fn connect(guest: &mut Guest, fd: u64, at: u64, len: u64) -> u64 { body.extend_from_slice(&handle.to_le_bytes()); body.extend_from_slice(&ip); body.extend_from_slice(&port.to_le_bytes()); - match call(OP_CONNECT, &body, 0) { - Some((0, _)) => errno::ok(0), - Some(_) => errno::fail(errno::ECONNREFUSED), - None => errno::fail(errno::EIO), - } + outcome(call(OP_CONNECT, &body, 0)) } 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) { + }; + outcome(call(OP_CONNECT_HOST, &body, 0)) +} + +/* + * What a connect reply means to the guest. No transport is a mixnet holding + * no gateway: there is no route out, which a tool has to read as unreachable + * and not as a peer that answered and refused. + */ +fn outcome(reply: Option<(u16, Vec)>) -> u64 { + match reply { Some((0, _)) => errno::ok(0), + Some((NET_E_NO_TRANSPORT, _)) => errno::fail(errno::ENETUNREACH), Some(_) => errno::fail(errno::ECONNREFUSED), None => errno::fail(errno::EIO), } 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..82fbed8aa2 100644 --- a/userland/capsule_linux/src/linux/net/ops.rs +++ b/userland/capsule_linux/src/linux/net/ops.rs @@ -26,6 +26,13 @@ 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; + +/// net.sockets' status for "connect had no transport to give the socket": the +/// mixnet holds no gateway, so there is no route out, not a peer that refused. +/// Kept in sync with E_NO_TRANSPORT in +/// userland/capsule_net_sockets/src/protocol/errno.rs. +pub const NET_E_NO_TRANSPORT: u16 = 6; /// 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/say.rs b/userland/capsule_linux/src/linux/say.rs new file mode 100644 index 0000000000..cc5ba81a7c --- /dev/null +++ b/userland/capsule_linux/src/linux/say.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 . + +//! What the personality says, through its debug channel: `say` for what went +//! wrong and for what a guest prints, `note` for how a run is going. + +use nonos_libc::mk_debug; + +/// The kernel takes at most 256 bytes a call and drops a longer line whole. +pub(super) fn say(line: &[u8]) { + for piece in line.chunks(256) { + let _ = mk_debug(piece.as_ptr(), piece.len()); + } +} + +/// A line about how a run is going, not about anything wrong with it. The +/// terminal's `linux` command shows only the program's own output and what +/// went wrong, so these stay out of it. +pub(super) fn note(line: &[u8]) { + if !super::terminal::started() { + say(line); + } +} 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..979a3f1e17 --- /dev/null +++ b/userland/capsule_linux/src/linux/serve/deliver.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 . + +//! Delivering a caught signal to a thread being answered: one returning from +//! a syscall is entered with that call's return value in rax, so the program +//! sees it when the handler returns through rt_sigreturn; one stopped at a +//! tick (family_interrupt) keeps its own rax. One woken by a futex or back +//! from rt_sigreturn is marked for its next tick instead (`Guest::rearm`). + +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 +} + +/// A line for the log, not for the terminal's window: how a run is going. +fn say(tid: u32, signum: u8, handler: u64) { + let line = alloc::format!("[LINUX] signal {signum} to tid {tid}, handler {handler:#x}\n"); + crate::linux::say::note(line.as_bytes()); +} diff --git a/userland/capsule_linux/src/linux/serve/dispatch.rs b/userland/capsule_linux/src/linux/serve/dispatch.rs index 67226625ec..ad2d4f55f6 100644 --- a/userland/capsule_linux/src/linux/serve/dispatch.rs +++ b/userland/capsule_linux/src/linux/serve/dispatch.rs @@ -14,28 +14,62 @@ // 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 | nr::READV | nr::WRITEV + if super::waits_try::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..cb1249a748 --- /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::say::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..eb6df5b5ee --- /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..f9d70eae34 --- /dev/null +++ b/userland/capsule_linux/src/linux/serve/family_reap.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 . + +//! 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, + ); + if !super::deliver::maybe_deliver(p, tid, 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..771662d348 --- /dev/null +++ b/userland/capsule_linux/src/linux/serve/family_sleep.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 . + +//! Answering sleepers whose deadline has passed. + +use alloc::vec::Vec; + +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() { + let due: Vec = + g.sleepers.iter().filter(|&&(d, _)| d <= now).map(|&(_, t)| t).collect(); + g.sleepers.retain(|&(d, _)| d > now); + for tid in due { + // A caught signal for this thread is delivered in place of the reply. + if !super::deliver::maybe_deliver(g, tid, 0) { + let _ = mk_foreign_reply(tid, 0); + } + } + } + } + + /// 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..944dcd216f --- /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_fds::watched; +use super::waits_try::{attempt, expire}; +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 mut wait in mem::take(&mut g.blocked) { + let value = match attempt(g, &mut 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..2aadd7723f 100644 --- a/userland/capsule_linux/src/linux/serve/mod.rs +++ b/userland/capsule_linux/src/linux/serve/mod.rs @@ -17,14 +17,35 @@ //! 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 pid_out; +mod refused; 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_iov; +mod waits_time; +mod waits_try; +mod waits_write; 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..c2b20513f5 100644 --- a/userland/capsule_linux/src/linux/serve/table_mem.rs +++ b/userland/capsule_linux/src/linux/serve/table_mem.rs @@ -16,7 +16,7 @@ //! Calls that shape the guest's address space. -use crate::linux::abi::{errno, nr}; +use crate::linux::abi::nr; use crate::linux::call; use crate::linux::guest::Guest; @@ -26,8 +26,8 @@ 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]), - // Advice, and this capsule takes none of it. - nr::MADVISE => errno::ok(0), + nr::MREMAP => call::mremap(guest, a[0], a[1], a[2], a[3]), + nr::MADVISE => call::madvise(guest, a[0], a[1], a[2]), _ => 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..25de2563b6 --- /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"); + crate::linux::say::note(line.as_bytes()); +} 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..669892042d --- /dev/null +++ b/userland/capsule_linux/src/linux/serve/waits.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 . + +//! 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; +use crate::linux::call::now_ms; +use crate::linux::guest::{Blocked, Guest}; + +use super::answer::Answer; +use super::waits_time::span; +use super::waits_try::{attempt, expire}; + +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 mut wait = Blocked { tid, nr, args: a, deadline, done: 0 }; + match attempt(guest, &mut wait) { + Some(v) => Answer::value(v), + None if deadline.is_some_and(|d| d <= now) => Answer::value(expire(guest, &wait)), + None => park(guest, wait), + } +} + +/// 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 mut wait = Blocked { tid, nr, args: a, deadline: None, done: 0 }; + match attempt(guest, &mut 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), + } +} + +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..76dcdf41d2 --- /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 | nr::READV | nr::WRITEV => 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_iov.rs b/userland/capsule_linux/src/linux/serve/waits_iov.rs new file mode 100644 index 0000000000..cbbc7219cc --- /dev/null +++ b/userland/capsule_linux/src/linux/serve/waits_iov.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 waiting write needs to know between tries: whether its descriptor +//! waits for all of it, what it answers after a failure, and its vector. + +use alloc::vec::Vec; + +use crate::linux::abi::errno; +use crate::linux::guest::{Blocked, Guest, Kind}; + +/// Linux refuses a longer vector. +const IOV_MAX: u64 = 1024; + +/// A blocking pipe, where Linux's writer waits until all of it is in. +pub(super) fn all(guest: &Guest, fd: u64) -> bool { + guest.fds.get(fd as usize).is_some_and(|f| f.kind == Kind::Pipe && !f.nonblock) +} + +/// A failure after some bytes went in answers that count, as Linux does. +pub(super) fn so_far(wait: &Blocked, failed: u64) -> u64 { + if wait.done == 0 { + failed + } else { + errno::ok(wait.done) + } +} + +pub(super) fn vector(guest: &Guest, iov: u64, count: u64) -> Result, u64> { + if count > IOV_MAX { + return Err(errno::fail(errno::EINVAL)); + } + let fault = errno::fail(errno::EFAULT); + let table = guest.read(iov, count as usize * 16).ok_or(fault)?; + let word = |b: &[u8]| <[u8; 8]>::try_from(b).map_or(0, u64::from_le_bytes); + Ok(table.chunks_exact(16).map(|e| (word(&e[..8]), word(&e[8..]))).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/serve/waits_try.rs b/userland/capsule_linux/src/linux/serve/waits_try.rs new file mode 100644 index 0000000000..c50137ca2f --- /dev/null +++ b/userland/capsule_linux/src/linux/serve/waits_try.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 . + +//! Trying a parked wait again, and what it answers when its time is up. + +use crate::linux::abi::{errno, nr, nr_path as np}; +use crate::linux::call; +use crate::linux::file; +use crate::linux::guest::{Blocked, Guest, Kind}; +use crate::linux::net; + +/// True for the reads and writes that can wait, vectored or not: 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) => matches!(nr, nr::READ | nr::READV), + _ => false, + } +} + +/// The call's answer if it can complete now, None if it would wait. +pub fn attempt(guest: &mut Guest, wait: &mut 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 | nr::WRITEV => super::waits_write::carry_on(guest, wait), + nr::READV => Some(call::readv(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 +} diff --git a/userland/capsule_linux/src/linux/serve/waits_write.rs b/userland/capsule_linux/src/linux/serve/waits_write.rs new file mode 100644 index 0000000000..51bc9686d0 --- /dev/null +++ b/userland/capsule_linux/src/linux/serve/waits_write.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 write to a blocking pipe, which Linux answers only once all of it is in: +//! a longer one than the pipe has room for waits for the reader, repeatedly. + +use crate::linux::abi::{errno, nr}; +use crate::linux::call; +use crate::linux::guest::{Blocked, Guest}; + +use super::waits_iov::{all, so_far, vector}; + +/// The answer once the write is done, None while it waits. What goes in is +/// counted in `done`, and the next try starts past it. +pub fn carry_on(guest: &mut Guest, wait: &mut Blocked) -> Option { + let a = wait.args; + let pieces = match wait.nr { + nr::WRITEV => match vector(guest, a[1], a[2]) { + Ok(pieces) => pieces, + Err(failed) => return Some(so_far(wait, failed)), + }, + _ => alloc::vec![(a[1], a[2])], + }; + let whole = pieces.iter().fold(0u64, |t, p| t.saturating_add(p.1)); + /* Nothing to put in: the plain call answers, refusals included. */ + if whole == 0 { + return Some(call::write(guest, a[0], a[1], 0)); + } + let mut skip = wait.done; + for (base, len) in pieces { + if skip >= len { + skip -= len; + continue; + } + let want = len - skip; + let got = call::write(guest, a[0], base.wrapping_add(skip), want); + skip = 0; + if (got as i64) < 0 { + /* EAGAIN waits, unless a non-blocking write already put some in. */ + let waits = got == errno::fail(errno::EAGAIN) && (wait.done == 0 || all(guest, a[0])); + return (!waits).then(|| so_far(wait, got)); + } + wait.done += got; + if got < want { + break; + } + } + (wait.done >= whole || !all(guest, a[0])).then(|| errno::ok(wait.done)) +} diff --git a/userland/capsule_linux/src/linux/settle.rs b/userland/capsule_linux/src/linux/settle.rs new file mode 100644 index 0000000000..b0ae2f6a48 --- /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::say::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] the 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); + super::say::note(&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..bbb9dbeffe 100644 --- a/userland/capsule_linux/src/linux/source.rs +++ b/userland/capsule_linux/src/linux/source.rs @@ -20,23 +20,34 @@ 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::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"); +use super::launch::Launch; 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 = Launch::store; + if let Some(asked) = super::terminal::requested(MAX_IMAGE) { + return asked; + } + 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)> { @@ -45,8 +56,8 @@ fn named() -> Option<(Vec, Vec)> { if n <= 0 { return None; } - // The first argument is the path. - let args = &buf[..n as usize]; + // The first argument is the path; longer arguments are none of the image's. + let args = buf.get(..usize::try_from(n).ok()?)?; let end = args.iter().position(|b| *b == 0 || *b == b' ').unwrap_or(args.len()); let path = args.get(..end)?; if path.is_empty() { diff --git a/userland/capsule_linux/src/linux/start.rs b/userland/capsule_linux/src/linux/start.rs index 971da847d4..c279e61892 100644 --- a/userland/capsule_linux/src/linux/start.rs +++ b/userland/capsule_linux/src/linux/start.rs @@ -16,23 +16,33 @@ //! 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_exit, mk_foreign_spawn}; -use super::guest::Guest; +use super::say::{note, say}; 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(); - say(b"[LINUX] personality up\n"); - if let Some(name) = super::request::install_request() { + super::heap::init(); + note(b"[LINUX] personality up\n"); + 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 { + /* The terminal's request has already said what it looked for. */ + if !super::terminal::started() { + 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,21 +51,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) + note(b"[LINUX] guest running\n"); + serve(guest) } Err(step) => { + super::file::clear_private(); say(step); mk_exit(2) } }; - say(b"[LINUX] guest exited\n"); + super::file::clear_private(); + note(b"[LINUX] guest exited\n"); mk_exit(code) } - -pub(super) fn say(line: &[u8]) { - let _ = mk_debug(line.as_ptr(), line.len()); -} diff --git a/userland/capsule_linux/src/linux/start_guest.rs b/userland/capsule_linux/src/linux/start_guest.rs index fcf34697dd..a324e8cb3e 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; +use super::say::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![launch.argv0.clone().unwrap_or_else(|| 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/terminal.rs b/userland/capsule_linux/src/linux/terminal.rs new file mode 100644 index 0000000000..f30976927d --- /dev/null +++ b/userland/capsule_linux/src/linux/terminal.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 . + +//! A program the terminal's `linux` command names. +//! +//! The terminal starts this capsule with `linux [args...]` and +//! shows what it prints. The program is a path in the Linux tree, or a bare +//! name looked for along the PATH a guest starts with. A name that is a link, +//! as busybox's applets are, runs what it links to with the link's name as +//! argv[0], which is how a multi-call program tells which one it is. Like +//! every program here, it must carry a proof that verifies. + +use alloc::vec::Vec; +use core::sync::atomic::{AtomicU8, Ordering}; + +use nonos_libc::mk_args; + +use super::launch::Launch; +use crate::linux::say::say; + +/// 0 not yet looked, 1 not started by the terminal, 2 started by it. +static STARTED: AtomicU8 = AtomicU8::new(0); + +/// True when the terminal's `linux` command started this capsule. +pub(super) fn started() -> bool { + match STARTED.load(Ordering::Relaxed) { + 0 => { + let got = args().unwrap_or_default(); + let yes = got.split(|b| *b == 0).next() == Some(b"linux".as_slice()); + STARTED.store(if yes { 2 } else { 1 }, Ordering::Relaxed); + yes + } + seen => seen == 2, + } +} + +/// None when the terminal did not start this capsule; otherwise what to run, +/// or None inside when there is nothing to run, with the reason said. +pub(super) fn requested(max_image: u32) -> Option> { + let got = args()?; + /* Every argument as typed, an empty one included. */ + let mut parts = got.split(|b| *b == 0); + if parts.next()? != b"linux" { + return None; + } + let Some(program) = parts.next().filter(|p| !p.is_empty()) else { + say(b"usage: linux [arguments]\n"); + return Some(None); + }; + let args: Vec> = parts.map(<[u8]>::to_vec).collect(); + Some(super::terminal_launch::launch(program, args, max_image)) +} + +/// This capsule's arguments, NUL-separated, in a buffer the kernel sized. +fn args() -> Option> { + let n = usize::try_from(mk_args(core::ptr::null_mut(), 0)).ok()?; + let mut buf = alloc::vec![0u8; n]; + let got = usize::try_from(mk_args(buf.as_mut_ptr(), n)).ok()?; + buf.truncate(got.min(n)); + Some(buf) +} diff --git a/userland/capsule_linux/src/linux/terminal_launch.rs b/userland/capsule_linux/src/linux/terminal_launch.rs new file mode 100644 index 0000000000..d8211b4520 --- /dev/null +++ b/userland/capsule_linux/src/linux/terminal_launch.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 . + +//! Reading the program the terminal's `linux` command names, and saying +//! what stopped it when it cannot be read: what the store answered, never a +//! guess at why. + +use alloc::format; +use alloc::string::String; +use alloc::vec::Vec; + +use super::launch::Launch; +use crate::linux::file::{key, store_read, store_stat}; +use crate::linux::guest::Links; +use crate::linux::say::say; + +pub(super) fn launch(program: &[u8], args: Vec>, max: u32) -> Option { + /* wait_settled says so itself when the store never settles. */ + if !super::settle::wait_settled() { + return None; + } + let links = Links::try_load(); + let (named, path) = super::terminal_path::find(program, links.as_ref().ok()); + /* Through a link, argv[0] is the link's name, as an execve of it gives. */ + let argv0 = (path != named).then(|| named.clone()); + let why = match store_read(&key(&path), max) { + Ok(bytes) => return Some(Launch { argv0, ..Launch::store(path, bytes, args) }), + Err(why) => why, + }; + /* A bare name was looked for along the whole PATH, not in one place. */ + let shown = if program.contains(&b'/') { named.clone() } else { program.to_vec() }; + let line = match store_stat(&key(&path)) { + Ok((_, true)) => format!("linux: {} is a directory\n", text(&path)), + Ok((size, false)) => { + format!("linux: {} is there ({size} bytes) but unread: {why}\n", text(&path)) + } + Err(_) => match nonos_app_skeleton::clients::vfs::store_status() { + Ok(0) => format!("linux: cannot find {} in the Linux tree\n", text(&shown)), + Ok(code) => { + format!("linux: cannot find {}: the store reports error {code}\n", text(&shown)) + } + Err(e) => { + format!("linux: cannot find {}: the store does not answer ({e})\n", text(&shown)) + } + }, + }; + say(line.as_bytes()); + if let (Err(why), Ok(_)) = (links, store_stat(&key(Links::TABLE))) { + say(format!("linux: link table {} unread: {why}\n", text(Links::TABLE)).as_bytes()); + } + None +} + +fn text(path: &[u8]) -> String { + String::from_utf8_lossy(path).into_owned() +} diff --git a/userland/capsule_linux/src/linux/terminal_path.rs b/userland/capsule_linux/src/linux/terminal_path.rs new file mode 100644 index 0000000000..3213db176a --- /dev/null +++ b/userland/capsule_linux/src/linux/terminal_path.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 . + +//! Where the terminal's `linux` command finds a program. A name with a slash +//! is a path in the Linux tree; a bare name is looked for along the PATH a +//! guest starts with, the first directory that holds it winning, as a +//! shell's search goes. + +use alloc::vec::Vec; + +use crate::linux::file::{key, store_stat, visible}; +use crate::linux::guest::Links; + +/// The name as found, and the file it leads to through the image's links. +/// A bare name no PATH directory holds is given as /bin's, which the +/// caller then fails to read and says so. +pub(super) fn find(program: &[u8], links: Option<&Links>) -> (Vec, Vec) { + let resolve = |named: Vec| { + let path = links.map_or_else(|| named.clone(), |l| l.follow(named.clone(), true)); + (named, path) + }; + if program.contains(&b'/') { + return resolve(visible(b"/", program)); + } + for dir in path_dirs() { + let (named, path) = resolve(visible(&dir, program)); + if matches!(store_stat(&key(&path)), Ok((_, false))) { + return (named, path); + } + } + resolve(visible(b"/bin", program)) +} + +/// The directories of the PATH in the environment a guest starts with. +fn path_dirs() -> Vec> { + let env = crate::linux::env::default(); + let path = env.iter().find_map(|v| v.strip_prefix(b"PATH=")).unwrap_or(b"/bin"); + path.split(|b| *b == b':').filter(|d| !d.is_empty()).map(<[u8]>::to_vec).collect() +} 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..9c2079fd02 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/mod.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_driver_rtl8139/src/protocol/endpoint.rs b/userland/capsule_linux_proofs/src/server/mod.rs similarity index 93% rename from userland/capsule_driver_rtl8139/src/protocol/endpoint.rs rename to userland/capsule_linux_proofs/src/server/mod.rs index 2aedb75622..24b6b971d0 100644 --- a/userland/capsule_driver_rtl8139/src/protocol/endpoint.rs +++ b/userland/capsule_linux_proofs/src/server/mod.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 const KERNEL_REPLY_ENDPOINT: u64 = 0x1_0000_000D; +pub mod handlers; diff --git a/userland/capsule_linux_proofs/src/tests.rs b/userland/capsule_linux_proofs/src/tests.rs index ae67ca5b14..40ba1565b3 100644 --- a/userland/capsule_linux_proofs/src/tests.rs +++ b/userland/capsule_linux_proofs/src/tests.rs @@ -14,14 +14,39 @@ // 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_alt_tests; +mod sigframe_entry_tests; +mod sigframe_layout_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..ad375dbcf8 --- /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 = 0x0DEB_5EEDu64; + 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_alt_tests.rs b/userland/capsule_linux_proofs/src/tests/sigframe_alt_tests.rs new file mode 100644 index 0000000000..4bfe9f2393 --- /dev/null +++ b/userland/capsule_linux_proofs/src/tests/sigframe_alt_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 handler installed with SA_ONSTACK is entered on the thread's alternate +//! stack, and `uc_stack` in its frame says which stack that is. + +use super::sigframe_tests::{regs, RSP}; +use crate::sigframe::{build, returned}; + +/// A 32 KiB alternate stack well away from the thread's own stack. +const ALT: (u64, u64) = (0x7000_0000_0000, 0x8000); + +/// ss_sp, ss_flags and ss_size, from the frame's ucontext. +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)) +} + +#[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(); + /* Already running a handler there, so the flags read SS_ONSTACK. */ + saved[RSP] = ALT.0 + 0x6000; + 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)); +} + +#[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/sigframe_entry_tests.rs b/userland/capsule_linux_proofs/src/tests/sigframe_entry_tests.rs new file mode 100644 index 0000000000..89786fbba1 --- /dev/null +++ b/userland/capsule_linux_proofs/src/tests/sigframe_entry_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 . + +//! How a handler is entered: with the direction flag clear, as a function +//! is, and never with a frame written below the alternate stack it is on. + +use super::sigframe_tests::{regs, RSP}; +use crate::sigframe::{build, returned}; + +/// rflags in the register word order, and its direction flag. +const RFLAGS: usize = 17; +const DF: u64 = 0x400; + +/// A 32 KiB alternate stack well away from the thread's own stack. +const ALT: (u64, u64) = (0x7000_0000_0000, 0x8000); + +#[test] +fn the_handler_starts_with_the_direction_flag_clear() { + let mut saved = regs(); + saved[RFLAGS] = 0x246 | DF; + let (_, buf, enter) = build(&saved, 1, 2, 3, 0, None, false).expect("frame"); + assert_eq!(enter[RFLAGS] & DF, 0, "the handler runs with DF clear"); + assert_eq!(enter[RFLAGS] & !DF, saved[RFLAGS] & !DF, "and every other flag as it was"); + /* The interrupted code gets its own DF back through rt_sigreturn. */ + assert_eq!(returned(&buf[8..]).unwrap()[RFLAGS], saved[RFLAGS]); +} + +#[test] +fn a_frame_nested_on_the_alternate_stack_without_sa_onstack_is_bounded_too() { + let mut saved = regs(); + /* In a handler on the alternate stack, too near its base for another. */ + saved[RSP] = ALT.0 + 0x200; + assert!(build(&saved, 1, 2, 3, 0, Some(ALT), false).is_none()); + /* With room left it nests there, below the red zone. */ + saved[RSP] = ALT.0 + 0x6000; + let (frame, _, _) = build(&saved, 1, 2, 3, 0, Some(ALT), false).expect("frame"); + assert!(frame < saved[RSP] - 128 && frame > ALT.0); +} diff --git a/userland/capsule_linux_proofs/src/tests/sigframe_layout_tests.rs b/userland/capsule_linux_proofs/src/tests/sigframe_layout_tests.rs new file mode 100644 index 0000000000..2efc082ecb --- /dev/null +++ b/userland/capsule_linux_proofs/src/tests/sigframe_layout_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 . + +//! Where a Linux program reads each part of the frame its handler gets. + +use super::sigframe_tests::{regs, RSP}; +use crate::sigframe::{build, SIGCONTEXT_OFF}; + +#[test] +fn the_sigcontext_sits_where_the_ucontext_says() { + /* uc_mcontext is at SIGCONTEXT_OFF within the ucontext, 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()); + /* r8, rsp, rip, then the mask. */ + assert_eq!(word(40), saved[0]); + assert_eq!(word(160), saved[RSP]); + assert_eq!(word(168), saved[16]); + assert_eq!(word(296), 0x0000_8000_0000_0001); +} 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..4fd5d649e4 --- /dev/null +++ b/userland/capsule_linux_proofs/src/tests/sigframe_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 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, WORDS}; + +pub(super) const RSP: usize = 15; +const RAX: usize = 13; + +/// A distinct value per register, and a plausible, page-aligned stack pointer. +pub(super) fn regs() -> [u64; WORDS] { + let mut r = [0u64; WORDS]; + for (i, w) in r.iter_mut().enumerate() { + *w = 0x1111_0000 + i as u64; + } + r[RSP] = 0x7fff_ffe0_0000; + 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); + /* rip is the handler, rdi the signal, rdx the ucontext. */ + assert_eq!(enter[16], 0xdead_beef); + assert_eq!(enter[8], 11); + assert_eq!(enter[12], frame + 8); + /* 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(); + /* 0 as the thread trapped, then the value the interrupted call returns. */ + saved[RAX] = 0; + saved[RAX] = 42; + 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(); + /* Below the red zone plus a frame. */ + low[RSP] = 64; + assert!(build(&low, 1, 2, 3, 0, None, false).is_none()); +} 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/SECURITY-TOOLSET.md b/userland/capsule_market/SECURITY-TOOLSET.md new file mode 100644 index 0000000000..0d724a8b36 --- /dev/null +++ b/userland/capsule_market/SECURITY-TOOLSET.md @@ -0,0 +1,44 @@ +# A security toolset for NONOS + +`security-packages.txt` is a curated list of security and networking tools +from Alpine v3.20 (main and community). It plugs into the marketplace the +same way the baseline list does: point `MARKET_LINUX_LIST` at it, and the +catalogue generator fetches each package, hashes it, and lists it. + +## How a tool reaches the machine + +1. `nonos-market-catalogue` reads the list, fetches each package from the + distribution over the mirror relay, and records its BLAKE3 as a measured + fact. A hash nobody computed is not an assertion, so a package that could + not be fetched is left out. +2. The marketplace index is signed by the operator key and served by the + market capsule. +3. In the terminal, `install ` looks the package up, fetches it again + over the relay, holds the bytes to the distribution's own signature + (Alpine's, Debian's or Kali's key), and unpacks it into a private tree. +4. The program then runs as a foreign guest at Authority 0: no hardware, no + DMA, no reach into another process. It is confined whether or not it is + trusted. + +## What each tool needs before it runs + +- A listing is held back until the package's measurement is enrolled (its + `zk_trailer_hash` is empty until then), so the operator vouches for the + exact bytes the machine will run. +- A tool that reaches a live network target (marked `(net)` in the list: + nmap, masscan, the netcat family, hydra, nikto and the rest) needs the + network capability granted to its guest. Until that grant exists it runs, + but reaches nothing outside the sandbox. Reading a capture file, auditing + a hash file, or examining a binary needs no network and works today. +- The store lists at most 128 entries and 48 MiB, and a dynamically linked + tool brings its shared-library closure, so an image ships a chosen subset + rather than the whole list. + +## Proven + +The catalogue generator fetched and hashed radare2 5.9.0, nmap 7.95, +openssl 3.3.7 and john 1.9.0 from Alpine, and listed each with the BLAKE3 +of its real bytes (for example radare2, 4179014 bytes, +366f6463e579517c931cb6863850b07812bd04ecdd0adc34f04c4a2b1f9d9eb7). Each +listing carries `required_capabilities: [ForeignExec]` and an empty +`zk_trailer_hash`, held back until enrolled. 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/security-packages.txt b/userland/capsule_market/security-packages.txt new file mode 100644 index 0000000000..48a3515053 --- /dev/null +++ b/userland/capsule_market/security-packages.txt @@ -0,0 +1,48 @@ +# A curated security toolset for NONOS, from Alpine v3.20 main and community. +# Point MARKET_LINUX_LIST at this file to list these in the marketplace, so +# `install ` fetches each over the mirror relay, holds it to Alpine's +# own signature, and lists it once its measurement is enrolled. Every tool +# runs as a foreign guest at Authority 0: no hardware, no DMA, no reach into +# another process. A tool that needs a live network target needs the network +# capability granted to it as well; those are marked (net) below and do not +# reach anything from inside the sandbox until that grant exists. +# +# The store lists at most 128 entries and 48 MiB, and a dynamically linked +# tool brings its shared-library closure, so a build picks a subset of this +# list rather than all of it at once. + +# -- recon and network (net: needs the network capability to reach a target) -- +nmap # port and service discovery (net) +nmap-scripts # the NSE script library nmap loads +tcpdump # packet capture and, with -r, reading a .pcap (net for live) +tshark # the Wireshark dissector, on the command line (net for live) +masscan # asynchronous port scanner (net) +nftables # the in-kernel firewall's user tool +socat # bidirectional stream relay (net) +netcat-openbsd # the classic connect-and-listen tool (net) +bind-tools # dig, host, nslookup (net) +curl # HTTP and many other protocols (net) +wget # HTTP and FTP fetch (net) + +# -- binary analysis and reverse engineering (all run confined on a file) -- +radare2 # disassembler, debugger and binary explorer +hexyl # a coloured hex viewer +file # identify a file from its contents +ripgrep # fast recursive search +fd # fast file find +bat # a pager with syntax highlighting +jq # JSON query and transform +xxd # hex dump and reverse + +# -- credential and cipher auditing (run confined on a supplied file) -- +john # John the Ripper, offline hash auditing +hydra # network login auditing (net) +aircrack-ng # WPA handshake auditing from a capture file + +# -- web (net: needs the network capability) -- +nikto # web server checks (net) + +# -- cryptography (run confined on files) -- +openssl # the OpenSSL command line +gnupg # GnuPG signing, encryption and key handling +age # a small modern file-encryption tool 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_core/src/setup.rs b/userland/capsule_net_core/src/setup.rs index c6a85b42e2..e74ed69c04 100644 --- a/userland/capsule_net_core/src/setup.rs +++ b/userland/capsule_net_core/src/setup.rs @@ -69,7 +69,7 @@ pub fn bound_port() -> u32 { // position in the WiFi-then-wired order, so a change logs once, not per tick. static PROBE_SEEN: [AtomicU32; 8] = [const { AtomicU32::new(0) }; 8]; -fn discover_nic() -> Option { +fn discover_nic() -> Option<(u32, &'static str)> { let count = WIFI_NICS.len() + WIRED_NICS.len(); let start = PROBE_CURSOR.load(Ordering::Relaxed); let mut probes = 0usize; @@ -85,7 +85,7 @@ fn discover_nic() -> Option { match device::link_up(port) { Some(true) => { PROBE_CURSOR.store(idx, Ordering::Relaxed); - return Some(port); + return Some((port, name)); } verdict => { let code = if verdict.is_none() { 2 } else { 1 }; @@ -136,7 +136,7 @@ fn probe_log(name: &str, verdict: Option) { /// or the bound link drops. A no-op once bound to the best link, so it is cheap to /// call on a timer from the server loop. pub fn reevaluate() { - let Some(best) = discover_nic() else { + let Some((best, name)) = discover_nic() else { return; }; if best == BOUND_PORT.load(Ordering::Acquire) { @@ -155,9 +155,22 @@ pub fn reevaluate() { }; state::store(net_state); BOUND_PORT.store(best, Ordering::Release); - bind_log(b"[NET-CORE] bind: interface up"); + bind_up_log(name); } fn bind_log(msg: &[u8]) { let _ = nonos_libc::mk_debug(msg.as_ptr(), msg.len()); } + +// Which NIC the stack bound. With a wired port and a WiFi link both present +// the choice is the first thing to know, and "interface up" alone does not +// say it. +fn bind_up_log(name: &str) { + let mut line = [0u8; 96]; + let tag: &[u8] = b"[NET-CORE] bind: interface up on "; + let n = tag.len(); + line[..n].copy_from_slice(tag); + let m = name.len().min(line.len() - n); + line[n..n + m].copy_from_slice(&name.as_bytes()[..m]); + bind_log(&line[..n + m]); +} 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/format.rs b/userland/capsule_process_manager/src/pm/format.rs index b01dec597e..e003d4c624 100644 --- a/userland/capsule_process_manager/src/pm/format.rs +++ b/userland/capsule_process_manager/src/pm/format.rs @@ -160,37 +160,6 @@ pub fn mem_human(kb: u64, out: &mut [u8]) -> usize { } } -// Capability bits and their short names, in enum order, so the selected -// process can show exactly which authorities it was granted. -pub const CAP_TABLE: &[(u64, &[u8])] = &[ - (1 << 0, b"exec"), - (1 << 1, b"io"), - (1 << 2, b"net"), - (1 << 3, b"ipc"), - (1 << 4, b"mem"), - (1 << 5, b"crypto"), - (1 << 6, b"fs"), - (1 << 7, b"hw"), - (1 << 8, b"debug"), - (1 << 9, b"admin"), - (1 << 10, b"regsvc"), - (1 << 11, b"gquery"), - (1 << 12, b"gcreate"), - (1 << 13, b"gmap"), - (1 << 14, b"gpresent"), - (1 << 15, b"devenum"), - (1 << 16, b"driver"), - (1 << 17, b"mmio"), - (1 << 18, b"irq"), - (1 << 19, b"dma"), - (1 << 20, b"pio"), - (1 << 21, b"input"), - (1 << 22, b"time"), - (1 << 23, b"spawnbroker"), - (1 << 24, b"spawnwindow"), - (1 << 25, b"procctl"), -]; - // "Ndrop 0xHEX": how many capabilities are granted, and the raw bit set, so the // authority of every process is visible at a glance. pub fn caps_summary(caps: u64, out: &mut [u8]) -> usize { diff --git a/userland/capsule_process_manager/src/pm/format_caps.rs b/userland/capsule_process_manager/src/pm/format_caps.rs new file mode 100644 index 0000000000..e33bdc1634 --- /dev/null +++ b/userland/capsule_process_manager/src/pm/format_caps.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 . + +//! Capability bits and their short names, in the kernel's enum order. + +/* Every bit the kernel defines, through LocalSign. A grant with no entry here + * was counted in a process's authority but drawn as no chip at all. */ +pub const CAP_TABLE: &[(u64, &[u8])] = &[ + (1 << 0, b"exec"), + (1 << 1, b"io"), + (1 << 2, b"net"), + (1 << 3, b"ipc"), + (1 << 4, b"mem"), + (1 << 5, b"crypto"), + (1 << 6, b"fs"), + (1 << 7, b"hw"), + (1 << 8, b"debug"), + (1 << 9, b"admin"), + (1 << 10, b"regsvc"), + (1 << 11, b"gquery"), + (1 << 12, b"gcreate"), + (1 << 13, b"gmap"), + (1 << 14, b"gpresent"), + (1 << 15, b"devenum"), + (1 << 16, b"driver"), + (1 << 17, b"mmio"), + (1 << 18, b"irq"), + (1 << 19, b"dma"), + (1 << 20, b"pio"), + (1 << 21, b"input"), + (1 << 22, b"time"), + (1 << 23, b"spawnbroker"), + (1 << 24, b"spawnwindow"), + (1 << 25, b"procctl"), + (1 << 26, b"storewrite"), + (1 << 27, b"enrolroot"), + (1 << 28, b"keyring"), + (1 << 29, b"entropy"), + (1 << 30, b"install"), + (1 << 31, b"attest"), + (1 << 32, b"foreign"), + (1 << 33, b"localsign"), +]; diff --git a/userland/capsule_process_manager/src/pm/format_labels.rs b/userland/capsule_process_manager/src/pm/format_labels.rs index 5febc1e017..b49a11590c 100644 --- a/userland/capsule_process_manager/src/pm/format_labels.rs +++ b/userland/capsule_process_manager/src/pm/format_labels.rs @@ -15,7 +15,7 @@ // along with this program. If not, see . //! Small formatters the inspector and the memory screens share: names for -//! the kernel's codes, shares, a user/kernel split, a region count. +//! the kernel's codes, a user/kernel split, a region count. use super::format::{pct_1dp, u32_decimal}; @@ -31,15 +31,6 @@ pub fn priority_label(code: u8) -> &'static [u8] { } } -/// `kb` as a whole percentage of `total_kb`, clamped; zero of nothing is zero. -pub fn share_pct(kb: u64, total_kb: u64) -> u8 { - if total_kb == 0 { - 0 - } else { - (kb.saturating_mul(100) / total_kb).min(100) as u8 - } -} - /// "1.0% / 3.0%": the share that was the process's own code, then the /// kernel's work for it. pub fn split(user: u8, kernel: u8, out: &mut [u8]) -> usize { diff --git a/userland/capsule_process_manager/src/pm/format_mem.rs b/userland/capsule_process_manager/src/pm/format_mem.rs new file mode 100644 index 0000000000..3344d32144 --- /dev/null +++ b/userland/capsule_process_manager/src/pm/format_mem.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 . + +//! Memory figures the inspector and the Memory screen share. + +use super::format::{mem_human, u32_decimal}; +use super::state::Row; + +/* "0.7%": `kb` as a share of `total_kb` to the tenth, clamped; zero of + * nothing is zero. A whole percent printed 0.0% for every process under a + * hundredth of RAM. */ +pub fn share_1dp(kb: u64, total_kb: u64, out: &mut [u8]) -> usize { + let tenths = if total_kb == 0 { 0 } else { (kb.saturating_mul(1000) / total_kb).min(1000) }; + let mut n = u32_decimal((tenths / 10) as u32, out); + if n + 3 <= out.len() { + out[n] = b'.'; + out[n + 1] = b'0' + (tenths % 10) as u8; + out[n + 2] = b'%'; + n += 3; + } + n +} + +/* A Linux guest's mappings are kept by its supervisor and the kernel lists + * none, so the cell names who holds them instead of printing 0 KB. */ +pub fn mapped<'a>(row: &Row, out: &'a mut [u8]) -> &'a [u8] { + if row.name().starts_with(b"foreign:") { + return b"supervisor"; + } + let n = mem_human(row.mapped_kb, out); + &out[..n] +} diff --git a/userland/capsule_process_manager/src/pm/mod.rs b/userland/capsule_process_manager/src/pm/mod.rs index bc11c3a434..c889539019 100644 --- a/userland/capsule_process_manager/src/pm/mod.rs +++ b/userland/capsule_process_manager/src/pm/mod.rs @@ -18,7 +18,9 @@ mod app; mod critical; mod event; mod format; +mod format_caps; mod format_labels; +mod format_mem; mod format_sys; mod manifest; mod security; diff --git a/userland/capsule_process_manager/src/pm/security/sensitive.rs b/userland/capsule_process_manager/src/pm/security/sensitive.rs index d292d42b14..fe863548e4 100644 --- a/userland/capsule_process_manager/src/pm/security/sensitive.rs +++ b/userland/capsule_process_manager/src/pm/security/sensitive.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 . -// Capability bits (mirrors the kernel enum order used in format::CAP_TABLE). +// Capability bits (mirrors the kernel enum order used in format_caps::CAP_TABLE). // These are the authorities whose abuse would cross an isolation boundary, so // the monitor counts who holds them. Holding one is not wrong on its own: a // driver capsule needs raw hardware. The point is to make the attack surface diff --git a/userland/capsule_process_manager/src/pm/state/refresh_finish.rs b/userland/capsule_process_manager/src/pm/state/refresh_finish.rs index 01d696410a..4e87fb6ae3 100644 --- a/userland/capsule_process_manager/src/pm/state/refresh_finish.rs +++ b/userland/capsule_process_manager/src/pm/state/refresh_finish.rs @@ -28,10 +28,10 @@ impl State { // action are usable at once. if let Some(idx) = self.selected_index() { self.ensure_visible(idx); - } else if let Some(first) = self.rows.first() { - self.selected_pid = first.pid; } else { - self.selected_pid = 0; + /* Only a row the table shows. When the filter matches none, the + * pane says so, and End Process has nothing hidden to aim at. */ + self.selected_pid = self.filtered().first().map_or(0, |r| r.pid); } let max = self.filtered().len().saturating_sub(self.visible); if self.scroll > max { diff --git a/userland/capsule_process_manager/src/pm/ui/hit.rs b/userland/capsule_process_manager/src/pm/ui/hit.rs index 59da3dc5a9..329fc37200 100644 --- a/userland/capsule_process_manager/src/pm/ui/hit.rs +++ b/userland/capsule_process_manager/src/pm/ui/hit.rs @@ -16,7 +16,7 @@ use crate::pm::state::{Filter, Screen, State}; -use super::table_geom::{Col, COLS_FULL, COLS_OVERVIEW}; +use super::table_geom::{Col, COLS_FULL}; use super::{chips, chrome, insp_geom, nav_geom, search}; #[path = "hit_pane.rs"] @@ -67,7 +67,7 @@ pub fn at(state: &State, w: u32, h: u32, x: i32, y: i32) -> Option { return None; } match state.screen { - Screen::Overview => hit_pane::table(state, &r, &COLS_OVERVIEW, x, y), + Screen::Overview => hit_pane::overview(state, &r, x, y), Screen::Processes => hit_pane::table(state, &r, &COLS_FULL, x, y), Screen::Authority => hit_pane::matrix(state, &r, x, y), Screen::Security => hit_pane::finding(state, &r, y), diff --git a/userland/capsule_process_manager/src/pm/ui/hit_pane.rs b/userland/capsule_process_manager/src/pm/ui/hit_pane.rs index f1c15dfe65..86ffc04938 100644 --- a/userland/capsule_process_manager/src/pm/ui/hit_pane.rs +++ b/userland/capsule_process_manager/src/pm/ui/hit_pane.rs @@ -18,21 +18,29 @@ use crate::pm::state::{Screen, State}; use super::super::chrome::Rect; use super::super::matrix_geom; -use super::super::screens::sec_geom; -use super::super::table_geom::{self, Col}; +use super::super::screens::{overview, sec_geom}; +use super::super::table_geom::{self, Col, COLS_OVERVIEW}; use super::Target; // How many rows of the active list fit the pane. Navigation clamps against this, // so it has to come from whichever geometry module actually drew the rows. -pub fn rows_visible(screen: Screen, pane_h: u32) -> usize { +pub fn rows_visible(screen: Screen, pane: &Rect) -> usize { match screen { - Screen::Authority => matrix_geom::visible_rows(pane_h), - _ => table_geom::visible_rows(pane_h), + Screen::Authority => matrix_geom::visible_rows(pane.h), + Screen::Overview => table_geom::visible_rows(overview::table_rect(pane).h), + _ => table_geom::visible_rows(pane.h), } } // The header band sorts, the body selects. Both tests read the same table-local // origin, which is why a click just under the header cannot resolve as a sort. +/* Overview's table starts below its cards: the click comes off that offset + * before the table sees it, as the painter added it before drawing. */ +pub fn overview(state: &State, r: &Rect, x: i32, y: i32) -> Option { + let t = overview::table_rect(r); + table(state, &t, &COLS_OVERVIEW, x, y - (t.y - r.y) as i32) +} + pub fn table(state: &State, r: &Rect, cols: &[Col], x: i32, y: i32) -> Option { if table_geom::in_head(y) { return table_geom::sort_at_x(cols, r.w, x).map(Target::Sort); 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/insp_chip.rs b/userland/capsule_process_manager/src/pm/ui/insp_chip.rs new file mode 100644 index 0000000000..79d50853a9 --- /dev/null +++ b/userland/capsule_process_manager/src/pm/ui/insp_chip.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 . + +//! One grant chip, and the "+N more" chip that counts those without room. + +use nonos_app_skeleton::PaintBuffer; + +use crate::pm::format::u32_decimal; +use crate::pm::theme::{PILL_BG, PILL_BORDER}; + +use super::metrics::{BODY_PX, CHIP_H, CHIP_PAD_X, CHIP_RADIUS}; +use super::text; + +pub(super) fn chip(fb: &mut PaintBuffer, x: u32, y: u32, label: &[u8], tint: u32) { + let cw = chip_w(fb, label); + fb.fill_round(x, y, cw, CHIP_H, CHIP_RADIUS, PILL_BG); + fb.stroke_round(x, y, cw, CHIP_H, CHIP_RADIUS, 1, PILL_BORDER); + let top = text::centred_top(y, CHIP_H, BODY_PX); + text::left(fb, x + CHIP_PAD_X, top, label, tint, BODY_PX); +} + +pub(super) fn chip_w(fb: &PaintBuffer, label: &[u8]) -> u32 { + text::width(fb, label, BODY_PX) + CHIP_PAD_X * 2 +} + +pub(super) fn more_w(fb: &PaintBuffer, count: u32) -> u32 { + let mut buf = [0u8; 16]; + let n = more(count, &mut buf); + chip_w(fb, &buf[..n]) +} + +/// "+3 more". +pub(super) fn more(count: u32, out: &mut [u8; 16]) -> usize { + out[0] = b'+'; + let n = 1 + u32_decimal(count, &mut out[1..]); + out[n..n + 5].copy_from_slice(b" more"); + n + 5 +} diff --git a/userland/capsule_process_manager/src/pm/ui/insp_chips.rs b/userland/capsule_process_manager/src/pm/ui/insp_chips.rs index faadee4d4d..4f26f5b587 100644 --- a/userland/capsule_process_manager/src/pm/ui/insp_chips.rs +++ b/userland/capsule_process_manager/src/pm/ui/insp_chips.rs @@ -16,33 +16,36 @@ use nonos_app_skeleton::PaintBuffer; -use crate::pm::format::CAP_TABLE; -use crate::pm::theme::{PILL_BG, PILL_BORDER}; +use crate::pm::format_caps::CAP_TABLE; +use crate::pm::theme::MUTED; -use super::metrics::{BODY_PX, CHIP_GAP, CHIP_H, CHIP_PAD_X, CHIP_RADIUS}; -use super::text; +use super::insp_chip::{chip, chip_w, more, more_w}; +use super::metrics::{CHIP_GAP, CHIP_H}; use super::tint::cap_tint; // The decoded grant list, wrapped by measured width rather than by a glyph -// count, because the body face is proportional. This is the surface with the -// room the table's Authority column never had. -pub fn paint(fb: &mut PaintBuffer, x: u32, y: u32, w: u32, caps: u64) -> u32 { - let mut cx = x; - let mut cy = y; - for &(bit, label) in CAP_TABLE { - if caps & bit == 0 { - continue; +// count, because the body face is proportional. It stops above `bottom`, where +// the actions begin: grants that would not fit are counted in a last "+N more" +// chip on the final row that does, never drawn under the buttons. +pub fn paint(fb: &mut PaintBuffer, x: u32, y: u32, w: u32, bottom: u32, caps: u64) { + let total = CAP_TABLE.iter().filter(|(bit, _)| caps & bit != 0).count() as u32; + let (mut cx, mut cy, mut shown) = (x, y, 0u32); + for &(bit, label) in CAP_TABLE.iter().filter(|(bit, _)| caps & bit != 0) { + let cw = chip_w(fb, label); + let wrap = cx > x && cx + cw > x + w; + let (nx, ny) = if wrap { (x, cy + CHIP_H + CHIP_GAP) } else { (cx, cy) }; + let after = total - shown - 1; + let last_row = ny + CHIP_H * 2 + CHIP_GAP > bottom; + let squeezed = after > 0 && last_row && nx + cw + CHIP_GAP + more_w(fb, after) > x + w; + if ny + CHIP_H > bottom || squeezed { + break; } - let cw = text::width(fb, label, BODY_PX) + CHIP_PAD_X * 2; - if cx > x && cx + cw > x + w { - cx = x; - cy += CHIP_H + CHIP_GAP; - } - fb.fill_round(cx, cy, cw, CHIP_H, CHIP_RADIUS, PILL_BG); - fb.stroke_round(cx, cy, cw, CHIP_H, CHIP_RADIUS, 1, PILL_BORDER); - let top = text::centred_top(cy, CHIP_H, BODY_PX); - text::left(fb, cx + CHIP_PAD_X, top, label, cap_tint(bit), BODY_PX); - cx += cw + CHIP_GAP; + chip(fb, nx, ny, label, cap_tint(bit)); + (cx, cy, shown) = (nx + cw + CHIP_GAP, ny, shown + 1); + } + if shown < total && cy + CHIP_H <= bottom { + let mut buf = [0u8; 16]; + let n = more(total - shown, &mut buf); + chip(fb, cx, cy, &buf[..n], MUTED); } - cy + CHIP_H } diff --git a/userland/capsule_process_manager/src/pm/ui/insp_fields.rs b/userland/capsule_process_manager/src/pm/ui/insp_fields.rs index 85eb2c99ea..83add060d6 100644 --- a/userland/capsule_process_manager/src/pm/ui/insp_fields.rs +++ b/userland/capsule_process_manager/src/pm/ui/insp_fields.rs @@ -20,7 +20,8 @@ use nonos_app_skeleton::PaintBuffer; use crate::pm::format::{mem_human, pct_1dp, state_label, u32_decimal, uptime_human}; -use crate::pm::format_labels::{priority_label, share_pct}; +use crate::pm::format_labels::priority_label; +use crate::pm::format_mem::share_1dp; use crate::pm::state::Row; use crate::pm::theme::{FOREGROUND, MUTED, TITLE}; @@ -55,7 +56,7 @@ pub fn block(fb: &mut PaintBuffer, x: u32, y: u32, row: &Row, ram_kb: u64) -> u3 y = field(fb, x, y, b"CPU", &buf[..n], FOREGROUND); let n = mem_human(row.mem_kb, &mut buf); y = field(fb, x, y, b"Resident", &buf[..n], FOREGROUND); - let n = pct_1dp(share_pct(row.mem_kb, ram_kb), &mut buf); + let n = share_1dp(row.mem_kb, ram_kb, &mut buf); y = field(fb, x, y, b"Share of RAM", &buf[..n], FOREGROUND); super::insp_fields_more::block(fb, x, y, row) } diff --git a/userland/capsule_process_manager/src/pm/ui/insp_fields_more.rs b/userland/capsule_process_manager/src/pm/ui/insp_fields_more.rs index e0356eba58..de871a3795 100644 --- a/userland/capsule_process_manager/src/pm/ui/insp_fields_more.rs +++ b/userland/capsule_process_manager/src/pm/ui/insp_fields_more.rs @@ -24,7 +24,7 @@ use crate::pm::format::mem_human; use crate::pm::format_labels::{regions, split}; use crate::pm::format_sys::{count_human, rate_human}; use crate::pm::state::Row; -use crate::pm::theme::FOREGROUND; +use crate::pm::theme::{FOREGROUND, MUTED}; use super::insp_fields::field; @@ -52,6 +52,10 @@ pub fn block(fb: &mut PaintBuffer, x: u32, y: u32, row: &Row) -> u32 { y = field(fb, x, y, b"Switched in", &buf[..n], FOREGROUND); let n = split(row.user_pct, row.cpu_pct.saturating_sub(row.user_pct), &mut buf); y = field(fb, x, y, b"User / kernel", &buf[..n], FOREGROUND); + /* A guest's mappings are kept by its supervisor; the kernel lists none. */ + if row.name().starts_with(b"foreign:") { + return field(fb, x, y, b"Mmapped", b"kept by its supervisor", MUTED); + } let n = mem_human(row.mapped_kb, &mut buf); let n = regions(row.vma_count, &mut buf, n); field(fb, x, y, b"Mmapped", &buf[..n], FOREGROUND) diff --git a/userland/capsule_process_manager/src/pm/ui/inspector.rs b/userland/capsule_process_manager/src/pm/ui/inspector.rs index fe63faddd5..8263fab910 100644 --- a/userland/capsule_process_manager/src/pm/ui/inspector.rs +++ b/userland/capsule_process_manager/src/pm/ui/inspector.rs @@ -43,8 +43,9 @@ pub fn paint(state: &State, fb: &mut PaintBuffer) { let mut buf = [0u8; 12]; 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); + let (_, actions, _, _) = super::insp_geom::btn(fb.width, fb.height, 0); + insp_chips::paint(fb, left, y, w, actions.saturating_sub(INSP_SECTION_GAP), row.caps); + 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_process_manager/src/pm/ui/mod.rs b/userland/capsule_process_manager/src/pm/ui/mod.rs index 489f6e74c0..138343f29e 100644 --- a/userland/capsule_process_manager/src/pm/ui/mod.rs +++ b/userland/capsule_process_manager/src/pm/ui/mod.rs @@ -23,7 +23,8 @@ pub mod help; pub mod hit; pub mod icon_table; pub mod insp_actions; -pub mod insp_chips; +pub mod insp_chip; +mod insp_chips; pub mod insp_fields; pub mod insp_fields_more; pub mod insp_geom; diff --git a/userland/capsule_process_manager/src/pm/ui/paint.rs b/userland/capsule_process_manager/src/pm/ui/paint.rs index 43a1b3c2f8..645089cda6 100644 --- a/userland/capsule_process_manager/src/pm/ui/paint.rs +++ b/userland/capsule_process_manager/src/pm/ui/paint.rs @@ -39,7 +39,7 @@ pub fn paint(state: &mut State, fb: &mut PaintBuffer) { chrome::page_head(fb, state, &buf[..n]); state.fb_w = w; state.fb_h = h; - state.visible = hit::rows_visible(state.screen, rect.h); + state.visible = hit::rows_visible(state.screen, &rect); state.alert_visible = screens::sec_geom::visible(rect.h); screen(state, fb, &rect); if state.screen.has_inspector() { diff --git a/userland/capsule_process_manager/src/pm/ui/screens/auth_legend.rs b/userland/capsule_process_manager/src/pm/ui/screens/auth_legend.rs index 60135597dc..b3cd4574fa 100644 --- a/userland/capsule_process_manager/src/pm/ui/screens/auth_legend.rs +++ b/userland/capsule_process_manager/src/pm/ui/screens/auth_legend.rs @@ -16,7 +16,7 @@ use nonos_app_skeleton::PaintBuffer; -use crate::pm::format::CAP_TABLE; +use crate::pm::format_caps::CAP_TABLE; use crate::pm::theme::{MUTED, RULE}; use super::super::chrome::Rect; @@ -31,7 +31,7 @@ const TOP_PAD: u32 = 4; const PAIR_GAP: u32 = 6; // An abbreviated header is only honest if the full name is on screen, so the -// strip under the grid pairs each one with the name format::CAP_TABLE gives it, +// strip under the grid pairs each one with the name format_caps::CAP_TABLE gives it, // five to a line. The abbreviation keeps its column colour; the spelt-out name // is muted, because the eye is meant to come here once and then stop. pub fn paint(fb: &mut PaintBuffer, r: &Rect) { diff --git a/userland/capsule_process_manager/src/pm/ui/screens/mem_consumers.rs b/userland/capsule_process_manager/src/pm/ui/screens/mem_consumers.rs index 498110c2f3..c400faee0d 100644 --- a/userland/capsule_process_manager/src/pm/ui/screens/mem_consumers.rs +++ b/userland/capsule_process_manager/src/pm/ui/screens/mem_consumers.rs @@ -16,8 +16,8 @@ use nonos_app_skeleton::PaintBuffer; -use crate::pm::format::{mem_human, pct_1dp}; -use crate::pm::format_labels::share_pct; +use crate::pm::format::mem_human; +use crate::pm::format_mem::{mapped, share_1dp}; use crate::pm::state::{Row, State}; use crate::pm::theme::{CARD_BG, CARD_BORDER, FOREGROUND, LABEL, MUTED, TITLE}; @@ -64,9 +64,8 @@ fn entry(fb: &mut PaintBuffer, x: u32, y: u32, w: u32, row: (&Row, u64), ram_kb: let n = mem_human(row.mem_kb, &mut buf); let right = x + w.saturating_sub(PANEL_PAD); text::mono_right(fb, right, top, &buf[..n], LABEL, NUM_PX); - let n = mem_human(row.mapped_kb, &mut buf); - text::mono_right(fb, right.saturating_sub(VALUE_W), top, &buf[..n], MUTED, NUM_PX); - let n = pct_1dp(share_pct(row.mem_kb, ram_kb), &mut buf); + text::mono_right(fb, right.saturating_sub(VALUE_W), top, mapped(row, &mut buf), MUTED, NUM_PX); + let n = share_1dp(row.mem_kb, ram_kb, &mut buf); text::mono_right(fb, right.saturating_sub(VALUE_W * 2), top, &buf[..n], MUTED, NUM_PX); let bx = x + PANEL_PAD + NAME_W + BAR_GAP; let bw = right.saturating_sub(VALUE_W * 3 + BAR_GAP).saturating_sub(bx); diff --git a/userland/capsule_process_manager/src/pm/ui/screens/overview.rs b/userland/capsule_process_manager/src/pm/ui/screens/overview.rs index c0932d4605..bc89061778 100644 --- a/userland/capsule_process_manager/src/pm/ui/screens/overview.rs +++ b/userland/capsule_process_manager/src/pm/ui/screens/overview.rs @@ -35,7 +35,12 @@ pub fn paint(state: &State, fb: &mut PaintBuffer, r: &Rect) { ovw_cpu_mem::memory(state, fb, r.x + step, r.y, w); ovw_activity::paint(state, fb, r.x + step * 2, r.y, w); ovw_authority::paint(state, fb, r.x + step * 3, r.y, w); + table::paint(state, fb, &table_rect(r), &COLS_OVERVIEW); +} + +/* The table sits under the cards. The hit test and the row budget read this + * same rect, so a click lands on the row drawn under the pointer. */ +pub fn table_rect(r: &Rect) -> Rect { let below = CARD_H + CARD_GAP; - let rect = Rect { x: r.x, y: r.y + below, w: r.w, h: r.h.saturating_sub(below) }; - table::paint(state, fb, &rect, &COLS_OVERVIEW); + Rect { x: r.x, y: r.y + below, w: r.w, h: r.h.saturating_sub(below) } } 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..89747d197e 100644 --- a/userland/capsule_setup_wizard/src/main.rs +++ b/userland/capsule_setup_wizard/src/main.rs @@ -4,13 +4,14 @@ extern crate alloc; mod clients; +mod consent; mod protocol; mod render; mod server; mod setup; mod state; -use nonos_libc::{heap_init, mk_exit}; +use nonos_libc::{heap_init, mk_debug, mk_exit}; #[no_mangle] pub unsafe extern "C" fn _start() -> ! { @@ -19,7 +20,20 @@ pub unsafe extern "C" fn _start() -> ! { } let ctx = match setup::run() { Ok(ctx) => ctx, - Err(_) => mk_exit(2), + Err(why) => { + // Setup ending is what starts the rest of the desktop, so a setup + // that cannot run says why before the desktop comes up without it. + say(b"[SETUP] not started: "); + say(why.as_bytes()); + say(b"; the desktop starts without first-boot setup\n"); + mk_exit(2) + } }; + let mut ctx = ctx; + server::restore_poll::poll(&mut ctx); server::runner::run(ctx) } + +fn say(line: &[u8]) { + let _ = mk_debug(line.as_ptr(), line.len()); +} 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/setup/discover.rs b/userland/capsule_setup_wizard/src/setup/discover.rs index 6590dc7460..74f1c98ca2 100644 --- a/userland/capsule_setup_wizard/src/setup/discover.rs +++ b/userland/capsule_setup_wizard/src/setup/discover.rs @@ -1,4 +1,4 @@ -use nonos_libc::mk_service_lookup; +use nonos_libc::{mk_service_lookup, mk_yield, Deadline}; const COMPOSITOR_SERVICE: &[u8] = b"compositor"; const INPUT_ROUTER_SERVICE: &[u8] = b"input_router"; @@ -19,6 +19,27 @@ fn lookup_optional(name: &[u8]) -> u32 { lookup_port(name).unwrap_or(0) } +/// Setup is spawned beside the compositor and the input router, and they may +/// not have registered yet. This bounds only a desktop that never arrives. +const DESKTOP_WAIT_MS: u64 = 60_000; + +/// The compositor and input router ports, waited for rather than asked once. +/// Asked once, a lookup that raced their registration ended setup before it +/// drew anything, and setup ending is what starts the rest of the desktop, so +/// the machine came up without first-boot setup and said nothing about it. +pub fn wait_for_desktop() -> Result<(u32, u32), &'static str> { + let until = Deadline::after_ms(DESKTOP_WAIT_MS); + loop { + match (lookup_compositor_port(), lookup_router_port()) { + (Ok(compositor), Ok(router)) => return Ok((compositor, router)), + (Err(why), _) | (_, Err(why)) if until.expired() => return Err(why), + _ => { + let _ = mk_yield(); + } + } + } +} + pub fn lookup_compositor_port() -> Result { lookup_port(COMPOSITOR_SERVICE).map_err(|_| "lookup compositor") } diff --git a/userland/capsule_setup_wizard/src/setup/mod.rs b/userland/capsule_setup_wizard/src/setup/mod.rs index aac4f66d14..1d13ee63ab 100644 --- a/userland/capsule_setup_wizard/src/setup/mod.rs +++ b/userland/capsule_setup_wizard/src/setup/mod.rs @@ -16,8 +16,7 @@ const OVERLAY_Z: u32 = 1; const FILL_ARGB: u32 = 0xFF20_3040; pub fn run() -> Result { - let compositor_port = discover::lookup_compositor_port()?; - let router_port = discover::lookup_router_port()?; + let (compositor_port, router_port) = discover::wait_for_desktop()?; let policy_port = discover::lookup_policy_port(); compositor::healthcheck(compositor_port, 1).map_err(|_| "compositor health failed")?; let di = display_info::query_display_info(compositor_port, 3) 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/Cargo.lock b/userland/capsule_terminal/Cargo.lock index 19aa38d6c4..155cef1b5b 100644 --- a/userland/capsule_terminal/Cargo.lock +++ b/userland/capsule_terminal/Cargo.lock @@ -109,6 +109,7 @@ dependencies = [ "nonos_tls", "nonos_toolkit", "nonos_userland_libc", + "nonos_vt", ] [[package]] @@ -134,6 +135,10 @@ dependencies = [ "linked_list_allocator", ] +[[package]] +name = "nonos_vt" +version = "0.1.0" + [[package]] name = "owned_ttf_parser" version = "0.25.1" diff --git a/userland/capsule_terminal/Cargo.toml b/userland/capsule_terminal/Cargo.toml index d17892367b..f76df1402f 100644 --- a/userland/capsule_terminal/Cargo.toml +++ b/userland/capsule_terminal/Cargo.toml @@ -36,6 +36,7 @@ nonos_policy_proto = { path = "../policy_proto" } nonos_socket = { path = "../nonos_socket" } nonos_tls = { path = "../nonos_tls" } nonos_toolkit = { path = "../toolkit", default-features = false } +nonos_vt = { path = "../nonos_vt" } [profile.release] panic = "abort" diff --git a/userland/capsule_terminal/src/command/builtin/echo.rs b/userland/capsule_terminal/src/command/builtin/echo.rs index a02077ea7c..c70ef0cb01 100644 --- a/userland/capsule_terminal/src/command/builtin/echo.rs +++ b/userland/capsule_terminal/src/command/builtin/echo.rs @@ -15,7 +15,7 @@ // along with this program. If not, see . use crate::command::output::Output; -use crate::term::dimensions::COLS; +use crate::term::dimensions::LINE_MAX; use crate::term::util::copy_into; pub fn run(out: &mut Output<'_>, argv: &[&[u8]]) { @@ -23,7 +23,7 @@ pub fn run(out: &mut Output<'_>, argv: &[&[u8]]) { out.writeln(b""); return; } - let mut buf = [0u8; COLS]; + let mut buf = [0u8; LINE_MAX]; let mut n = 0; for (i, arg) in argv[1..].iter().enumerate() { if i > 0 && n < buf.len() { diff --git a/userland/capsule_terminal/src/command/builtin/help.rs b/userland/capsule_terminal/src/command/builtin/help.rs index 5abf25b1a1..062fe627c1 100644 --- a/userland/capsule_terminal/src/command/builtin/help.rs +++ b/userland/capsule_terminal/src/command/builtin/help.rs @@ -14,52 +14,47 @@ // 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; +use super::help_layout::{plain_row, DEEPER, DEEPER_PAD, GROUPS, GROUP_PAD, INTRO, PAGE_PAD}; + 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(INTRO); + out.writeln(b""); + for (name, list) in GROUPS { + row(out, name, list, GROUP_PAD); + } + super::help_tools::tools(out); + out.writeln(b""); + for (name, what) in DEEPER { + row(out, name, what, DEEPER_PAD); + } } -/// 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, PAGE_PAD); } - 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 plain = plain_row(name, text, pad); + 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_layout.rs b/userland/capsule_terminal/src/command/builtin/help_layout.rs new file mode 100644 index 0000000000..26d4c01220 --- /dev/null +++ b/userland/capsule_terminal/src/command/builtin/help_layout.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 . + +/* + * What `help` shows and how its rows are laid out, with nothing else in the + * file, so the proofs render the same rows the terminal prints. + */ + +use alloc::vec::Vec; + +/// The width `help` has to fit: what every terminal opens at. +pub const WIDTH: usize = 80; +/// Where the text column starts, after the label, in each table. +pub const GROUP_PAD: usize = 9; +pub const DEEPER_PAD: usize = 13; +pub const PAGE_PAD: usize = 10; + +pub const INTRO: &[u8] = b"Type a command and press Enter. Tab completes."; + +pub const GROUPS: &[(&[u8], &[u8])] = &[ + (b"files", b"ls tree cat cd pwd mkdir touch rm rmdir mv cp"), + (b"disk", b"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 battery about"), + (b"session", b"id whoami date uptime"), + (b"net", b"ping ifconfig nslookup curl nym"), + (b"apps", b"market install pkg git nox"), +]; + +pub const DEEPER: &[(&[u8], &[u8])] = &[ + (b"help keys", b"editing, history, tabs, selection and search keys"), + (b"help shell", b"pipes, redirects, jobs and aliases"), + (b"help ", b"what one command takes"), +]; + +/// A row as plain text: indent, the label padded to `pad`, then the text. +pub fn plain_row(name: &[u8], text: &[u8], pad: usize) -> Vec { + 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); + plain +} 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..0d22754d51 --- /dev/null +++ b/userland/capsule_terminal/src/command/builtin/help_pages.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 deeper help pages: keys and shell syntax. + +pub const KEYS: [(&[u8], &[u8]); 12] = [ + (b"move", b"Ctrl-A start Ctrl-E end Ctrl-F a char Alt-B/F a word"), + (b"cut", b"Ctrl-W word back Alt-D word on Ctrl-K to end Ctrl-U line"), + (b"put", b"Ctrl-Y puts back the last cut Ctrl-D or Ctrl-H delete a character"), + (b"complete", b"Tab completes a command or a path"), + (b"recall", b"Up/Down or Ctrl-P/N walk history Ctrl-R search it Ctrl-C abandon"), + (b"history", b"!! the last command !n the nth !text the last starting with text"), + (b"select", b"drag double-click a word triple-click a line Alt+drag a block"), + (b"clipboard", b"Ctrl+Shift+C copy the selection Ctrl+Shift+V paste"), + (b"search", b"Ctrl+Shift+F find in scrollback Enter older Shift+Enter newer"), + (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..3bc3af2591 --- /dev/null +++ b/userland/capsule_terminal/src/command/builtin/help_tools.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 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 list = super::tool_list::tool_list(super::tool::TOOLS); + super::help::row(out, b"tools", &list, super::help_layout::GROUP_PAD); +} diff --git a/userland/capsule_terminal/src/command/builtin/history_cmd.rs b/userland/capsule_terminal/src/command/builtin/history_cmd.rs index 3ab105dfb7..d08c4468a5 100644 --- a/userland/capsule_terminal/src/command/builtin/history_cmd.rs +++ b/userland/capsule_terminal/src/command/builtin/history_cmd.rs @@ -15,7 +15,7 @@ // along with this program. If not, see . use crate::command::output::Output; -use crate::term::dimensions::COLS; +use crate::term::dimensions::LINE_MAX; use crate::term::history::History; use crate::term::util::format_u64; @@ -24,7 +24,7 @@ pub fn run(out: &mut Output<'_>, history: &History, _argv: &[&[u8]]) { let mut numbuf = [0u8; 4]; let nn = format_u64(i as u64, &mut numbuf); let p = nn.min(3); - let mut line = [0u8; COLS]; + let mut line = [0u8; LINE_MAX]; let mut o = 0; for k in 0..p { line[o] = numbuf[nn - p + k]; @@ -34,7 +34,7 @@ pub fn run(out: &mut Output<'_>, history: &History, _argv: &[&[u8]]) { line[o + 1] = b' '; o += 2; let body = history.get(i); - let take = body.len().min(COLS - o); + let take = body.len().min(LINE_MAX - o); line[o..o + take].copy_from_slice(&body[..take]); out.writeln(&line[..o + take]); } diff --git a/userland/capsule_terminal/src/command/builtin/mod.rs b/userland/capsule_terminal/src/command/builtin/mod.rs index 8fd1c2c4c3..29368e7806 100644 --- a/userland/capsule_terminal/src/command/builtin/mod.rs +++ b/userland/capsule_terminal/src/command/builtin/mod.rs @@ -25,7 +25,10 @@ pub mod exit_check; pub mod fs; pub mod git; pub mod help; +mod help_layout; pub mod help_one; +mod help_pages; +mod help_tools; pub mod history_cmd; pub mod jobs; pub mod market; @@ -37,6 +40,8 @@ pub mod receipt; pub mod service; pub mod theme; pub mod tool; +mod tool_list; +mod tool_refused; pub mod version; pub mod which; pub mod whoami; diff --git a/userland/capsule_terminal/src/command/builtin/nox/exec.rs b/userland/capsule_terminal/src/command/builtin/nox/exec.rs index c294783c53..96b2bd4ac0 100644 --- a/userland/capsule_terminal/src/command/builtin/nox/exec.rs +++ b/userland/capsule_terminal/src/command/builtin/nox/exec.rs @@ -39,6 +39,7 @@ pub fn run(state: &mut State, args: &[&[u8]]) -> bool { let argv = argv_blob(stem, &args[1..]); match call_installer(stem, &argv) { Ok(pid) => { + crate::jobs::tty::attach(state, pid); let work = JobWork::ExternalStage { pid, in_buf: Vec::new(), in_cursor: 0 }; let _ = submit(state, stem, false, work); state.fg_running = true; diff --git a/userland/capsule_terminal/src/command/builtin/nox/install/job.rs b/userland/capsule_terminal/src/command/builtin/nox/install/job.rs index 2d1a6f7c3b..0b46696224 100644 --- a/userland/capsule_terminal/src/command/builtin/nox/install/job.rs +++ b/userland/capsule_terminal/src/command/builtin/nox/install/job.rs @@ -27,7 +27,7 @@ const DEADLINE_MS: i64 = 5000; // final flush of whatever is still buffered. Holds the progress cursor // (elapsed start, whether any output has been seen) between slices. pub struct InstallJob { - pid: u32, + pub(crate) pid: u32, start: i64, saw_output: bool, } diff --git a/userland/capsule_terminal/src/command/builtin/nox/install/run.rs b/userland/capsule_terminal/src/command/builtin/nox/install/run.rs index 8805cfe7ba..762e03a64b 100644 --- a/userland/capsule_terminal/src/command/builtin/nox/install/run.rs +++ b/userland/capsule_terminal/src/command/builtin/nox/install/run.rs @@ -37,6 +37,7 @@ pub fn run(state: &mut State, args: &[&[u8]]) -> bool { let argv = argv_blob(stem, &args[1..]); match call_installer(stem, &argv) { Ok(new_pid) => { + crate::jobs::tty::attach(state, new_pid); emit_ok(state, stem, new_pid); debug_marker(b"[TERMINAL-INSTALL] load ok\n"); drain_output(state, new_pid); diff --git a/userland/capsule_terminal/src/command/builtin/tool.rs b/userland/capsule_terminal/src/command/builtin/tool.rs index e6981ab083..8ac9faba23 100644 --- a/userland/capsule_terminal/src/command/builtin/tool.rs +++ b/userland/capsule_terminal/src/command/builtin/tool.rs @@ -3,25 +3,23 @@ // SPDX-License-Identifier: AGPL-3.0-or-later //! Run one of the baked, attested command-line tools (grex, tokei, csview, ...) -//! from the shell. The kernel spawns the tool parented to this terminal, so the -//! same async drain job that streams a store install's output streams the -//! tool's stdout here. Adding a tool is one line in `TOOLS`. +//! from the shell. The kernel spawns the tool parented to this terminal, which +//! drives it as any foreground program: its stdout into the block, keys to its +//! stdin, Ctrl-C to end it, and its own exit status. Adding a tool is one line +//! in `TOOLS`. use alloc::vec::Vec; use nonos_libc::mk_tool_run; -use crate::command::builtin::nox::install::InstallJob; use crate::term::state::State; /// The installed command-line tools: what you type, and the service it runs. /// -/// The two are not always the same word. ripgrep installs as `rg`, which is the -/// name its users have in their fingers, while its capsule serves -/// `tool.ripgrep`. Mapping the pair here lets a tool keep the name it is known -/// by without renaming its service, and both spellings can reach it. -/// -/// Kept in step with `userland/apps.list` and the std tool capsules the desktop -/// profile bakes. +/// The typed name comes first because it need not be the service's: a tool +/// can keep the name its users have in their fingers without renaming its +/// capsule. Every service here is one `userland/apps.list` registers, but +/// `linux`, which the kernel runs itself (`tool.linux`); terminal_line_proofs +/// checks both, so a name on this list always has a program behind it. pub const TOOLS: &[(&[u8], &[u8])] = &[ (b"grex", b"grex"), (b"dotenv-linter", b"dotenv-linter"), @@ -30,11 +28,12 @@ pub const TOOLS: &[(&[u8], &[u8])] = &[ (b"tokei", b"tokei"), (b"huniq", b"huniq"), (b"csview", b"csview"), - (b"rg", b"ripgrep"), - (b"ripgrep", b"ripgrep"), - (b"install", b"install"), + /* Not a crates.io tool: the Linux personality, running a Linux program. */ + (b"linux", b"linux"), ]; +// `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. @@ -49,9 +48,9 @@ pub fn is_tool(name: &[u8]) -> bool { } /// Spawn the baked tool named by `args[0]` with the rest as its argv, and return -/// a drain job that streams its stdout to the terminal. `None` on a spawn error, -/// with the reason already pushed to the scrollback. -pub fn prepare(state: &mut State, args: &[&[u8]]) -> Option { +/// its pid. `None` on a spawn error, with the reason already pushed to the +/// scrollback. +pub fn prepare(state: &mut State, args: &[&[u8]]) -> Option { let name = args[0]; // `is_tool` gated this call, so the lookup cannot miss. Falling back to the // typed name rather than unwrapping keeps a future caller that skips the @@ -64,11 +63,11 @@ pub fn prepare(state: &mut State, args: &[&[u8]]) -> Option { let argv = argv_blob(args); let rc = mk_tool_run(&service, &argv); if rc < 0 { - state.scrollback.push_error(b"tool: launch failed"); - state.last_status = 1; + super::tool_refused::refused(state, name, rc); return None; } - Some(InstallJob::new(rc as u32)) + crate::jobs::tty::attach(state, rc as u32); + Some(rc as u32) } // argv as the tool sees it: argv[0] is the command name, then each argument, diff --git a/userland/capsule_terminal/src/command/builtin/tool_list.rs b/userland/capsule_terminal/src/command/builtin/tool_list.rs new file mode 100644 index 0000000000..f9bf930785 --- /dev/null +++ b/userland/capsule_terminal/src/command/builtin/tool_list.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 tools row of `help`, kept apart from the tool table that spawns them +//! so the proofs can build it. + +use alloc::vec::Vec; + +use super::help_layout::{GROUP_PAD, WIDTH}; + +/* + * The tools, one name for each: two names for one service run the same + * program, and listing both says there are two. A name that would push + * the row past the width is left out rather than wrapped; the list points + * at what exists. + */ +pub fn tool_list(tools: &[(&[u8], &[u8])]) -> Vec { + let room = WIDTH - 2 - GROUP_PAD; + let mut list: Vec = Vec::with_capacity(room); + for (i, (typed, service)) in tools.iter().enumerate() { + if tools[..i].iter().any(|(_, s)| s == service) { + continue; + } + let gap = if list.is_empty() { 0 } else { 2 }; + if list.len() + gap + typed.len() > room { + break; + } + list.resize(list.len() + gap, b' '); + list.extend_from_slice(typed); + } + list +} diff --git a/userland/capsule_terminal/src/command/builtin/tool_refused.rs b/userland/capsule_terminal/src/command/builtin/tool_refused.rs new file mode 100644 index 0000000000..4a90fd6ed0 --- /dev/null +++ b/userland/capsule_terminal/src/command/builtin/tool_refused.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 . + +//! What the terminal says when the kernel will not start a tool. + +use crate::term::state::State; + +const ERRNO_NOENT: i64 = -2; +const ERRNO_EXIST: i64 = -17; + +/// Which tool the kernel would not start, and why, in its own words. +pub fn refused(state: &mut State, name: &[u8], rc: i64) { + let why: &[u8] = match rc { + ERRNO_NOENT => b": not installed in this build", + /* The kernel's answer when a live one holds the tool's endpoints. */ + ERRNO_EXIST => b": one is already running", + _ => b": the kernel refused to start it", + }; + let mut line = name.to_vec(); + line.extend_from_slice(why); + state.scrollback.push_error(&line); + state.last_status = 1; +} 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/command/output/feed_raw.rs b/userland/capsule_terminal/src/command/output/feed_raw.rs index 90844b5f69..083fb30ad2 100644 --- a/userland/capsule_terminal/src/command/output/feed_raw.rs +++ b/userland/capsule_terminal/src/command/output/feed_raw.rs @@ -20,4 +20,13 @@ impl<'a> Output<'a> { pub fn feed_raw(&mut self, bytes: &[u8]) { self.sb.feed_raw(bytes); } + + /// Answers the screen owes the program that wrote to it. + pub fn take_replies(&mut self) -> alloc::vec::Vec { + self.sb.vt.take_replies() + } + + pub fn program_ended(&mut self) { + self.sb.program_ended(); + } } diff --git a/userland/capsule_terminal/src/event/clip.rs b/userland/capsule_terminal/src/event/clip.rs new file mode 100644 index 0000000000..9932ce85fc --- /dev/null +++ b/userland/capsule_terminal/src/event/clip.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 . + +//! Ctrl+Shift+C copies, Ctrl+Shift+V pastes, Ctrl+Shift+F searches. They +//! are taken before a running program sees the key, since without Shift +//! the same letters are the program's own. + +use crate::term::state::State; +use nonos_app_skeleton::{clipboard_copy, EventOutcome, InputEvent, MOD_CTRL, MOD_SHIFT}; + +pub fn clip_key(state: &mut State, event: &InputEvent) -> Option { + let both = MOD_CTRL | MOD_SHIFT; + if event.flags & both != both { + return None; + } + match char::from_u32(event.code)?.to_ascii_lowercase() { + 'c' if state.sel.is_some() => Some(copy_selection(state)), + 'v' if state.fg_running => Some(super::paste_program::paste_to_program(state)), + 'f' => Some(super::find_bar::open(state)), + _ => None, + } +} + +fn copy_selection(state: &mut State) -> EventOutcome { + let Some(sel) = state.sel else { return EventOutcome::Idle }; + let (a, b) = sel.ordered(); + let text = state.scrollback.vt.text_between(a, b, sel.block); + if clipboard_copy(text.as_bytes()).is_err() { + state.scrollback.push_line(b"copy: clipboard unavailable"); + return EventOutcome::Repaint; + } + EventOutcome::Idle +} diff --git a/userland/capsule_terminal/src/event/cooked.rs b/userland/capsule_terminal/src/event/cooked.rs new file mode 100644 index 0000000000..e0d4ec1baf --- /dev/null +++ b/userland/capsule_terminal/src/event/cooked.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 . + +//! The line a foreground program has not been sent yet, edited as a tty's +//! canonical mode edits it. Typed characters echo, Backspace erases the +//! last one from the screen as well as the line, Ctrl+U the whole line and +//! Ctrl+W the last word, and Enter sends the line with its newline. + +use alloc::vec::Vec; + +use nonos_vt::width::width; + +#[derive(Default)] +pub struct Cooked { + pub line: Vec, +} + +/// What an edit asks the terminal to do. +#[derive(Default)] +pub struct Effect { + /// Bytes to put on the screen. + pub echo: Vec, + /// Bytes to send to the program. + pub send: Vec, +} + +pub(super) fn erase(echo: &mut Vec, c: char) { + for _ in 0..width(c).max(1) { + echo.extend_from_slice(b"\x08 \x08"); + } +} + +impl Cooked { + pub fn char(&mut self, c: char, fx: &mut Effect) { + self.line.push(c); + let mut buf = [0u8; 4]; + fx.echo.extend_from_slice(c.encode_utf8(&mut buf).as_bytes()); + } + + pub fn backspace(&mut self, fx: &mut Effect) { + if let Some(c) = self.line.pop() { + erase(&mut fx.echo, c); + } + } +} diff --git a/userland/capsule_terminal/src/event/cooked_kill.rs b/userland/capsule_terminal/src/event/cooked_kill.rs new file mode 100644 index 0000000000..40fd3ef402 --- /dev/null +++ b/userland/capsule_terminal/src/event/cooked_kill.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 . + +//! Killing back to a word or line start, and Enter, in the line a program +//! has not been sent yet. + +use super::cooked::{erase, Cooked, Effect}; + +impl Cooked { + pub fn kill_line(&mut self, fx: &mut Effect) { + while let Some(c) = self.line.pop() { + erase(&mut fx.echo, c); + } + } + + pub fn kill_word(&mut self, fx: &mut Effect) { + while self.line.last().is_some_and(|c| c.is_whitespace()) { + self.backspace(fx); + } + while self.line.last().is_some_and(|c| !c.is_whitespace()) { + self.backspace(fx); + } + } + + pub fn enter(&mut self, fx: &mut Effect) { + fx.echo.extend_from_slice(b"\n"); + for c in self.line.drain(..) { + let mut buf = [0u8; 4]; + fx.send.extend_from_slice(c.encode_utf8(&mut buf).as_bytes()); + } + fx.send.push(b'\n'); + } +} diff --git a/userland/capsule_terminal/src/event/fg_cooked.rs b/userland/capsule_terminal/src/event/fg_cooked.rs new file mode 100644 index 0000000000..3d84d350be --- /dev/null +++ b/userland/capsule_terminal/src/event/fg_cooked.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 . + +//! Keys for a program reading lines: the line is edited here and sent on +//! Enter, as a tty's canonical mode does. + +use nonos_app_skeleton::EventOutcome; +use nonos_vt::input::{Key, Mods}; + +use super::cooked::Effect; +use super::fg_input::send; +use super::interrupt::interrupt; +use crate::term::state::State; + +pub(super) fn cooked(state: &mut State, key: Key, m: Mods) -> EventOutcome { + let mut fx = Effect::default(); + let lc = |c: char| c.to_ascii_lowercase(); + match key { + Key::Char(c) if m.ctrl => match lc(c) { + 'c' => return interrupt(state), + 'u' => state.cooked.kill_line(&mut fx), + 'w' => state.cooked.kill_word(&mut fx), + 'd' if state.cooked.line.is_empty() => { + /* + * There is no end-of-input for a NONOS program's stdin yet; + * saying so beats a key that silently does nothing. + */ + fx.echo.extend_from_slice(b"^D (end of input is not delivered to this program)\n"); + } + _ => return EventOutcome::Idle, + }, + Key::Char(c) => state.cooked.char(c, &mut fx), + Key::Tab => state.cooked.char('\t', &mut fx), + Key::Backspace => state.cooked.backspace(&mut fx), + Key::Enter => state.cooked.enter(&mut fx), + _ => return EventOutcome::Idle, + } + if !fx.echo.is_empty() { + state.scrollback.feed_raw(&fx.echo); + } + if !fx.send.is_empty() { + send(state, &fx.send); + } + EventOutcome::Repaint +} diff --git a/userland/capsule_terminal/src/event/fg_input.rs b/userland/capsule_terminal/src/event/fg_input.rs new file mode 100644 index 0000000000..edaa8b1ba3 --- /dev/null +++ b/userland/capsule_terminal/src/event/fg_input.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 . + +//! Whether the foreground program reads raw keys, and handing it bytes. + +use nonos_vt::{MouseMode, Term}; + +use crate::jobs::JobWork; +use crate::term::state::State; + +pub fn reads_raw(vt: &Term) -> bool { + let m = &vt.modes; + vt.alt_active() || m.cursor_keys || m.bracketed_paste || m.mouse != MouseMode::Off +} + +/// Queue bytes for the foreground program. False when it cannot take input. +pub fn send(state: &mut State, bytes: &[u8]) -> bool { + let Some(id) = state.jobs.foreground() else { return false }; + match state.jobs.get_mut(id).map(|j| &mut j.work) { + Some(JobWork::ExternalStage { in_buf, .. }) => { + in_buf.extend_from_slice(bytes); + true + } + _ => false, + } +} + +pub(super) fn takes_input(state: &State) -> bool { + let Some(id) = state.jobs.foreground() else { return false }; + matches!(state.jobs.get(id).map(|j| &j.work), Some(JobWork::ExternalStage { .. })) +} diff --git a/userland/capsule_terminal/src/event/fg_keys.rs b/userland/capsule_terminal/src/event/fg_keys.rs new file mode 100644 index 0000000000..0853c1c647 --- /dev/null +++ b/userland/capsule_terminal/src/event/fg_keys.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 . + +//! Keys while a program runs in the foreground. +//! +//! A program that drew a full screen reads keys raw, as the sequences +//! xterm sends. Any other program reads lines, edited here as a tty's +//! canonical mode edits them. Programs cannot yet say which they want: the +//! terminal has no path for a program's tty settings. Until it does, a +//! program is taken to read raw once it asks for the alternate screen, +//! application cursor keys, bracketed paste or mouse reports, which only +//! raw readers ask for. + +use nonos_app_skeleton::{EventOutcome, InputEvent}; +use nonos_vt::input::{encode_key, Key, Mods}; + +use super::fg_input::{reads_raw, send, takes_input}; +use super::interrupt::interrupt; +use super::keymap::key_of; +use crate::term::state::State; + +pub fn fg_key(state: &mut State, event: InputEvent) -> Option { + if !state.fg_running { + return None; + } + let Some((key, m)) = key_of(&event) else { return Some(EventOutcome::Idle) }; + // Shift with the page keys reads history, as in every terminal. + if m.shift && !m.ctrl && !state.scrollback.vt.alt_active() { + let page = state.scrollback.vt.rows().saturating_sub(2).max(1); + match key { + Key::PageUp => state.scrollback.scroll_up(page), + Key::PageDown => state.scrollback.scroll_down(page), + _ => return Some(raw_or_cooked(state, key, m)), + } + return Some(EventOutcome::Repaint); + } + Some(raw_or_cooked(state, key, m)) +} + +fn raw_or_cooked(state: &mut State, key: Key, m: Mods) -> EventOutcome { + if !takes_input(state) { + // A built-in job reads nothing; Ctrl+C still stops it. + if m.ctrl && key == Key::Char('c') { + return interrupt(state); + } + return EventOutcome::Idle; + } + state.scrollback.jump_bottom(); + if reads_raw(&state.scrollback.vt) { + let mut bytes = alloc::vec::Vec::new(); + if encode_key(key, m, &state.scrollback.vt.modes, &mut bytes) { + send(state, &bytes); + } + return EventOutcome::Repaint; + } + super::fg_cooked::cooked(state, key, m) +} diff --git a/userland/capsule_terminal/src/event/find_bar.rs b/userland/capsule_terminal/src/event/find_bar.rs new file mode 100644 index 0000000000..3703f0ac65 --- /dev/null +++ b/userland/capsule_terminal/src/event/find_bar.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 scrollback search bar. Typing searches back from the newest line, +//! Enter steps to the next older match and Shift+Enter to the newer one, +//! Alt+C toggles case, Esc closes. The view follows the match. + +use nonos_app_skeleton::{ + EventOutcome, InputEvent, KEY_BACKSPACE, KEY_ENTER, KEY_ESC, MOD_ALT, MOD_SHIFT, +}; + +use super::find_seek::{end, reveal, seek}; +use crate::term::select::Find; +use crate::term::state::State; + +const MAX_QUERY: usize = 256; + +pub fn open(state: &mut State) -> EventOutcome { + state.find = Some(Find::default()); + EventOutcome::Repaint +} + +pub fn key(state: &mut State, event: InputEvent) -> EventOutcome { + let Some(mut f) = state.find.take() else { return EventOutcome::Idle }; + let alt = event.flags & MOD_ALT != 0; + match event.code { + KEY_ESC => { + state.scrollback.jump_bottom(); + return EventOutcome::Repaint; + } + KEY_ENTER => { + let from = f.hit.map(|h| h.0).unwrap_or(end(state)); + f.hit = seek(state, &f, from, event.flags & MOD_SHIFT == 0).or(f.hit); + } + KEY_BACKSPACE => { + f.query.pop(); + f.hit = seek(state, &f, end(state), true); + } + c if alt && matches!(c, 0x43 | 0x63) => { + f.case = !f.case; + f.hit = seek(state, &f, end(state), true); + } + c => { + let Some(ch) = char::from_u32(c).filter(|ch| !ch.is_control()) else { + state.find = Some(f); + return EventOutcome::Idle; + }; + if f.query.len() < MAX_QUERY { + f.query.push(ch); + } + f.hit = seek(state, &f, end(state), true); + } + } + if let Some((a, _)) = f.hit { + reveal(state, a.line); + } + state.find = Some(f); + EventOutcome::Repaint +} diff --git a/userland/capsule_terminal/src/event/find_seek.rs b/userland/capsule_terminal/src/event/find_seek.rs new file mode 100644 index 0000000000..1c487c6311 --- /dev/null +++ b/userland/capsule_terminal/src/event/find_seek.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 . + +//! Where a search starts, the next match from a place, and bringing a +//! match into view. + +use nonos_vt::Pos; + +use crate::term::select::Find; +use crate::term::state::State; + +/// Just past the newest cell, so a backward search starts at the bottom. +pub(super) fn end(state: &State) -> Pos { + Pos { line: state.scrollback.vt.last_line() + 1, col: 0 } +} + +pub(super) fn seek(state: &State, f: &Find, from: Pos, older: bool) -> Option<(Pos, Pos)> { + if f.query.is_empty() { + return None; + } + state.scrollback.vt.find(&f.query, from, older, f.case) +} + +/// Scroll history so `line` sits mid-screen. +pub(super) fn reveal(state: &mut State, line: u64) { + let vt = &mut state.scrollback.vt; + let rows = vt.rows() as u64; + let bottom = vt.cursor_pos().line; + let back = (bottom + rows / 2).saturating_sub(line + rows); + vt.scroll_to_bottom(); + vt.scroll_view(back.min(isize::MAX as u64) as isize); +} diff --git a/userland/capsule_terminal/src/event/fg_stdin.rs b/userland/capsule_terminal/src/event/interrupt.rs similarity index 50% rename from userland/capsule_terminal/src/event/fg_stdin.rs rename to userland/capsule_terminal/src/event/interrupt.rs index 6c200f3e50..17bec71589 100644 --- a/userland/capsule_terminal/src/event/fg_stdin.rs +++ b/userland/capsule_terminal/src/event/interrupt.rs @@ -14,31 +14,32 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . +//! Ctrl+C: stop the foreground job, or drop the line being typed. + use nonos_app_skeleton::EventOutcome; +use nonos_libc::mk_kill; -use crate::command::output::Output; use crate::jobs::JobWork; use crate::term::state::State; -// A key typed while a foreground job runs: if that job is an interactive -// external capsule, queue the byte on its stdin buffer (the on_tick pump feeds -// it to the child via mk_proc_input) and echo it locally so the typist sees -// what they entered. Any other foreground job swallows the key exactly as -// Task 13's gate did, returning `Idle`. -pub fn forward(state: &mut State, byte: u8) -> EventOutcome { - let mut fed = false; - if let Some(id) = state.jobs.foreground() { - if let Some(job) = state.jobs.get_mut(id) { - if let JobWork::ExternalStage { in_buf, .. } = &mut job.work { - in_buf.push(byte); - fed = true; +const SIGINT: u64 = 2; + +pub fn interrupt(state: &mut State) -> EventOutcome { + if state.fg_running { + if let Some(id) = state.jobs.foreground() { + if let Some(job) = state.jobs.get_mut(id) { + if let JobWork::ExternalStage { pid, .. } = job.work { + let _ = mk_kill(pid as u64, SIGINT); + } + job.cancel = true; } } - } - if fed { - Output::new(&mut state.scrollback).feed_raw(&[byte]); - EventOutcome::Repaint + state.cooked.line.clear(); } else { - EventOutcome::Idle + state.line.clear(); + state.history.reset_cursor(); } + state.scrollback.push_line(b"^C"); + state.scrollback.jump_bottom(); + EventOutcome::Repaint } diff --git a/userland/capsule_terminal/src/event/key_first.rs b/userland/capsule_terminal/src/event/key_first.rs new file mode 100644 index 0000000000..fac5a37426 --- /dev/null +++ b/userland/capsule_terminal/src/event/key_first.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 a key reaches before the shell's line editor: copy, paste and +//! search, an open search bar, and a running program. + +use nonos_app_skeleton::{EventOutcome, InputEvent}; + +use crate::term::state::State; + +pub fn key_first(state: &mut State, event: InputEvent) -> Option { + if let Some(out) = super::clip::clip_key(state, &event) { + return Some(out); + } + if state.find.is_some() { + return Some(super::find_bar::key(state, event)); + } + // Typing moves on from whatever was picked. + state.sel = None; + /* + * A running program gets the keys first, Ctrl ones included: a full + * screen program binds them itself. + */ + super::fg_keys::fg_key(state, event) +} + +/// A page of history: the screen less two lines of overlap. +pub fn page(state: &State) -> usize { + state.scrollback.vt.rows().saturating_sub(2).max(1) +} diff --git a/userland/capsule_terminal/src/event/keymap.rs b/userland/capsule_terminal/src/event/keymap.rs new file mode 100644 index 0000000000..e187771e2b --- /dev/null +++ b/userland/capsule_terminal/src/event/keymap.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 . + +//! A key press as the window reports it, as the key a terminal program +//! reads. + +use nonos_app_skeleton::{ + InputEvent, KEY_BACKSPACE, KEY_DELETE, KEY_DOWN, KEY_END, KEY_ENTER, KEY_ESC, KEY_F1, KEY_F12, + KEY_HOME, KEY_INSERT, KEY_LEFT, KEY_PAGE_DOWN, KEY_PAGE_UP, KEY_RIGHT, KEY_TAB, KEY_UP, + MOD_ALT, MOD_CTRL, MOD_SHIFT, +}; +use nonos_vt::input::{Key, Mods}; + +pub fn mods(flags: u16) -> Mods { + Mods { shift: flags & MOD_SHIFT != 0, alt: flags & MOD_ALT != 0, ctrl: flags & MOD_CTRL != 0 } +} + +pub fn key_of(event: &InputEvent) -> Option<(Key, Mods)> { + let m = mods(event.flags); + let key = match event.code { + KEY_ENTER => Key::Enter, + KEY_TAB => Key::Tab, + KEY_BACKSPACE | 0x7F => Key::Backspace, + KEY_ESC => Key::Escape, + KEY_UP => Key::Up, + KEY_DOWN => Key::Down, + KEY_LEFT => Key::Left, + KEY_RIGHT => Key::Right, + KEY_HOME => Key::Home, + KEY_END => Key::End, + KEY_PAGE_UP => Key::PageUp, + KEY_PAGE_DOWN => Key::PageDown, + KEY_INSERT => Key::Insert, + KEY_DELETE => Key::Delete, + c @ KEY_F1..=KEY_F12 => Key::F((c - KEY_F1 + 1) as u8), + c => Key::Char(char::from_u32(c).filter(|ch| !ch.is_control())?), + }; + Some((key, m)) +} diff --git a/userland/capsule_terminal/src/event/mod.rs b/userland/capsule_terminal/src/event/mod.rs index 22318e8102..4ef83892d2 100644 --- a/userland/capsule_terminal/src/event/mod.rs +++ b/userland/capsule_terminal/src/event/mod.rs @@ -16,21 +16,35 @@ mod accept_suggestion; mod bool_to_outcome; +mod clip; pub(crate) mod complete; +pub(crate) mod cooked; +mod cooked_kill; mod copy_line; -mod fg_stdin; +mod fg_cooked; +mod fg_input; +mod fg_keys; +mod find_bar; +mod find_seek; +mod interrupt; +mod key_first; +pub(crate) mod keymap; mod on_ctrl; mod on_down; mod on_enter; mod on_event; mod on_key; +mod on_nav; mod on_printable; mod on_tab; mod on_up; mod paste_clipboard; +mod paste_program; +mod readline; pub(crate) mod search; pub(crate) mod search_edit; mod search_place; +pub use fg_input::send as send_to_program; pub use on_enter::on_enter; pub use on_event::on_event; diff --git a/userland/capsule_terminal/src/event/on_ctrl.rs b/userland/capsule_terminal/src/event/on_ctrl.rs index 1dc20535d9..8740ef08c0 100644 --- a/userland/capsule_terminal/src/event/on_ctrl.rs +++ b/userland/capsule_terminal/src/event/on_ctrl.rs @@ -15,18 +15,14 @@ // along with this program. If not, see . use nonos_app_skeleton::{EventOutcome, KEY_LEFT, KEY_RIGHT, MOD_SHIFT}; -use nonos_libc::mk_kill; use super::accept_suggestion::accept_suggestion; use super::bool_to_outcome::bool_to_outcome; use super::copy_line::copy_line; use super::paste_clipboard::paste_clipboard; use super::search::{search_cancel, search_step}; -use crate::jobs::JobWork; use crate::term::state::State; -const SIGINT: u64 = 2; - const CTRL_A: u32 = 0x41; const CTRL_C: u32 = 0x43; const CTRL_E: u32 = 0x45; @@ -73,24 +69,7 @@ pub fn on_ctrl(state: &mut State, code: u32, flags: u16) -> Option state.scrollback.jump_bottom(); Some(EventOutcome::Repaint) } - CTRL_C | CTRL_C_LO => { - if state.fg_running { - if let Some(id) = state.jobs.foreground() { - if let Some(job) = state.jobs.get_mut(id) { - if let JobWork::ExternalStage { pid, .. } = job.work { - let _ = mk_kill(pid as u64, SIGINT); - } - job.cancel = true; - } - } - } else { - state.line.clear(); - state.history.reset_cursor(); - } - state.scrollback.push_line(b"^C"); - state.scrollback.jump_bottom(); - Some(EventOutcome::Repaint) - } + CTRL_C | CTRL_C_LO => Some(super::interrupt::interrupt(state)), CTRL_U | CTRL_U_LO => { state.line.kill_line(); Some(EventOutcome::Repaint) diff --git a/userland/capsule_terminal/src/event/on_enter.rs b/userland/capsule_terminal/src/event/on_enter.rs index 0e89b68098..5e97499544 100644 --- a/userland/capsule_terminal/src/event/on_enter.rs +++ b/userland/capsule_terminal/src/event/on_enter.rs @@ -21,7 +21,7 @@ use crate::command; use crate::jobs; use crate::term::context::context_line; use crate::term::cwd::home_var; -use crate::term::dimensions::COLS; +use crate::term::dimensions::LINE_MAX; use crate::term::identity::{hostname, USER}; use crate::term::prompt::PROMPT_BYTES; use crate::term::state::State; @@ -34,20 +34,20 @@ pub fn on_enter(state: &mut State) -> EventOutcome { state.fresh = false; let started = mk_time_millis(); state.open_block(crate::term::rtc::rtc_hms()); - let mut ctx = [0u8; COLS]; + let mut ctx = [0u8; LINE_MAX]; let cn = context_line(USER, hostname(), state.cwd.as_bytes(), home_var(state), &mut ctx); state.scrollback.push_line(&ctx[..cn]); // A `!` form is resolved before anything else sees the line, so what is // echoed, recorded in history and run are all the same text. Expanding // later would put one command on screen and another through the parser. - let mut entered = [0u8; COLS]; + let mut entered = [0u8; LINE_MAX]; let n; match crate::term::history::expand(state.line.as_bytes(), &state.history) { // The expansion is what gets echoed, which is the whole safety of the // feature: the reader sees the command that is about to run, not the // shorthand they typed for it. Some(Ok(line)) => { - n = line.len().min(COLS); + n = line.len().min(LINE_MAX); entered[..n].copy_from_slice(&line[..n]); } Some(Err(_)) => { @@ -67,7 +67,7 @@ pub fn on_enter(state: &mut State) -> EventOutcome { entered[..n].copy_from_slice(body); } } - let mut echo = [0u8; COLS + 8]; + let mut echo = [0u8; LINE_MAX + 8]; let mut k = 0; k += copy_into(&mut echo[k..], PROMPT_BYTES); k += copy_into(&mut echo[k..], &entered[..n]); diff --git a/userland/capsule_terminal/src/event/on_key.rs b/userland/capsule_terminal/src/event/on_key.rs index 1447accbd5..6eedd3c76d 100644 --- a/userland/capsule_terminal/src/event/on_key.rs +++ b/userland/capsule_terminal/src/event/on_key.rs @@ -15,8 +15,8 @@ // along with this program. If not, see . use nonos_app_skeleton::{ - EventOutcome, InputEvent, KEY_BACKSPACE, KEY_DELETE, KEY_DOWN, KEY_END, KEY_ENTER, KEY_ESC, - KEY_HOME, KEY_LEFT, KEY_PAGE_DOWN, KEY_PAGE_UP, KEY_RIGHT, KEY_TAB, KEY_UP, MOD_CTRL, + EventOutcome, InputEvent, KEY_BACKSPACE, KEY_DELETE, KEY_DOWN, KEY_ENTER, KEY_ESC, KEY_TAB, + KEY_UP, MOD_CTRL, }; use super::bool_to_outcome::bool_to_outcome; @@ -26,23 +26,22 @@ use super::on_enter::on_enter; use super::on_printable::on_printable; use super::on_tab::on_tab; use super::on_up::on_up; -use crate::term::dimensions::VISIBLE_ROWS; use crate::term::state::State; pub fn on_key(state: &mut State, event: InputEvent) -> EventOutcome { + if let Some(out) = super::key_first::key_first(state, event) { + return out; + } if event.flags & MOD_CTRL != 0 { if let Some(out) = on_ctrl(state, event.code, event.flags) { return out; } } - if state.fg_running && event.flags & MOD_CTRL == 0 { - match event.code { - KEY_ENTER => return super::fg_stdin::forward(state, b'\n'), - code if (0x20..=0x7E).contains(&code) => { - return super::fg_stdin::forward(state, code as u8) - } - _ => {} - } + if let Some(out) = super::readline::readline_key(state, event.code, event.flags) { + return out; + } + if let Some(out) = super::on_nav::on_nav(state, event.code) { + return out; } match event.code { // Clears the line. Esc used to close the window, so one stray press @@ -59,26 +58,8 @@ pub fn on_key(state: &mut State, event: InputEvent) -> EventOutcome { } KEY_BACKSPACE => bool_to_outcome(state.line.backspace()), KEY_DELETE => bool_to_outcome(state.line.delete()), - KEY_LEFT => bool_to_outcome(state.line.move_left()), - KEY_RIGHT => bool_to_outcome(state.line.move_right()), - KEY_HOME => { - state.line.move_home(); - EventOutcome::Repaint - } - KEY_END => { - state.line.move_end(); - EventOutcome::Repaint - } KEY_UP => on_up(state), KEY_DOWN => on_down(state), - KEY_PAGE_UP => { - state.scrollback.scroll_up(VISIBLE_ROWS - 2); - EventOutcome::Repaint - } - KEY_PAGE_DOWN => { - state.scrollback.scroll_down(VISIBLE_ROWS - 2); - EventOutcome::Repaint - } KEY_TAB => on_tab(state), code if (0x20..=0x7E).contains(&code) => on_printable(state, code as u8), _ => EventOutcome::Idle, diff --git a/userland/capsule_terminal/src/event/on_nav.rs b/userland/capsule_terminal/src/event/on_nav.rs new file mode 100644 index 0000000000..48e4799fbb --- /dev/null +++ b/userland/capsule_terminal/src/event/on_nav.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 . + +/* + * Moving along the line and through history: the arrows, Home and End on + * the line, Page Up and Page Down on the scrollback. + */ + +use nonos_app_skeleton::{ + EventOutcome, KEY_END, KEY_HOME, KEY_LEFT, KEY_PAGE_DOWN, KEY_PAGE_UP, KEY_RIGHT, +}; + +use super::bool_to_outcome::bool_to_outcome; +use crate::term::state::State; + +pub fn on_nav(state: &mut State, code: u32) -> Option { + Some(match code { + KEY_LEFT => bool_to_outcome(state.line.move_left()), + KEY_RIGHT => bool_to_outcome(state.line.move_right()), + KEY_HOME => { + state.line.move_home(); + EventOutcome::Repaint + } + KEY_END => { + state.line.move_end(); + EventOutcome::Repaint + } + KEY_PAGE_UP => { + state.scrollback.scroll_up(super::key_first::page(state)); + EventOutcome::Repaint + } + KEY_PAGE_DOWN => { + state.scrollback.scroll_down(super::key_first::page(state)); + EventOutcome::Repaint + } + _ => return None, + }) +} diff --git a/userland/capsule_terminal/src/event/paste_clipboard.rs b/userland/capsule_terminal/src/event/paste_clipboard.rs index 74814a43f1..4bd16b40f6 100644 --- a/userland/capsule_terminal/src/event/paste_clipboard.rs +++ b/userland/capsule_terminal/src/event/paste_clipboard.rs @@ -16,11 +16,11 @@ use nonos_app_skeleton::{clipboard_paste, EventOutcome}; -use crate::term::dimensions::COLS; +use crate::term::dimensions::LINE_MAX; use crate::term::state::State; pub fn paste_clipboard(state: &mut State) -> EventOutcome { - let mut buf = [0u8; COLS]; + let mut buf = [0u8; LINE_MAX]; let n = match clipboard_paste(&mut buf) { Ok(n) => n.min(buf.len()), Err(_) => return EventOutcome::Idle, @@ -45,7 +45,7 @@ pub fn paste_clipboard(state: &mut State) -> EventOutcome { changed = true; } } - // The input line holds COLS bytes, so anything longer cannot fit. Say so + // The input line holds LINE_MAX bytes, so anything longer cannot fit. Say so // instead of leaving a silently shortened command on the prompt. if multiline { state.scrollback.push_line(b"paste: first line only"); diff --git a/userland/capsule_terminal/src/event/paste_program.rs b/userland/capsule_terminal/src/event/paste_program.rs new file mode 100644 index 0000000000..1f04e1cac2 --- /dev/null +++ b/userland/capsule_terminal/src/event/paste_program.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 . + +//! A paste into a running program: bracketed and stripped of escapes for a +//! raw reader, typed line by line for a line reader. + +use alloc::string::String; +use alloc::vec; + +use nonos_app_skeleton::{clipboard_paste, EventOutcome}; +use nonos_vt::input::encode_paste; + +use super::cooked::Effect; +use super::fg_input::{reads_raw, send}; +use crate::term::state::State; + +/// Largest paste taken at once. +const PASTE_MAX: usize = 16 * 1024; + +pub(super) fn paste_to_program(state: &mut State) -> EventOutcome { + let mut buf = vec![0u8; PASTE_MAX]; + let n = match clipboard_paste(&mut buf) { + Ok(n) => n.min(buf.len()), + Err(_) => { + state.scrollback.push_line(b"paste: clipboard unavailable"); + return EventOutcome::Repaint; + } + }; + let text = String::from_utf8_lossy(&buf[..n]).into_owned(); + if reads_raw(&state.scrollback.vt) { + let mut bytes = alloc::vec::Vec::new(); + encode_paste(&text, &state.scrollback.vt.modes, &mut bytes); + send(state, &bytes); + return EventOutcome::Repaint; + } + // A line reader gets the paste as if typed: each line sent on its end. + let mut fx = Effect::default(); + for c in text.chars() { + match c { + '\r' | '\n' => state.cooked.enter(&mut fx), + c if c.is_control() && c != '\t' => {} + c => state.cooked.char(c, &mut fx), + } + } + state.scrollback.feed_raw(&fx.echo); + send(state, &fx.send); + EventOutcome::Repaint +} diff --git a/userland/capsule_terminal/src/event/readline.rs b/userland/capsule_terminal/src/event/readline.rs new file mode 100644 index 0000000000..17625747df --- /dev/null +++ b/userland/capsule_terminal/src/event/readline.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 . + +/* + * The readline keys a shell user types without thinking, beyond the ones + * on_ctrl owns: Ctrl-F and Ctrl-H step and rub out a character, Ctrl-P and + * Ctrl-N walk history, and Alt with B, F and D moves and cuts by word. + * Ctrl-B is the rail's, taken before the line sees it. + */ + +use nonos_app_skeleton::{EventOutcome, MOD_ALT, MOD_ALTGR, MOD_CTRL}; + +use super::bool_to_outcome::bool_to_outcome; +use crate::term::state::State; + +pub fn readline_key(state: &mut State, code: u32, flags: u16) -> Option { + /* AltGr is how some layouts type characters: a key with it is text. */ + if flags & MOD_ALTGR != 0 { + return None; + } + let letter = char::from_u32(code)?.to_ascii_lowercase(); + if flags & MOD_CTRL != 0 { + return match letter { + 'f' => Some(bool_to_outcome(state.line.move_right())), + 'h' => Some(bool_to_outcome(state.line.backspace())), + 'p' => Some(super::on_up::on_up(state)), + 'n' => Some(super::on_down::on_down(state)), + _ => unbound(code), + }; + } + if flags & MOD_ALT != 0 { + return match letter { + 'b' => Some(bool_to_outcome(state.line.move_word_left())), + 'f' => Some(bool_to_outcome(state.line.move_word_right())), + 'd' => Some(bool_to_outcome(state.line.delete_word_right())), + _ => unbound(code), + }; + } + None +} + +/* + * A chord no binding took: typing its letter would put text on the line + * that nobody typed, so a printable one is swallowed. Named keys go on to + * the line editor, which gives Ctrl-Home and the like their plain meaning. + */ +fn unbound(code: u32) -> Option { + (0x20..=0x7E).contains(&code).then_some(EventOutcome::Idle) +} diff --git a/userland/capsule_terminal/src/jobs/classify.rs b/userland/capsule_terminal/src/jobs/classify.rs index b8f2bb217d..f4b7cdfdc5 100644 --- a/userland/capsule_terminal/src/jobs/classify.rs +++ b/userland/capsule_terminal/src/jobs/classify.rs @@ -80,11 +80,12 @@ pub fn is_job_command(state: &mut State, args: &[&[u8]]) -> Verdict { Verdict::Handled } }, - // Bare-name run of a baked, attested tool (`tokei`, `grex foo`, ...): the - // kernel spawns it parented to this terminal and it streams its stdout - // through the same drain job. + /* A baked tool, `linux` among them, runs as a foreground program for as + * long as it runs: an install's drain gave up on it at 5 s. */ name if tool::is_tool(name) => match tool::prepare(state, args) { - Some(job) => Verdict::Job(JobWork::InstallDrain(job)), + Some(pid) => { + Verdict::Job(JobWork::ExternalStage { pid, in_buf: Vec::new(), in_cursor: 0 }) + } None => Verdict::Handled, }, _ => Verdict::Instant, diff --git a/userland/capsule_terminal/src/jobs/external.rs b/userland/capsule_terminal/src/jobs/external.rs index 4f18afbf25..d84ed49791 100644 --- a/userland/capsule_terminal/src/jobs/external.rs +++ b/userland/capsule_terminal/src/jobs/external.rs @@ -14,52 +14,48 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -use nonos_libc::{mk_proc_input, mk_proc_output, mk_wait}; +use alloc::vec::Vec; + +use nonos_libc::{mk_proc_output, mk_wait}; use crate::command::output::Output; +use super::external_io::{drain_remaining, feed_stdin, TICK_BUDGET}; use crate::jobs::JobProgress; const ERRNO_TIMEDOUT: i64 = -110; -const MAX_PROC_INPUT: usize = 1024 * 1024; - pub fn step_external( pid: u32, - in_buf: &[u8], + in_buf: &mut Vec, in_cursor: &mut usize, out: &mut Output<'_>, + leave_modes: bool, ) -> JobProgress { feed_stdin(pid, in_buf, in_cursor); let mut buf = [0u8; 256]; - let n = mk_proc_output(pid, buf.as_mut_ptr(), buf.len()); - if n > 0 { - out.feed_raw(&buf[..(n as usize).min(buf.len())]); + let mut taken = 0; + while taken < TICK_BUDGET { + let n = mk_proc_output(pid, buf.as_mut_ptr(), buf.len()); + if n <= 0 { + break; + } + let n = (n as usize).min(buf.len()); + out.feed_raw(&buf[..n]); + taken += n; } + /* + * What the program asked the terminal (its cursor position, its + * identity) is answered on its stdin, as a tty answers. + */ + in_buf.extend_from_slice(&out.take_replies()); let status = mk_wait(pid as u64, 0); if status == ERRNO_TIMEDOUT { return JobProgress::Running; } drain_remaining(pid, out, &mut buf); - JobProgress::Done(status as i32) -} - -fn feed_stdin(pid: u32, in_buf: &[u8], in_cursor: &mut usize) { - if *in_cursor >= in_buf.len() { - return; - } - let pending = &in_buf[*in_cursor..]; - let chunk = &pending[..pending.len().min(MAX_PROC_INPUT)]; - let sent = mk_proc_input(pid as u64, chunk.as_ptr(), chunk.len() as u64); - if sent > 0 { - *in_cursor += (sent as usize).min(chunk.len()); - } -} - -fn drain_remaining(pid: u32, out: &mut Output<'_>, buf: &mut [u8; 256]) { - loop { - let m = mk_proc_output(pid, buf.as_mut_ptr(), buf.len()); - if m <= 0 { - break; - } - out.feed_raw(&buf[..(m as usize).min(buf.len())]); + /* A background job ending while a foreground one holds the screen leaves + * its modes to that one; otherwise the screen is reset as ever. */ + if !leave_modes { + out.program_ended(); } + JobProgress::Done(status as i32) } diff --git a/userland/capsule_terminal/src/jobs/external_io.rs b/userland/capsule_terminal/src/jobs/external_io.rs new file mode 100644 index 0000000000..1ff941914e --- /dev/null +++ b/userland/capsule_terminal/src/jobs/external_io.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 . + +//! Moving bytes between the terminal and a running program: its stdin +//! queue in, its output out, each bounded a tick. + +use alloc::vec::Vec; + +use nonos_libc::{mk_proc_input, mk_proc_output}; + +use crate::command::output::Output; + +const MAX_PROC_INPUT: usize = 1024 * 1024; +/// Output taken from a program in one tick. A program printing fast used to +/// be read 256 bytes a tick, about 8 KiB a second; this keeps a frame +/// bounded while letting a build log or a scan scroll at full speed. +pub(super) const TICK_BUDGET: usize = 64 * 1024; + +pub(super) fn feed_stdin(pid: u32, in_buf: &mut Vec, in_cursor: &mut usize) { + if *in_cursor >= in_buf.len() { + // All of it went: start the buffer over rather than let it grow. + in_buf.clear(); + *in_cursor = 0; + return; + } + let pending = &in_buf[*in_cursor..]; + let chunk = &pending[..pending.len().min(MAX_PROC_INPUT)]; + let sent = mk_proc_input(pid as u64, chunk.as_ptr(), chunk.len() as u64); + if sent > 0 { + *in_cursor += (sent as usize).min(chunk.len()); + } +} + +pub(super) fn drain_remaining(pid: u32, out: &mut Output<'_>, buf: &mut [u8; 256]) { + let mut taken = 0; + while taken < TICK_BUDGET { + let m = mk_proc_output(pid, buf.as_mut_ptr(), buf.len()); + if m <= 0 { + break; + } + let m = (m as usize).min(buf.len()); + out.feed_raw(&buf[..m]); + taken += m; + } +} diff --git a/userland/capsule_terminal/src/jobs/mod.rs b/userland/capsule_terminal/src/jobs/mod.rs index 3662d5c5cf..9ef84288fd 100644 --- a/userland/capsule_terminal/src/jobs/mod.rs +++ b/userland/capsule_terminal/src/jobs/mod.rs @@ -17,11 +17,14 @@ mod classify; mod env; mod external; +mod external_io; mod pipeline_job; mod pump; +mod pump_pipeline; mod reap; mod submit; mod table; +pub mod tty; mod work; pub use classify::{is_job_command, is_store_tool, Verdict}; diff --git a/userland/capsule_terminal/src/jobs/pump.rs b/userland/capsule_terminal/src/jobs/pump.rs index e84ef7ff3b..e62e7961ec 100644 --- a/userland/capsule_terminal/src/jobs/pump.rs +++ b/userland/capsule_terminal/src/jobs/pump.rs @@ -19,7 +19,7 @@ use alloc::vec::Vec; use crate::command::output::Output; use crate::term::state::State; -use super::pipeline_job::step_pipeline; +use super::pump_pipeline::step_pipeline_job; use super::reap::reap; use super::table::{JobProgress, JobState}; use super::work::{step, JobWork}; @@ -42,8 +42,8 @@ pub fn pump(state: &mut State) -> bool { } fn step_job(state: &mut State, id: u32) { - let cancel = match state.jobs.get(id) { - Some(job) => job.cancel, + let (cancel, held) = match state.jobs.get(id) { + Some(job) => (job.cancel, state.fg_running), None => return, }; if !cancel && matches!(state.jobs.get(id).map(|j| &j.work), Some(JobWork::PipelineStages(_))) { @@ -51,29 +51,7 @@ fn step_job(state: &mut State, id: u32) { } if let Some(job) = state.jobs.get_mut(id) { let mut out = Output::new(&mut state.scrollback); - if let JobProgress::Done(status) = step(&mut job.work, &mut out, job.cancel) { - job.status = status; - job.state = JobState::Done; - } - } -} - -// `PipelineStages` needs `&mut State` (to run non-filter stages through -// `exec`), which `step` above does not take. The work is moved out of the -// job record so `state` is fully free for `step_pipeline`, then moved back -// in with the resulting status applied. -fn step_pipeline_job(state: &mut State, id: u32) { - let mut work = match state.jobs.get_mut(id) { - Some(job) => core::mem::replace(&mut job.work, JobWork::Noop), - None => return, - }; - let progress = match &mut work { - JobWork::PipelineStages(pj) => step_pipeline(pj, state), - _ => JobProgress::Running, - }; - if let Some(job) = state.jobs.get_mut(id) { - job.work = work; - if let JobProgress::Done(status) = progress { + if let JobProgress::Done(status) = step(job, &mut out, held) { job.status = status; job.state = JobState::Done; } diff --git a/userland/capsule_terminal/src/jobs/pump_pipeline.rs b/userland/capsule_terminal/src/jobs/pump_pipeline.rs new file mode 100644 index 0000000000..2ee4eb7979 --- /dev/null +++ b/userland/capsule_terminal/src/jobs/pump_pipeline.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 crate::term::state::State; + +use super::pipeline_job::step_pipeline; +use super::table::{JobProgress, JobState}; +use super::work::JobWork; + +// `PipelineStages` needs `&mut State` (to run non-filter stages through +// `exec`), which `work::step` does not take. The work is moved out of the +// job record so `state` is fully free for `step_pipeline`, then moved back +// in with the resulting status applied. +pub(super) fn step_pipeline_job(state: &mut State, id: u32) { + let mut work = match state.jobs.get_mut(id) { + Some(job) => core::mem::replace(&mut job.work, JobWork::Noop), + None => return, + }; + let progress = match &mut work { + JobWork::PipelineStages(pj) => step_pipeline(pj, state), + _ => JobProgress::Running, + }; + if let Some(job) = state.jobs.get_mut(id) { + job.work = work; + if let JobProgress::Done(status) = progress { + job.status = status; + job.state = JobState::Done; + } + } +} diff --git a/userland/capsule_terminal/src/jobs/tty.rs b/userland/capsule_terminal/src/jobs/tty.rs new file mode 100644 index 0000000000..5ece144861 --- /dev/null +++ b/userland/capsule_terminal/src/jobs/tty.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 . + +//! Telling a program that its output reaches this screen, and how big the +//! screen is. The kernel answers the program's own is-it-a-terminal +//! question from this, so it picks colour and columns for a person. A +//! stage that feeds a pipe or a file is never told, and writes plain bytes. + +use nonos_libc::{mk_tty_set, TTY_STDERR, TTY_STDIN, TTY_STDOUT}; + +use super::JobWork; +use crate::term::state::State; + +/// The program `pid` reads the keyboard and writes to this screen. A kernel +/// without the call leaves it believing it writes to a pipe, which is where +/// it started, so the answer is not checked. +pub fn attach(state: &State, pid: u32) { + let vt = &state.scrollback.vt; + let (cols, rows) = (vt.cols().min(u16::MAX as usize), vt.rows().min(u16::MAX as usize)); + let _ = mk_tty_set(pid, TTY_STDIN | TTY_STDOUT | TTY_STDERR, cols as u16, rows as u16); +} + +/// The screen changed size: the program in front hears the new one the +/// next time it asks. +pub fn resized(state: &State) { + let Some(id) = state.jobs.foreground() else { return }; + let pid = match state.jobs.get(id).map(|j| &j.work) { + Some(JobWork::ExternalStage { pid, .. }) => *pid, + Some(JobWork::InstallDrain(job)) => job.pid, + _ => return, + }; + attach(state, pid); +} diff --git a/userland/capsule_terminal/src/jobs/work.rs b/userland/capsule_terminal/src/jobs/work.rs index a320749c2c..f0efcd9e06 100644 --- a/userland/capsule_terminal/src/jobs/work.rs +++ b/userland/capsule_terminal/src/jobs/work.rs @@ -21,7 +21,7 @@ use crate::command::builtin::ping::{emit_probe, PingJob}; use crate::command::output::Output; use super::pipeline_job::PipelineJob; -use super::table::JobProgress; +use super::table::{JobProgress, JobRecord}; // The step machine for long-running command kinds, one variant per kind, // each holding the progress cursor its poll body tracks. `Noop` is the @@ -42,12 +42,12 @@ pub enum JobWork { // Step a job's work by one bounded slice. A cancelled job is finished // unconditionally, regardless of variant: the terminal reports it as // interrupted rather than letting the underlying poll run to completion. -pub fn step(work: &mut JobWork, out: &mut Output<'_>, cancel: bool) -> JobProgress { - if cancel { +pub fn step(job: &mut JobRecord, out: &mut Output<'_>, held: bool) -> JobProgress { + if job.cancel { out.writeln(b"interrupted"); return JobProgress::Done(130); } - match work { + match &mut job.work { JobWork::Noop => JobProgress::Done(0), JobWork::Ping(job) => match job.step_once() { None => JobProgress::Running, @@ -58,7 +58,7 @@ pub fn step(work: &mut JobWork, out: &mut Output<'_>, cancel: bool) -> JobProgre }, JobWork::InstallDrain(job) => job.step_once(out), JobWork::ExternalStage { pid, in_buf, in_cursor } => { - super::external::step_external(*pid, in_buf, in_cursor, out) + super::external::step_external(*pid, in_buf, in_cursor, out, job.background && held) } JobWork::PipelineStages(_) => JobProgress::Running, } diff --git a/userland/capsule_terminal/src/main.rs b/userland/capsule_terminal/src/main.rs index 2c1c4c8419..e7e63ee712 100644 --- a/userland/capsule_terminal/src/main.rs +++ b/userland/capsule_terminal/src/main.rs @@ -40,6 +40,12 @@ use nonos_app_skeleton::run; /// It must not be called from Rust code. #[no_mangle] pub unsafe extern "C" fn _start() -> ! { + /* + * Each tab keeps a screen and its history. The default 16 MiB heap left + * room for a few hundred lines; this holds thousands in every tab. + */ + const TERMINAL_HEAP: usize = 64 * 1024 * 1024; + let _ = nonos_libc::heap_init_sized(TERMINAL_HEAP); #[cfg(feature = "nonos-autorun-selftest")] { term::terminal::selftest::main() diff --git a/userland/capsule_terminal/src/paint/block_chrome.rs b/userland/capsule_terminal/src/paint/block_chrome.rs index be1549490c..52c69ee07d 100644 --- a/userland/capsule_terminal/src/paint/block_chrome.rs +++ b/userland/capsule_terminal/src/paint/block_chrome.rs @@ -19,8 +19,8 @@ use nonos_app_skeleton::PaintBuffer; use super::block_meta::draw_meta; use super::metrics::Metrics; use super::shade::elevate; +use super::vt::Area; use crate::term::block::Status; -use crate::term::dimensions::VISIBLE_ROWS; use crate::term::state::State; use crate::term::theme::types::Theme; @@ -30,20 +30,22 @@ const STRIPE_GAP: u32 = 8; pub fn draw_block_chrome( state: &State, fb: &mut PaintBuffer, - ox: u32, - oy: u32, - max_y: u32, - max_x: u32, + a: &Area, + top: u64, m: &Metrics, t: &Theme, ) { - let g = &state.scrollback.grid; - for row in 0..VISIBLE_ROWS { + let (ox, oy, max_x, max_y) = (a.x, a.y, a.max_x, a.max_y); + let last = state.scrollback.vt.cursor_pos().line; + for row in 0.. { let y = crate::layout::row_top(row as u32, oy, m.lh); if y + m.lh > max_y { break; } - let abs = g.abs_of_visible_row(row); + let abs = top + row as u64; + if abs > last { + break; + } let (idx, status) = match block_for(state, abs) { Some(v) => v, None => continue, diff --git a/userland/capsule_terminal/src/paint/compose.rs b/userland/capsule_terminal/src/paint/compose.rs index 2ce84a2b7a..8d6ad5fcd1 100644 --- a/userland/capsule_terminal/src/paint/compose.rs +++ b/userland/capsule_terminal/src/paint/compose.rs @@ -17,16 +17,14 @@ use nonos_app_skeleton::PaintBuffer; use super::block_chrome::draw_block_chrome; -use super::constants::{BODY_PAD_TOP, HEADER_H, TEXT_LEFT}; -use super::draw_grid::{draw_grid, draw_grid_cursor}; use super::draw_input_line::draw_input_line; use super::fetch::draw_fetch; -use super::footer::{draw_footer, footer_h}; +use super::footer::draw_footer; +use super::geometry::geometry; use super::header::draw_header; -use super::metrics::Metrics; use super::rail_left; -use crate::layout::limits::LEFT_RAIL_W; -use crate::layout::{compute, Chrome, Layout, Rails}; +use super::vt::{draw_find_bar, draw_vt, Area, Frame, Rows}; +use crate::layout::Layout; use crate::palette::{Index, Palette}; use crate::rail::Rail; use crate::term::prefs::types::Project; @@ -66,34 +64,30 @@ pub fn paint( ) -> Layout { fb.clear(t.bg); draw_header(state, fb, t); - let m = Metrics::new(fb, font_scale); - let chrome = Chrome { - titlebar_h: HEADER_H, - tabstrip_h: 0, - body_pad_top: BODY_PAD_TOP, - footer_h: footer_h(), - text_left: TEXT_LEFT, - row_h: m.lh, - }; - // The rail is off unless it was asked for. A terminal that opens with a - // quarter of the window given to charts is a dashboard that happens to - // accept commands; the grid is the window, and the telemetry is there for - // whoever wants it. - let left = if rail_open { LEFT_RAIL_W } else { 0 }; - let l = compute(fb.width, fb.height, &chrome, Rails { left }); + let (l, m, chrome) = geometry(fb, font_scale, rail_open); let text_x = l.body.x + chrome.text_left; let text_r = (l.body.x + l.body.w).saturating_sub(chrome.text_left); - let alt = state.scrollback.grid.alternate; - if alt { - draw_grid(&state.scrollback.grid, fb, text_x, l.body.y, l.footer.y, text_r, m, t); - draw_grid_cursor(&state.scrollback.grid, fb, text_x, l.body.y, m, t); + let vt = &state.scrollback.vt; + /* + * A program in the foreground, or on the alternate screen, owns the + * whole body and is drawn as its screen; the prompt returns at its end. + */ + let owned = vt.alt_active() || state.fg_running; + let bottom = if owned { l.footer.y } else { l.input.y }; + let area = Area { x: text_x, y: l.body.y, max_x: text_r, max_y: bottom }; + let f = Frame { vt, area, m, t }; + if owned { + draw_vt(&f, fb, Rows::Screen, state.shade()); } else if state.fresh { draw_fetch(state, fb, text_x, l.body.y, text_r, t); } else { - draw_block_chrome(state, fb, text_x, l.body.y, l.input.y, text_r, &m, t); - draw_grid(&state.scrollback.grid, fb, text_x, l.body.y, l.input.y, text_r, m, t); + let rows = Rows::Shell { rows: (l.body.h / m.lh.max(1)) as usize, back: vt.view_offset() }; + draw_block_chrome(state, fb, &f.area, rows.first(vt), &m, t); + draw_vt(&f, fb, rows, state.shade()); } - if !alt { + if let Some(find) = &state.find { + draw_find_bar(find, fb, l.input, m, t); + } else if !owned { draw_input_line(state, fb, l.input, m, t); } draw_footer(fb, t); diff --git a/userland/capsule_terminal/src/paint/draw_grid.rs b/userland/capsule_terminal/src/paint/draw_grid.rs deleted file mode 100644 index a9c20f8c5a..0000000000 --- a/userland/capsule_terminal/src/paint/draw_grid.rs +++ /dev/null @@ -1,95 +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 . - -//! Draw the character grid with crisp monospace TrueType glyphs. Each cell is -//! laid out on the measured advance and painted with `text_ttf_mono`, so the -//! body reads like a real terminal rather than a scaled bitmap. - -use nonos_app_skeleton::PaintBuffer; - -use super::metrics::Metrics; -use crate::term::dimensions::{COLS, VISIBLE_ROWS}; -use crate::term::grid::cell::{F_REVERSE, F_WIDE_TAIL}; -use crate::term::grid::types::Grid; -use crate::term::theme::types::Theme; -use crate::term::vt::color::DEFAULT_BG; - -fn glyph(fb: &mut PaintBuffer, x: u32, y: u32, ch: char, argb: u32, px: f32) { - // Blanks and control characters have no glyph. Everything else is handed - // to the face, which covers far more than ASCII and draws .notdef for - // what it does not have, so an unmapped character is visibly missing - // rather than silently absent. - if ch == ' ' || (ch as u32) < 0x20 || ch as u32 == 0x7f { - return; - } - let mut buf = [0u8; 4]; - let s = ch.encode_utf8(&mut buf); - let _ = fb.text_ttf_mono(x as i32, y as i32, s, argb, px); -} - -pub fn draw_grid_cursor(g: &Grid, fb: &mut PaintBuffer, ox: u32, oy: u32, m: Metrics, t: &Theme) { - if !g.cursor_visible { - return; - } - let x = ox + g.x as u32 * m.adv; - let y = oy + g.y as u32 * m.lh; - fb.fill_rect(x, y, m.adv, m.lh, t.accent); - glyph(fb, x, y, g.cells[Grid::idx(g.x, g.y)].ch, t.bg, m.px); -} - -pub fn draw_grid( - g: &Grid, - fb: &mut PaintBuffer, - ox: u32, - oy: u32, - max_y: u32, - max_x: u32, - m: Metrics, - t: &Theme, -) { - for row in 0..VISIBLE_ROWS { - let y = crate::layout::row_top(row as u32, oy, m.lh); - if y + m.lh > max_y { - break; - } - let rowcells = g.visible_row(row); - for (col, cell) in rowcells.iter().enumerate().take(COLS) { - let x = ox + col as u32 * m.adv; - if x + m.adv > max_x { - break; - } - let has_bg = cell.bg != DEFAULT_BG; - let mut fg = cell.fg; - // A default (transparent) background resolves to the terminal - // backdrop when it needs to become a visible colour, so reverse - // video and explicit fills both read correctly. - let mut bg = if has_bg { cell.bg } else { t.bg }; - let reverse = cell.flags & F_REVERSE != 0; - if reverse { - core::mem::swap(&mut fg, &mut bg); - } - if has_bg || reverse { - fb.fill_rect(x, y, m.adv, m.lh, bg); - } - // The right half of a wide character carries the background and - // nothing else. Its glyph was drawn by the cell before it, which - // had both columns to draw into. - if cell.flags & F_WIDE_TAIL == 0 { - glyph(fb, x, y, cell.ch, fg, m.px); - } - } - } -} diff --git a/userland/capsule_terminal/src/paint/geometry.rs b/userland/capsule_terminal/src/paint/geometry.rs new file mode 100644 index 0000000000..cc5f3005b7 --- /dev/null +++ b/userland/capsule_terminal/src/paint/geometry.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 . + +//! Where the body and its cells fall in a window of a given size and zoom, +//! for the painter and for sizing the screen before painting. + +use nonos_app_skeleton::PaintBuffer; + +use super::constants::{BODY_PAD_TOP, HEADER_H, TEXT_LEFT}; +use super::footer::footer_h; +use super::metrics::Metrics; +use crate::layout::limits::LEFT_RAIL_W; +use crate::layout::{compute, Chrome, Layout, Rails}; + +pub fn geometry(fb: &PaintBuffer, font_scale: u32, rail_open: bool) -> (Layout, Metrics, Chrome) { + let m = Metrics::new(fb, font_scale); + let chrome = Chrome { + titlebar_h: HEADER_H, + tabstrip_h: 0, + body_pad_top: BODY_PAD_TOP, + footer_h: footer_h(), + text_left: TEXT_LEFT, + row_h: m.lh, + }; + /* + * The rail is off unless it was asked for. A terminal that opens with a + * quarter of the window given to charts is a dashboard that happens to + * accept commands; the grid is the window, and the telemetry is there for + * whoever wants it. + */ + let left = if rail_open { LEFT_RAIL_W } else { 0 }; + (compute(fb.width, fb.height, &chrome, Rails { left }), m, chrome) +} + +/// The cells the body holds at this window size and zoom: columns between +/// the text margins, rows over the body and the prompt row together, which +/// a program in the foreground draws on. +pub fn grid_size(fb: &PaintBuffer, font_scale: u32, rail_open: bool) -> (usize, usize, Metrics) { + let (l, m, chrome) = geometry(fb, font_scale, rail_open); + let width = l.body.w.saturating_sub(2 * chrome.text_left); + let cols = (width / m.adv.max(1)) as usize; + let rows = ((l.body.h + l.input.h) / m.lh.max(1)) as usize; + (cols.max(2), rows.max(1), m) +} diff --git a/userland/capsule_terminal/src/paint/line_text.rs b/userland/capsule_terminal/src/paint/line_text.rs index bee5f5b85e..3e0a0d98a4 100644 --- a/userland/capsule_terminal/src/paint/line_text.rs +++ b/userland/capsule_terminal/src/paint/line_text.rs @@ -20,8 +20,8 @@ use nonos_app_skeleton::PaintBuffer; use super::line_chars::chars_of; use super::syntax::Part; -use crate::term::grid::width::char_width; use crate::term::theme::types::Theme; +use nonos_vt::width::width as char_width; // Draw text as crisp monospace, advancing by the columns each character // occupies. What is typed has to render the same as what the grid shows, or a diff --git a/userland/capsule_terminal/src/paint/mod.rs b/userland/capsule_terminal/src/paint/mod.rs index 4cea5556b4..b3bd880dea 100644 --- a/userland/capsule_terminal/src/paint/mod.rs +++ b/userland/capsule_terminal/src/paint/mod.rs @@ -19,7 +19,6 @@ mod block_meta; mod compose; mod constants; mod draw_cursor; -mod draw_grid; mod draw_input_line; mod fetch; pub(crate) mod fetch_banner; @@ -28,11 +27,12 @@ mod fetch_uptime; mod fetch_version; mod fit_text; mod footer; +mod geometry; mod header; mod line_chars; mod line_text; mod line_window; -mod metrics; +pub(crate) mod metrics; mod palette; mod palette_row; mod prompt; @@ -65,6 +65,10 @@ pub mod tab_pill; pub mod tokens; mod tool_icon; pub mod toolbar; +mod vt; pub use compose::paint_tabs; +pub use constants::TEXT_LEFT; +pub use geometry::grid_size; pub use tab_bar::draw_tab_bar; +pub use vt::{Rows, Shade}; 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/paint/vt/area.rs b/userland/capsule_terminal/src/paint/vt/area.rs new file mode 100644 index 0000000000..c3fd438269 --- /dev/null +++ b/userland/capsule_terminal/src/paint/vt/area.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 body is drawn, with what, and what is shaded over it. + +use nonos_vt::{Pos, Term}; + +use crate::paint::metrics::Metrics; +use crate::term::select::Selection; +use crate::term::theme::types::Theme; + +pub const OPAQUE: u32 = 0xFF00_0000; + +/// What is shaded over the text. +#[derive(Clone, Copy, Default)] +pub struct Shade { + pub selection: Option, + pub found: Option<(Pos, Pos)>, +} + +/// The rectangle the body may draw in. +pub struct Area { + pub x: u32, + pub y: u32, + pub max_x: u32, + pub max_y: u32, +} + +/// One frame's drawing context. +pub struct Frame<'a> { + pub vt: &'a Term, + pub area: Area, + pub m: Metrics, + pub t: &'a Theme, +} diff --git a/userland/capsule_terminal/src/paint/vt/block.rs b/userland/capsule_terminal/src/paint/vt/block.rs new file mode 100644 index 0000000000..715db6e47f --- /dev/null +++ b/userland/capsule_terminal/src/paint/vt/block.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 . + +//! Block elements filled as exact parts of the cell. + +use nonos_app_skeleton::PaintBuffer; + +/// Fill the part of the cell at `(x, y)`, sized `w` by `h`, that the block +/// element `ch` covers. False for any other character, so the caller draws +/// the glyph instead. +pub fn fill(fb: &mut PaintBuffer, ch: char, x: u32, y: u32, w: u32, h: u32, argb: u32) -> bool { + /* + * The face's glyphs miss the cell's edges by a pixel, which left a line + * of the other colour between rows of half blocks. Each element is its + * span of the cell in eighths: left, top, right, bottom. + */ + let n = ch as u32; + let (l, t, r, b) = match n { + 0x2580 => (0, 0, 8, 4), + 0x2581..=0x2588 => (0, 0x2588 - n, 8, 8), + 0x2589..=0x258F => (0, 0, 0x2590 - n, 8), + 0x2590 => (4, 0, 8, 8), + 0x2594 => (0, 0, 8, 1), + 0x2595 => (7, 0, 8, 8), + 0x2596..=0x259F => return quadrants(fb, n, x, y, w, h, argb), + _ => return false, + }; + part(fb, (x, y, w, h), (l, t, r, b), argb); + true +} + +/* Quadrant bits: upper left 1, upper right 2, lower left 4, lower right 8. */ +const QUADRANTS: [u8; 10] = [4, 8, 1, 13, 9, 7, 11, 2, 6, 14]; + +fn quadrants(fb: &mut PaintBuffer, n: u32, x: u32, y: u32, w: u32, h: u32, argb: u32) -> bool { + let bits = QUADRANTS[(n - 0x2596) as usize]; + for (bit, span) in [(1, (0, 0, 4, 4)), (2, (4, 0, 8, 4)), (4, (0, 4, 4, 8)), (8, (4, 4, 8, 8))] + { + if bits & bit != 0 { + part(fb, (x, y, w, h), span, argb); + } + } + true +} + +fn part(fb: &mut PaintBuffer, cell: (u32, u32, u32, u32), span: (u32, u32, u32, u32), argb: u32) { + let (x, y, w, h) = cell; + let (l, t, r, b) = span; + let (x0, x1) = (x + w * l / 8, x + w * r / 8); + let (y0, y1) = (y + h * t / 8, y + h * b / 8); + fb.fill_rect(x0, y0, x1 - x0, y1 - y0, argb); +} diff --git a/userland/capsule_terminal/src/paint/vt/body.rs b/userland/capsule_terminal/src/paint/vt/body.rs new file mode 100644 index 0000000000..fec455270c --- /dev/null +++ b/userland/capsule_terminal/src/paint/vt/body.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 body's rows: colours after every attribute, wide characters across +//! two cells, and the selection and search match shaded. + +use nonos_app_skeleton::PaintBuffer; +use nonos_vt::Line; + +use super::area::{Frame, Shade}; +use super::cell::text; +use super::cursor::draw_cursor; +use super::deco::decorations; +use super::fill::background; +use super::rows::Rows; + +pub fn draw_vt(f: &Frame, fb: &mut PaintBuffer, rows: Rows, shade: Shade) { + let top = rows.first(f.vt); + for i in 0.. { + let y = crate::layout::row_top(i as u32, f.area.y, f.m.lh); + if y + f.m.lh > f.area.max_y { + break; + } + let Some((abs, line)) = rows.get(f.vt, top, i) else { break }; + draw_row(f, fb, line, abs, y, shade); + draw_cursor(f, fb, rows, abs, y); + } +} + +fn draw_row(f: &Frame, fb: &mut PaintBuffer, line: &Line, abs: u64, y: u32, shade: Shade) { + let m = f.m; + for col in 0..f.vt.cols() { + let x = f.area.x + col as u32 * m.adv; + if x + m.adv > f.area.max_x { + break; + } + let cell = line.cell(col); + let (fg, bg) = f.vt.cell_colors(&cell); + if let Some(bg) = background(f, &cell, bg, abs, col, shade) { + fb.fill_rect(x, y, m.adv, m.lh, bg); + } + /* + * The right half of a wide character carries only its background; + * the head drew the glyph across both cells. + */ + if !cell.is_tail() { + let w = if cell.is_wide() { 2 * m.adv } else { m.adv }; + text(fb, line, &cell, x, y, fg, m); + decorations(fb, &cell, x, y, w, fg, m); + } + } +} diff --git a/userland/capsule_terminal/src/paint/vt/box_arms.rs b/userland/capsule_terminal/src/paint/vt/box_arms.rs new file mode 100644 index 0000000000..7d2bf05e87 --- /dev/null +++ b/userland/capsule_terminal/src/paint/vt/box_arms.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 . + +/* + * Which arms a box-drawing character has, and how heavy each is. Tables + * that tools print with these characters only join up when the lines run + * to the edges of the cell, which a font's glyph does not: the line gap + * leaves a break between rows. + */ + +/// An arm's weight: none, light, heavy or double. +pub const NONE: u8 = 0; +pub const LIGHT: u8 = 1; +pub const HEAVY: u8 = 2; +pub const DOUBLE: u8 = 3; + +/// Up, down, left and right arms of `ch`, or None when it is not one of +/// the line characters drawn here. +pub fn arms(ch: char) -> Option<[u8; 4]> { + let (l, h, d) = (LIGHT, HEAVY, DOUBLE); + Some(match ch { + '─' => [0, 0, l, l], + '━' => [0, 0, h, h], + '│' => [l, l, 0, 0], + '┃' => [h, h, 0, 0], + '┌' | '╭' => [0, l, 0, l], + '┐' | '╮' => [0, l, l, 0], + '└' | '╰' => [l, 0, 0, l], + '┘' | '╯' => [l, 0, l, 0], + '├' => [l, l, 0, l], + '┤' => [l, l, l, 0], + '┬' => [0, l, l, l], + '┴' => [l, 0, l, l], + '┼' => [l, l, l, l], + '┏' => [0, h, 0, h], + '┓' => [0, h, h, 0], + '┗' => [h, 0, 0, h], + '┛' => [h, 0, h, 0], + '┣' => [h, h, 0, h], + '┫' => [h, h, h, 0], + '┳' => [0, h, h, h], + '┻' => [h, 0, h, h], + '╋' => [h, h, h, h], + '═' => [0, 0, d, d], + '║' => [d, d, 0, 0], + '╔' => [0, d, 0, d], + '╗' => [0, d, d, 0], + '╚' => [d, 0, 0, d], + '╝' => [d, 0, d, 0], + '╠' => [d, d, 0, d], + '╣' => [d, d, d, 0], + '╦' => [0, d, d, d], + '╩' => [d, 0, d, d], + '╬' => [d, d, d, d], + _ => return None, + }) +} diff --git a/userland/capsule_terminal/src/paint/vt/box_draw.rs b/userland/capsule_terminal/src/paint/vt/box_draw.rs new file mode 100644 index 0000000000..86a7669f5f --- /dev/null +++ b/userland/capsule_terminal/src/paint/vt/box_draw.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 . + +//! Box-drawing characters stroked across the whole cell. + +use nonos_app_skeleton::PaintBuffer; + +use super::box_arms::{arms, DOUBLE, HEAVY, NONE}; + +/// Stroke `ch` over the cell at `(x, y)` sized `w` by `h`. False when it +/// is not a line character, so the caller draws the glyph instead. +pub fn stroke(fb: &mut PaintBuffer, ch: char, x: u32, y: u32, w: u32, h: u32, argb: u32) -> bool { + let Some([up, down, left, right]) = arms(ch) else { return false }; + let t = (w / 8).max(1); + let (cx, cy) = (x + w / 2, y + h / 2); + for (arm, across, first) in + [(up, false, true), (down, false, false), (left, true, true), (right, true, false)] + { + if arm == NONE { + continue; + } + let k = if arm == HEAVY { 2 * t } else { t }; + /* + * An arm runs from the cell's edge to just past the centre line, by + * half its own width, so the arms of a cross meet with no notch. A + * double arm is two strokes either side of the centre. + */ + let gap = if arm == DOUBLE { t + 1 } else { 0 }; + let past = k / 2 + gap + k % 2; + let sides: &[i32] = if arm == DOUBLE { &[-1, 1] } else { &[0] }; + for &side in sides { + let lane = |c: u32| (c as i32 + side * gap as i32) as u32 - k / 2; + if across { + let (a, b) = if first { (x, cx + past) } else { (cx - past, x + w) }; + fb.fill_rect(a, lane(cy), b - a, k, argb); + } else { + let (a, b) = if first { (y, cy + past) } else { (cy - past, y + h) }; + fb.fill_rect(lane(cx), a, k, b - a, argb); + } + } + } + true +} diff --git a/userland/capsule_terminal/src/paint/vt/cell.rs b/userland/capsule_terminal/src/paint/vt/cell.rs new file mode 100644 index 0000000000..e9373fcab6 --- /dev/null +++ b/userland/capsule_terminal/src/paint/vt/cell.rs @@ -0,0 +1,59 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! A cell's glyph and the marks drawn over it. + +use nonos_app_skeleton::PaintBuffer; +use nonos_vt::cell::attr; +use nonos_vt::{Cell, Line}; + +use super::area::OPAQUE; +use crate::paint::metrics::Metrics; + +pub fn glyph(fb: &mut PaintBuffer, x: u32, y: u32, ch: char, argb: u32, px: f32) { + /* + * Blanks and controls have no glyph. Anything else goes to the face, + * which draws .notdef for what it lacks, so a missing character shows. + */ + if ch == ' ' || ch == '\u{a0}' || ch.is_control() { + return; + } + let mut buf = [0u8; 4]; + let _ = fb.text_ttf_mono(x as i32, y as i32, ch.encode_utf8(&mut buf), argb, px); +} + +/// Draw the text of `cell` at `(x, y)` in `fg`. Bold is drawn twice a pixel +/// apart: the face has no bold weight. It has no italic either, so italic +/// text is drawn upright. +pub fn text(fb: &mut PaintBuffer, line: &Line, cell: &Cell, x: u32, y: u32, fg: u32, m: Metrics) { + let fg = OPAQUE | fg; + // Line characters run to the cell's edges so tables join between rows. + if super::box_draw::stroke(fb, cell.ch, x, y, m.adv, m.lh, fg) { + return; + } + if super::block::fill(fb, cell.ch, x, y, m.adv, m.lh, fg) { + return; + } + glyph(fb, x, y, cell.ch, fg, m.px); + if cell.attr & attr::BOLD != 0 { + glyph(fb, x + 1, y, cell.ch, fg, m.px); + } + if let Some(marks) = line.marks_of(cell) { + for c in marks.chars() { + glyph(fb, x, y, c, fg, m.px); + } + } +} diff --git a/userland/capsule_terminal/src/paint/vt/cursor.rs b/userland/capsule_terminal/src/paint/vt/cursor.rs new file mode 100644 index 0000000000..8d059129c9 --- /dev/null +++ b/userland/capsule_terminal/src/paint/vt/cursor.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 cursor, in the shape the program set: block, underline or bar. + +use nonos_app_skeleton::PaintBuffer; +use nonos_vt::CursorShape; + +use super::area::{Frame, OPAQUE}; +use super::cell::glyph; +use super::rows::Rows; + +/// Draw the cursor if it is on the row at `abs`, drawn at `y`. The shell +/// view draws its own cursor in the prompt instead. +pub fn draw_cursor(f: &Frame, fb: &mut PaintBuffer, rows: Rows, abs: u64, y: u32) { + let c = f.vt.cursor(); + if !matches!(rows, Rows::Screen) || !c.visible || f.vt.abs_of_row(c.y) != abs { + return; + } + let (m, x) = (f.m, f.area.x + c.x as u32 * f.m.adv); + if x + m.adv > f.area.max_x { + return; + } + let colour = OPAQUE | f.vt.palette().cursor; + match c.shape { + CursorShape::Block => { + fb.fill_rect(x, y, m.adv, m.lh, colour); + let under = f.vt.visible_line(c.y).cell(c.x); + glyph(fb, x, y, under.ch, OPAQUE | f.t.bg, m.px); + } + CursorShape::Underline => fb.fill_rect(x, y + m.lh.saturating_sub(2), m.adv, 2, colour), + CursorShape::Bar => fb.fill_rect(x, y, 2, m.lh, colour), + } +} diff --git a/userland/capsule_terminal/src/paint/vt/deco.rs b/userland/capsule_terminal/src/paint/vt/deco.rs new file mode 100644 index 0000000000..761305bc89 --- /dev/null +++ b/userland/capsule_terminal/src/paint/vt/deco.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 . + +//! Lines drawn across a cell: the underline styles, strikethrough and +//! overline. + +use nonos_app_skeleton::PaintBuffer; +use nonos_vt::cell::attr; +use nonos_vt::{Cell, Underline}; + +use super::area::OPAQUE; +use crate::paint::metrics::Metrics; + +fn curly(fb: &mut PaintBuffer, x: u32, base: u32, w: u32, fg: u32) { + let pts: alloc::vec::Vec<(i32, i32)> = (0..=w / 2) + .map(|i| { + let py = if i % 2 == 0 { base.saturating_sub(1) } else { base + 1 }; + ((x + 2 * i) as i32, py as i32) + }) + .collect(); + fb.polyline(&pts, fg); +} + +pub fn decorations(fb: &mut PaintBuffer, cell: &Cell, x: u32, y: u32, w: u32, fg: u32, m: Metrics) { + let fg = OPAQUE | fg; + let base = y + m.lh.saturating_sub(3); + match cell.underline() { + Underline::None => {} + Underline::Single => fb.fill_rect(x, base, w, 1, fg), + Underline::Double => { + fb.fill_rect(x, base.saturating_sub(2), w, 1, fg); + fb.fill_rect(x, base, w, 1, fg); + } + Underline::Curly => curly(fb, x, base, w, fg), + Underline::Dotted => (0..w).step_by(2).for_each(|i| fb.fill_rect(x + i, base, 1, 1, fg)), + Underline::Dashed => { + (0..w).step_by(5).for_each(|i| fb.fill_rect(x + i, base, 3.min(w - i), 1, fg)) + } + } + if cell.attr & attr::STRIKE != 0 { + fb.fill_rect(x, y + m.lh / 2, w, 1, fg); + } + if cell.attr & attr::OVERLINE != 0 { + fb.fill_rect(x, y + 1, w, 1, fg); + } +} diff --git a/userland/capsule_terminal/src/paint/vt/fill.rs b/userland/capsule_terminal/src/paint/vt/fill.rs new file mode 100644 index 0000000000..be519d7ba6 --- /dev/null +++ b/userland/capsule_terminal/src/paint/vt/fill.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 fill behind a cell: the selection, a search match, its own +//! background, or none where the theme's backdrop shows through. + +use nonos_toolkit::paint::mixer::mix; +use nonos_vt::cell::attr; +use nonos_vt::{Cell, Color}; + +use super::area::{Frame, Shade, OPAQUE}; +use crate::term::select::in_span; + +/// The fill behind a cell, or none where the theme's backdrop shows. +pub(super) fn background( + f: &Frame, + cell: &Cell, + bg: u32, + abs: u64, + col: usize, + shade: Shade, +) -> Option { + if shade.selection.is_some_and(|s| s.contains(abs, col)) { + return Some(OPAQUE | mix(f.t.bg, f.t.accent, 90)); + } + if in_span(shade.found, abs, col) { + return Some(OPAQUE | mix(f.t.bg, f.t.run, 150)); + } + let plain = cell.bg == Color::Default && cell.attr & attr::INVERSE == 0; + if plain && !f.vt.modes.reverse_video { + return None; + } + Some(OPAQUE | bg) +} diff --git a/userland/capsule_terminal/src/paint/vt/find_bar.rs b/userland/capsule_terminal/src/paint/vt/find_bar.rs new file mode 100644 index 0000000000..5b489699d7 --- /dev/null +++ b/userland/capsule_terminal/src/paint/vt/find_bar.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 search bar, drawn where the prompt goes while a search is open. + +use nonos_app_skeleton::PaintBuffer; + +use crate::layout::Rect; +use crate::paint::constants::TEXT_LEFT; +use crate::paint::metrics::Metrics; +use crate::paint::shade::elevate; +use crate::term::select::Find; +use crate::term::theme::types::Theme; + +pub fn draw_find_bar(f: &Find, fb: &mut PaintBuffer, r: Rect, m: Metrics, t: &Theme) { + let bar_x = r.x + TEXT_LEFT / 2; + let bar_y = r.y.saturating_sub(3); + fb.fill_rect(bar_x, bar_y, r.w.saturating_sub(TEXT_LEFT), m.lh + 4, elevate(t.bg, 12)); + fb.fill_rect(bar_x, bar_y, 2, m.lh + 4, t.run); + let x = (r.x + TEXT_LEFT) as i32; + let label = "find "; + let _ = fb.text_ttf_mono(x, r.y as i32, label, t.dim, m.px); + let qx = x + (label.len() as u32 * m.adv) as i32; + let _ = fb.text_ttf_mono(qx, r.y as i32, &f.query, t.fg, m.px); + let state = match (f.query.is_empty(), f.hit.is_some()) { + (true, _) => "type to search history", + (false, true) => "Enter older, Shift+Enter newer, Esc close", + (false, false) => "no match", + }; + let case = if f.case { " case" } else { "" }; + let hint_x = qx + ((f.query.chars().count() as u32 + 3) * m.adv) as i32; + let _ = fb.text_ttf_mono(hint_x, r.y as i32, state, t.dim, m.px); + if !case.is_empty() { + let cx = hint_x + (state.len() as u32 * m.adv) as i32; + let _ = fb.text_ttf_mono(cx, r.y as i32, case, t.accent, m.px); + } +} diff --git a/userland/capsule_terminal/src/paint/vt/mod.rs b/userland/capsule_terminal/src/paint/vt/mod.rs new file mode 100644 index 0000000000..5d99d5b4ec --- /dev/null +++ b/userland/capsule_terminal/src/paint/vt/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 . + +//! Drawing the terminal body from the screen and its history. + +mod area; +mod block; +mod body; +mod box_arms; +mod box_draw; +mod cell; +mod cursor; +mod deco; +mod fill; +mod find_bar; +mod rows; + +pub use area::{Area, Frame, Shade}; +pub use body::draw_vt; +pub use find_bar::draw_find_bar; +pub use rows::Rows; diff --git a/userland/capsule_terminal/src/paint/vt/rows.rs b/userland/capsule_terminal/src/paint/vt/rows.rs new file mode 100644 index 0000000000..1bcbd7cf0e --- /dev/null +++ b/userland/capsule_terminal/src/paint/vt/rows.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 . + +//! Which lines fill the body. The shell draws its history ending at the +//! last line written, so output sits just above the prompt. A program in +//! the foreground, or on the alternate screen, owns the whole body and is +//! drawn as its screen. + +use nonos_vt::{Line, Term}; + +#[derive(Clone, Copy)] +pub enum Rows { + /// Screen rows `0..n`, as the program laid them out. + Screen, + /// `n` lines of history ending at the last line written, `back` lines + /// back into history. + Shell { rows: usize, back: usize }, +} + +impl Rows { + /// The absolute line of the first row drawn. + pub fn first(&self, vt: &Term) -> u64 { + match *self { + Rows::Screen => vt.abs_of_row(0), + Rows::Shell { rows, back } => { + let cur = vt.cursor_pos(); + let written = vt.line_at(cur.line).is_some_and(|l| l.content_len() > 0); + let last = + if cur.col > 0 || written { cur.line } else { cur.line.saturating_sub(1) }; + let top = (last + 1).saturating_sub(rows as u64).saturating_sub(back as u64); + top.max(vt.first_line()) + } + } + } + + /// Row `i` of the body, given `top` from `first`: its absolute line + /// and the line itself. + pub fn get<'a>(&self, vt: &'a Term, top: u64, i: usize) -> Option<(u64, &'a Line)> { + match self { + Rows::Screen => (i < vt.rows()).then(|| (vt.abs_of_row(i), vt.visible_line(i))), + Rows::Shell { .. } => { + let abs = top + i as u64; + let cur = vt.cursor_pos().line; + if abs > cur { + return None; + } + vt.line_at(abs).map(|l| (abs, l)) + } + } + } +} diff --git a/userland/capsule_terminal/src/term/block/ops.rs b/userland/capsule_terminal/src/term/block/ops.rs index 9cd23c1fa6..db26f37836 100644 --- a/userland/capsule_terminal/src/term/block/ops.rs +++ b/userland/capsule_terminal/src/term/block/ops.rs @@ -21,7 +21,7 @@ const MAX_BLOCKS: usize = 256; impl State { pub fn open_block(&mut self, ts: [u8; 8]) { - let start_abs = self.scrollback.grid.current_abs_line(); + let start_abs = self.scrollback.vt.cursor_pos().line; self.blocks.push(Block { start_abs, ts, status: Status::Running, dur_ms: 0 }); if self.blocks.len() > MAX_BLOCKS { self.blocks.remove(0); @@ -36,7 +36,7 @@ impl State { } pub fn evict_blocks(&mut self) { - let base = self.scrollback.grid.abs_base(); + let base = self.scrollback.vt.first_line(); let keep = self.blocks.iter().position(|b| b.start_abs >= base).unwrap_or(self.blocks.len()); if keep > 1 { diff --git a/userland/capsule_terminal/src/term/dimensions.rs b/userland/capsule_terminal/src/term/dimensions.rs index 831a217656..1f154b5a7b 100644 --- a/userland/capsule_terminal/src/term/dimensions.rs +++ b/userland/capsule_terminal/src/term/dimensions.rs @@ -15,7 +15,11 @@ // along with this program. If not, see . pub const COLS: usize = 96; -pub const SCROLLBACK_ROWS: usize = 256; +/// The longest command line the shell takes. Wider than any screen: a +/// command wraps on screen, it is not cut off. +pub const LINE_MAX: usize = 1024; +/// History lines each tab keeps. +pub const SCROLLBACK_ROWS: usize = 3000; pub const VISIBLE_ROWS: usize = 40; pub const HISTORY_DEPTH: usize = 32; pub const MIN_FONT_SCALE: u32 = 1; diff --git a/userland/capsule_terminal/src/term/grid/erase.rs b/userland/capsule_terminal/src/term/grid/erase.rs deleted file mode 100644 index 4bf7713d3c..0000000000 --- a/userland/capsule_terminal/src/term/grid/erase.rs +++ /dev/null @@ -1,69 +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 crate::term::dimensions::{COLS, VISIBLE_ROWS}; -use crate::term::grid::types::Grid; - -impl Grid { - pub fn erase_line(&mut self, mode: u8) { - let blank = self.blank_cell(); - let (start, end) = match mode { - 0 => (self.x, COLS), - 1 => (0, self.x), - _ => (0, COLS), - }; - for x in start..end { - self.cells[Grid::idx(x, self.y)] = blank; - } - } - pub fn erase_display(&mut self, mode: u8) { - let blank = self.blank_cell(); - match mode { - 0 => { - for x in self.x..COLS { - self.cells[Grid::idx(x, self.y)] = blank; - } - for y in (self.y + 1)..VISIBLE_ROWS { - for x in 0..COLS { - self.cells[Grid::idx(x, y)] = blank; - } - } - } - 1 => { - for y in 0..self.y { - for x in 0..COLS { - self.cells[Grid::idx(x, y)] = blank; - } - } - for x in 0..self.x { - self.cells[Grid::idx(x, self.y)] = blank; - } - } - _ => { - self.clear(); - } - } - } - pub fn clear(&mut self) { - let blank = self.blank_cell(); - for i in 0..self.cells.len() { - self.cells[i] = blank; - } - self.x = 0; - self.y = 0; - self.view_offset = 0; - } -} diff --git a/userland/capsule_terminal/src/term/grid/new.rs b/userland/capsule_terminal/src/term/grid/new.rs deleted file mode 100644 index 32114f7b49..0000000000 --- a/userland/capsule_terminal/src/term/grid/new.rs +++ /dev/null @@ -1,49 +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 crate::term::dimensions::{COLS, SCROLLBACK_ROWS, VISIBLE_ROWS}; -use crate::term::grid::cell::Cell; -use crate::term::grid::types::Grid; -use crate::term::vt::color::{DEFAULT_BG, DEFAULT_FG}; -use crate::term::vt::parser::Parser; -use crate::term::vt::utf8::Utf8; - -impl Grid { - pub fn new() -> Grid { - Grid { - cells: vec![Cell::blank(); COLS * VISIBLE_ROWS], - alt: vec![Cell::blank(); COLS * VISIBLE_ROWS], - history: vec![Cell::blank(); COLS * SCROLLBACK_ROWS], - hist_head: 0, - hist_count: 0, - view_offset: 0, - alternate: false, - cursor_visible: true, - x: 0, - y: 0, - fg: DEFAULT_FG, - bg: DEFAULT_BG, - flags: 0, - parser: Parser::new(), - utf8: Utf8::default(), - total_scrolled: 0, - scroll_top: 0, - scroll_bot: VISIBLE_ROWS - 1, - } - } -} diff --git a/userland/capsule_terminal/src/term/grid/put.rs b/userland/capsule_terminal/src/term/grid/put.rs deleted file mode 100644 index 442df9c315..0000000000 --- a/userland/capsule_terminal/src/term/grid/put.rs +++ /dev/null @@ -1,62 +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 crate::term::dimensions::{COLS, VISIBLE_ROWS}; -use crate::term::grid::cell::{Cell, F_WIDE_TAIL}; -use crate::term::grid::types::Grid; -use crate::term::grid::width::char_width; - -impl Grid { - pub fn blank_cell(&self) -> Cell { - Cell { ch: ' ', fg: self.fg, bg: self.bg, flags: 0 } - } - pub fn put_char(&mut self, c: char) { - let w = char_width(c); - // A character drawn two columns wide cannot straddle the edge, so it - // wraps whole rather than being split across two lines. - if self.x + w > COLS { - self.x = 0; - self.line_feed(); - } - let i = Grid::idx(self.x, self.y); - self.cells[i] = Cell { ch: c, fg: self.fg, bg: self.bg, flags: self.flags }; - if w == 2 { - // The right half holds no glyph. It is still a cell, so that - // erasing, scrolling and background fills treat the pair as the - // two columns it occupies. - let tail = Grid::idx(self.x + 1, self.y); - self.cells[tail] = - Cell { ch: ' ', fg: self.fg, bg: self.bg, flags: self.flags | F_WIDE_TAIL }; - } - self.x += w; - if self.x >= COLS { - self.x = 0; - self.line_feed(); - } - } - pub fn line_feed(&mut self) { - if self.y == self.scroll_bot { - // At the foot of the scroll region the window shifts and the cursor - // stays put; elsewhere it just steps down within the screen. - self.scroll_region_up(); - } else if self.y + 1 < VISIBLE_ROWS { - self.y += 1; - } - } - pub fn carriage_return(&mut self) { - self.x = 0; - } -} diff --git a/userland/capsule_terminal/src/term/grid/scroll_region.rs b/userland/capsule_terminal/src/term/grid/scroll_region.rs deleted file mode 100644 index 8859af5b37..0000000000 --- a/userland/capsule_terminal/src/term/grid/scroll_region.rs +++ /dev/null @@ -1,57 +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 crate::term::dimensions::{COLS, VISIBLE_ROWS}; -use crate::term::grid::types::Grid; - -impl Grid { - /// DECSTBM: set the vertical scrolling region to the inclusive 0-based rows - /// `[top, bot]`. An inverted or out-of-range window resets to the full - /// screen, matching xterm. Per the spec the cursor homes to the top-left of - /// the new region afterwards. - pub fn set_scroll_region(&mut self, top: usize, bot: usize) { - if top < bot && bot < VISIBLE_ROWS { - self.scroll_top = top; - self.scroll_bot = bot; - } else { - self.scroll_top = 0; - self.scroll_bot = VISIBLE_ROWS - 1; - } - self.x = 0; - self.y = self.scroll_top; - } - - /// Scroll the active region up by one line. A full-screen region preserves - /// its top line into scrollback (the normal terminal history path); a - /// partial region simply drops its top line, so a pinned status bar outside - /// the window is left untouched. - pub fn scroll_region_up(&mut self) { - if self.scroll_top == 0 && self.scroll_bot == VISIBLE_ROWS - 1 { - self.scroll_up_one(); - return; - } - let (top, bot) = (self.scroll_top, self.scroll_bot); - for y in top..bot { - let dst = y * COLS; - let src = (y + 1) * COLS; - self.cells.copy_within(src..src + COLS, dst); - } - let blank = self.blank_cell(); - for x in 0..COLS { - self.cells[Grid::idx(x, bot)] = blank; - } - } -} diff --git a/userland/capsule_terminal/src/term/grid/types.rs b/userland/capsule_terminal/src/term/grid/types.rs deleted file mode 100644 index a535c25713..0000000000 --- a/userland/capsule_terminal/src/term/grid/types.rs +++ /dev/null @@ -1,54 +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::Vec; - -use crate::term::dimensions::COLS; -use crate::term::grid::cell::Cell; -use crate::term::vt::parser::Parser; -use crate::term::vt::utf8::Utf8; - -pub struct Grid { - pub cells: Vec, - pub alt: Vec, - pub history: Vec, - pub hist_head: usize, - pub hist_count: usize, - pub view_offset: usize, - pub alternate: bool, - pub cursor_visible: bool, - pub x: usize, - pub y: usize, - pub fg: u32, - pub bg: u32, - pub flags: u8, - pub parser: Parser, - /// Rebuilds characters from the bytes they arrive in. It belongs to the - /// grid because a character can be split across two feeds. - pub utf8: Utf8, - pub total_scrolled: u64, - // DECSTBM vertical scroll region, inclusive 0-based rows. Defaults to the - // whole screen; full-screen TUIs set a smaller window so a status line at - // the top or bottom stays put while the body scrolls. - pub scroll_top: usize, - pub scroll_bot: usize, -} - -impl Grid { - pub fn idx(x: usize, y: usize) -> usize { - y * COLS + x - } -} diff --git a/userland/capsule_terminal/src/term/grid/width.rs b/userland/capsule_terminal/src/term/grid/width.rs deleted file mode 100644 index 3690032397..0000000000 --- a/userland/capsule_terminal/src/term/grid/width.rs +++ /dev/null @@ -1,67 +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 . - -//! How many columns a character takes. -//! -//! A grid assumes every character is one cell wide. That holds for Latin and -//! stops holding the moment anything else arrives: a CJK ideograph or an -//! emoji is drawn two columns wide, and treating it as one puts the next -//! character on top of its right half. Every column after it on that line is -//! then wrong, and so is anything that counts columns to place a cursor. - -/// Ranges drawn two columns wide. -/// -/// Taken from the East Asian Wide and Fullwidth classes, which is where the -/// double width ones live. Ranges rather than a table per codepoint: the -/// blocks are contiguous and a table would be tens of kilobytes to say the -/// same thing. -const WIDE: &[(u32, u32)] = &[ - (0x1100, 0x115F), // Hangul Jamo initial consonants - (0x2E80, 0x303E), // CJK radicals, Kangxi, CJK symbols - (0x3041, 0x33FF), // Kana, Bopomofo, Hangul compatibility, enclosed - (0x3400, 0x4DBF), // CJK unified ideographs extension A - (0x4E00, 0x9FFF), // CJK unified ideographs - (0xA000, 0xA4CF), // Yi syllables and radicals - (0xA960, 0xA97F), // Hangul Jamo extended A - (0xAC00, 0xD7A3), // Hangul syllables - (0xF900, 0xFAFF), // CJK compatibility ideographs - (0xFE10, 0xFE19), // Vertical forms - (0xFE30, 0xFE6F), // CJK compatibility forms, small form variants - (0xFF00, 0xFF60), // Fullwidth forms - (0xFFE0, 0xFFE6), // Fullwidth signs - (0x1F300, 0x1F64F), // Symbols, pictographs, emoticons - (0x1F900, 0x1F9FF), // Supplemental symbols and pictographs - (0x20000, 0x3FFFD), // CJK unified ideographs, later extensions -]; - -/// Columns `ch` occupies. -/// -/// Never zero. Combining marks are the case that would justify zero, and -/// composing them onto the character before is work this grid does not do, -/// so they are given their own cell instead: a decomposed accent shows as a -/// separate mark rather than being silently dropped. -pub fn char_width(ch: char) -> usize { - let cp = ch as u32; - for &(lo, hi) in WIDE { - if cp < lo { - break; - } - if cp <= hi { - return 2; - } - } - 1 -} diff --git a/userland/capsule_terminal/src/term/history/new.rs b/userland/capsule_terminal/src/term/history/new.rs index 57fbd2b9ad..5ff9e2b8d3 100644 --- a/userland/capsule_terminal/src/term/history/new.rs +++ b/userland/capsule_terminal/src/term/history/new.rs @@ -15,12 +15,12 @@ // along with this program. If not, see . use super::types::History; -use crate::term::dimensions::{COLS, HISTORY_DEPTH}; +use crate::term::dimensions::{LINE_MAX, HISTORY_DEPTH}; impl History { pub const fn new() -> Self { Self { - entries: [[0; COLS]; HISTORY_DEPTH], + entries: [[0; LINE_MAX]; HISTORY_DEPTH], lengths: [0; HISTORY_DEPTH], count: 0, cursor: None, diff --git a/userland/capsule_terminal/src/term/history/push.rs b/userland/capsule_terminal/src/term/history/push.rs index 042a4bef21..17cc5e5248 100644 --- a/userland/capsule_terminal/src/term/history/push.rs +++ b/userland/capsule_terminal/src/term/history/push.rs @@ -15,7 +15,7 @@ // along with this program. If not, see . use super::types::History; -use crate::term::dimensions::{COLS, HISTORY_DEPTH}; +use crate::term::dimensions::{LINE_MAX, HISTORY_DEPTH}; impl History { pub fn push(&mut self, line: &[u8]) { @@ -37,7 +37,7 @@ impl History { self.count = HISTORY_DEPTH - 1; } let slot = self.count; - let n = line.len().min(COLS); + let n = line.len().min(LINE_MAX); self.entries[slot][..n].copy_from_slice(&line[..n]); self.lengths[slot] = n; self.count += 1; diff --git a/userland/capsule_terminal/src/term/history/types.rs b/userland/capsule_terminal/src/term/history/types.rs index 16563b38df..b685e96b0f 100644 --- a/userland/capsule_terminal/src/term/history/types.rs +++ b/userland/capsule_terminal/src/term/history/types.rs @@ -14,10 +14,10 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -use crate::term::dimensions::{COLS, HISTORY_DEPTH}; +use crate::term::dimensions::{LINE_MAX, HISTORY_DEPTH}; pub struct History { - pub(super) entries: [[u8; COLS]; HISTORY_DEPTH], + pub(super) entries: [[u8; LINE_MAX]; HISTORY_DEPTH], pub(super) lengths: [usize; HISTORY_DEPTH], pub(super) count: usize, pub(super) cursor: Option, diff --git a/userland/capsule_terminal/src/term/line/delete_word_right.rs b/userland/capsule_terminal/src/term/line/delete_word_right.rs new file mode 100644 index 0000000000..97fee9fa14 --- /dev/null +++ b/userland/capsule_terminal/src/term/line/delete_word_right.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 super::types::Line; + +impl Line { + /* + * Delete the word after the cursor (Alt-D): the run Alt-F would step + * over, word first and then the gap, so the two keys agree. + */ + pub fn delete_word_right(&mut self) -> bool { + let mut end = self.cursor; + while end < self.len && self.buf[end] != b' ' { + end += 1; + } + while end < self.len && self.buf[end] == b' ' { + end += 1; + } + let removed = end - self.cursor; + if removed == 0 { + return false; + } + let mut cut = [0u8; super::types::KILL_CAP]; + cut[..removed].copy_from_slice(&self.buf[self.cursor..end]); + self.hold_killed(&cut[..removed]); + self.buf.copy_within(end..self.len, self.cursor); + self.len -= removed; + true + } +} diff --git a/userland/capsule_terminal/src/term/line/insert.rs b/userland/capsule_terminal/src/term/line/insert.rs index a7c117dc06..f754e9dd5a 100644 --- a/userland/capsule_terminal/src/term/line/insert.rs +++ b/userland/capsule_terminal/src/term/line/insert.rs @@ -15,11 +15,11 @@ // along with this program. If not, see . use super::types::Line; -use crate::term::dimensions::COLS; +use crate::term::dimensions::LINE_MAX; impl Line { pub fn insert(&mut self, byte: u8) -> bool { - if self.len >= COLS { + if self.len >= LINE_MAX { return false; } if self.cursor < self.len { diff --git a/userland/capsule_terminal/src/term/line/mod.rs b/userland/capsule_terminal/src/term/line/mod.rs index 2cb1be106f..93ce43d4a5 100644 --- a/userland/capsule_terminal/src/term/line/mod.rs +++ b/userland/capsule_terminal/src/term/line/mod.rs @@ -19,6 +19,7 @@ mod backspace; mod clear; mod delete; mod delete_word; +mod delete_word_right; mod insert; mod kill_ring; mod kill_to_end; diff --git a/userland/capsule_terminal/src/term/line/new.rs b/userland/capsule_terminal/src/term/line/new.rs index f8f5046ac3..9e8446242e 100644 --- a/userland/capsule_terminal/src/term/line/new.rs +++ b/userland/capsule_terminal/src/term/line/new.rs @@ -15,12 +15,12 @@ // along with this program. If not, see . use super::types::Line; -use crate::term::dimensions::COLS; +use crate::term::dimensions::LINE_MAX; impl Line { pub const fn new() -> Self { Self { - buf: [0; COLS], + buf: [0; LINE_MAX], len: 0, cursor: 0, killed: [0; super::types::KILL_CAP], diff --git a/userland/capsule_terminal/src/term/line/replace.rs b/userland/capsule_terminal/src/term/line/replace.rs index 16500c7410..78236903cb 100644 --- a/userland/capsule_terminal/src/term/line/replace.rs +++ b/userland/capsule_terminal/src/term/line/replace.rs @@ -15,11 +15,11 @@ // along with this program. If not, see . use super::types::Line; -use crate::term::dimensions::COLS; +use crate::term::dimensions::LINE_MAX; impl Line { pub fn replace(&mut self, src: &[u8]) { - let n = src.len().min(COLS); + let n = src.len().min(LINE_MAX); self.buf[..n].copy_from_slice(&src[..n]); self.len = n; self.cursor = n; diff --git a/userland/capsule_terminal/src/term/line/types.rs b/userland/capsule_terminal/src/term/line/types.rs index 71f24f5506..b56841e020 100644 --- a/userland/capsule_terminal/src/term/line/types.rs +++ b/userland/capsule_terminal/src/term/line/types.rs @@ -14,14 +14,14 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -use crate::term::dimensions::COLS; +use crate::term::dimensions::LINE_MAX; -/// How much cut text is held for Ctrl-Y. A line cannot exceed COLS, so a -/// buffer that size can always hold whatever a single kill removed. -pub const KILL_CAP: usize = COLS; +/// How much cut text is held for Ctrl-Y. A line cannot exceed LINE_MAX, so +/// a buffer that size can always hold whatever a single kill removed. +pub const KILL_CAP: usize = LINE_MAX; pub struct Line { - pub buf: [u8; COLS], + pub buf: [u8; LINE_MAX], pub len: usize, pub cursor: usize, /// The last text a kill key removed, waiting to be yanked back. 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/mod.rs b/userland/capsule_terminal/src/term/mod.rs index 2270200503..3fe2b1cf1c 100644 --- a/userland/capsule_terminal/src/term/mod.rs +++ b/userland/capsule_terminal/src/term/mod.rs @@ -20,7 +20,6 @@ pub mod context; pub mod cwd; pub mod dimensions; pub mod dur; -pub mod grid; pub mod history; pub mod identity; pub mod line; @@ -30,10 +29,10 @@ pub mod prompt; pub mod rtc; pub mod scrollback; pub mod search; +pub mod select; pub mod state; pub mod terminal; pub mod theme; pub mod util; -pub mod vt; pub use terminal::Terminal; 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_terminal/src/term/scrollback/clear.rs b/userland/capsule_terminal/src/term/scrollback/clear.rs index c995a26bf2..df8cadfe47 100644 --- a/userland/capsule_terminal/src/term/scrollback/clear.rs +++ b/userland/capsule_terminal/src/term/scrollback/clear.rs @@ -17,7 +17,8 @@ use super::types::Scrollback; impl Scrollback { + /// Blank the screen and forget its history, cursor home. pub fn clear(&mut self) { - self.grid.clear(); + self.vt.feed(b"\x1b[0m\x1b[H\x1b[2J\x1b[3J"); } } diff --git a/userland/capsule_terminal/src/term/scrollback/feed_raw.rs b/userland/capsule_terminal/src/term/scrollback/feed_raw.rs index d5e5ec1599..f930dfb068 100644 --- a/userland/capsule_terminal/src/term/scrollback/feed_raw.rs +++ b/userland/capsule_terminal/src/term/scrollback/feed_raw.rs @@ -17,7 +17,18 @@ use super::types::Scrollback; impl Scrollback { + /// Bytes a program or the shell wrote, through output processing. pub fn feed_raw(&mut self, bytes: &[u8]) { - self.grid.feed(bytes); + if !self.onlcr { + self.vt.feed(bytes); + return; + } + let mut rest = bytes; + while let Some(i) = rest.iter().position(|&b| b == b'\n') { + self.vt.feed(&rest[..i]); + self.vt.feed(b"\r\n"); + rest = &rest[i + 1..]; + } + self.vt.feed(rest); } } diff --git a/userland/capsule_terminal/src/term/scrollback/fit.rs b/userland/capsule_terminal/src/term/scrollback/fit.rs new file mode 100644 index 0000000000..63f99cae44 --- /dev/null +++ b/userland/capsule_terminal/src/term/scrollback/fit.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 nonos_vt::Palette; + +use super::types::Scrollback; +use crate::term::theme::types::Theme; + +impl Scrollback { + /// Match the screen to the window. A resize re-wraps what is there. + /// True when the size in cells changed. + pub fn fit(&mut self, cols: usize, rows: usize, cell_w: u32, cell_h: u32) -> bool { + let changed = (cols, rows) != (self.vt.cols(), self.vt.rows()); + self.vt.resize(cols, rows); + self.vt.set_cell_pixels( + cell_w.min(u16::MAX as u32) as u16, + cell_h.min(u16::MAX as u32) as u16, + ); + changed + } + + /// Program colours start from the window's theme. Only a change of + /// theme resets them, so a colour a program set survives a repaint. + pub fn follow_theme(&mut self, index: u16, t: &Theme) { + if self.theme_of == Some(index) { + return; + } + let mut p = Palette::xterm(); + p.colors[..16].copy_from_slice(crate::term::theme::ansi::base16_for(t.bg)); + p.fg = t.fg & 0x00FF_FFFF; + p.bg = t.bg & 0x00FF_FFFF; + p.cursor = t.accent & 0x00FF_FFFF; + self.vt.set_theme(p); + self.theme_of = Some(index); + } +} diff --git a/userland/capsule_terminal/src/term/scrollback/jump_bottom.rs b/userland/capsule_terminal/src/term/scrollback/jump_bottom.rs index caad121f5f..3cab442b94 100644 --- a/userland/capsule_terminal/src/term/scrollback/jump_bottom.rs +++ b/userland/capsule_terminal/src/term/scrollback/jump_bottom.rs @@ -18,6 +18,6 @@ use super::types::Scrollback; impl Scrollback { pub fn jump_bottom(&mut self) { - self.grid.jump_view_bottom(); + self.vt.scroll_to_bottom(); } } diff --git a/userland/capsule_terminal/src/term/scrollback/mod.rs b/userland/capsule_terminal/src/term/scrollback/mod.rs index 0b5462932b..253a804c0e 100644 --- a/userland/capsule_terminal/src/term/scrollback/mod.rs +++ b/userland/capsule_terminal/src/term/scrollback/mod.rs @@ -17,8 +17,10 @@ mod capture; mod clear; mod feed_raw; +mod fit; mod jump_bottom; mod new; +mod program_ended; mod push_dir_row; mod push_error; mod push_line; diff --git a/userland/capsule_terminal/src/term/scrollback/new.rs b/userland/capsule_terminal/src/term/scrollback/new.rs index 06252a3c93..a8407102fa 100644 --- a/userland/capsule_terminal/src/term/scrollback/new.rs +++ b/userland/capsule_terminal/src/term/scrollback/new.rs @@ -14,11 +14,20 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . +use nonos_vt::Term; + use super::types::Scrollback; -use crate::term::grid::types::Grid; +use crate::term::dimensions::{COLS, SCROLLBACK_ROWS, VISIBLE_ROWS}; impl Scrollback { + /// The size here is only where it starts: the paint resizes it to the + /// window before anything is drawn. pub fn new() -> Self { - Self { capture: None, grid: Grid::new() } + Self { + capture: None, + vt: Term::new(COLS, VISIBLE_ROWS, SCROLLBACK_ROWS), + onlcr: true, + theme_of: None, + } } } diff --git a/userland/capsule_terminal/src/term/scrollback/program_ended.rs b/userland/capsule_terminal/src/term/scrollback/program_ended.rs new file mode 100644 index 0000000000..813b13b7d8 --- /dev/null +++ b/userland/capsule_terminal/src/term/scrollback/program_ended.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 super::types::Scrollback; + +impl Scrollback { + /// A program ended. Whatever modes it left on are turned off, as `reset` + /// would: a full-screen program that crashed must not leave the shell on + /// its alternate screen, hide the cursor, or keep sending mouse reports + /// into the prompt. Output that stopped mid-line gets its line ended, so + /// the next command's output starts at the left. + pub fn program_ended(&mut self) { + self.vt.feed( + b"\x1b[?1049l\x1b[?1l\x1b[?2004l\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1006l\ + \x1b[?1004l\x1b[?25h\x1b[0m\x1b(B", + ); + if self.vt.cursor().x > 0 { + self.vt.feed(b"\r\n"); + } + self.onlcr = true; + } +} diff --git a/userland/capsule_terminal/src/term/scrollback/push_dir_row.rs b/userland/capsule_terminal/src/term/scrollback/push_dir_row.rs index fc1ffced45..212f2e53f9 100644 --- a/userland/capsule_terminal/src/term/scrollback/push_dir_row.rs +++ b/userland/capsule_terminal/src/term/scrollback/push_dir_row.rs @@ -30,9 +30,9 @@ impl Scrollback { self.push_line(&joined); return; } - self.grid.feed(plain); - self.grid.feed(b"\x1b[94m"); - self.grid.feed(name); - self.grid.feed(b"\x1b[0m\n"); + self.feed_raw(plain); + self.feed_raw(b"\x1b[94m"); + self.feed_raw(name); + self.feed_raw(b"\x1b[0m\n"); } } diff --git a/userland/capsule_terminal/src/term/scrollback/push_raw.rs b/userland/capsule_terminal/src/term/scrollback/push_raw.rs index ca071c3780..cb364edaaa 100644 --- a/userland/capsule_terminal/src/term/scrollback/push_raw.rs +++ b/userland/capsule_terminal/src/term/scrollback/push_raw.rs @@ -21,13 +21,13 @@ impl Scrollback { pub(super) fn push_raw(&mut self, line: &[u8], role: Role) { match role { Role::Error => { - self.grid.feed(b"\x1b[31m"); - self.grid.feed(line); - self.grid.feed(b"\x1b[0m\n"); + self.feed_raw(b"\x1b[31m"); + self.feed_raw(line); + self.feed_raw(b"\x1b[0m\n"); } Role::Normal => { - self.grid.feed(line); - self.grid.feed(b"\n"); + self.feed_raw(line); + self.feed_raw(b"\n"); } } } diff --git a/userland/capsule_terminal/src/term/scrollback/push_styled.rs b/userland/capsule_terminal/src/term/scrollback/push_styled.rs index be559d0f11..ab9a17cc17 100644 --- a/userland/capsule_terminal/src/term/scrollback/push_styled.rs +++ b/userland/capsule_terminal/src/term/scrollback/push_styled.rs @@ -29,7 +29,7 @@ impl Scrollback { self.push_line(plain); return; } - self.grid.feed(styled); - self.grid.feed(b"\x1b[0m\n"); + self.feed_raw(styled); + self.feed_raw(b"\x1b[0m\n"); } } diff --git a/userland/capsule_terminal/src/term/scrollback/scroll_down.rs b/userland/capsule_terminal/src/term/scrollback/scroll_down.rs index 116ed64443..45d52fc17c 100644 --- a/userland/capsule_terminal/src/term/scrollback/scroll_down.rs +++ b/userland/capsule_terminal/src/term/scrollback/scroll_down.rs @@ -17,7 +17,8 @@ use super::types::Scrollback; impl Scrollback { + /// Forward toward the live screen by `lines`. pub fn scroll_down(&mut self, lines: usize) { - self.grid.scroll_view_down(lines); + self.vt.scroll_view(-(lines.min(isize::MAX as usize) as isize)); } } diff --git a/userland/capsule_terminal/src/term/scrollback/scroll_up.rs b/userland/capsule_terminal/src/term/scrollback/scroll_up.rs index b7e093d467..9a7d28a3b2 100644 --- a/userland/capsule_terminal/src/term/scrollback/scroll_up.rs +++ b/userland/capsule_terminal/src/term/scrollback/scroll_up.rs @@ -17,7 +17,8 @@ use super::types::Scrollback; impl Scrollback { + /// Back into history by `lines`. pub fn scroll_up(&mut self, lines: usize) { - self.grid.scroll_view_up(lines); + self.vt.scroll_view(lines.min(isize::MAX as usize) as isize); } } diff --git a/userland/capsule_terminal/src/term/scrollback/types.rs b/userland/capsule_terminal/src/term/scrollback/types.rs index eff6bd4a69..1c157353d8 100644 --- a/userland/capsule_terminal/src/term/scrollback/types.rs +++ b/userland/capsule_terminal/src/term/scrollback/types.rs @@ -16,9 +16,16 @@ use alloc::vec::Vec; -use crate::term::grid::types::Grid; +use nonos_vt::Term; pub struct Scrollback { pub(super) capture: Option>>, - pub grid: Grid, + /// The screen and its history. + pub vt: Term, + /// Output processing a tty does by default: a line feed also returns + /// the carriage, since the shell and most programs end lines with `\n` + /// alone. A program that turns output processing off gets bare feeds. + pub onlcr: bool, + /// The theme the palette was last taken from. + pub(super) theme_of: Option, } diff --git a/userland/capsule_terminal/src/term/select.rs b/userland/capsule_terminal/src/term/select.rs new file mode 100644 index 0000000000..e0b362f6db --- /dev/null +++ b/userland/capsule_terminal/src/term/select.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 . + +//! A span of the screen and its history picked with the pointer, and the +//! match a scrollback search is on. + +use nonos_vt::Pos; + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub struct Selection { + /// Where the press landed, and where the pointer is now. + pub anchor: Pos, + pub head: Pos, + /// Alt held: a rectangle of columns rather than a run of text. + pub block: bool, +} + +impl Selection { + pub fn ordered(&self) -> (Pos, Pos) { + if self.anchor <= self.head { + (self.anchor, self.head) + } else { + (self.head, self.anchor) + } + } + + pub fn contains(&self, line: u64, col: usize) -> bool { + let (a, b) = self.ordered(); + if line < a.line || line > b.line { + return false; + } + if self.block { + let (l, r) = (a.col.min(b.col), a.col.max(b.col)); + return col >= l && col <= r; + } + let from = if line == a.line { a.col } else { 0 }; + let to = if line == b.line { b.col } else { usize::MAX }; + col >= from && col <= to + } +} + +/// A search through the screen and its history. +#[derive(Clone, Default, Debug)] +pub struct Find { + pub query: alloc::string::String, + /// The match on view, first and last cells. + pub hit: Option<(Pos, Pos)>, + /// Upper and lower case count as different. + pub case: bool, +} + +/// A span to shade: a search match. +pub fn in_span(span: Option<(Pos, Pos)>, line: u64, col: usize) -> bool { + match span { + Some((a, b)) => { + let p = Pos { line, col }; + p >= a && p <= b + } + None => false, + } +} diff --git a/userland/capsule_terminal/src/term/state/mod.rs b/userland/capsule_terminal/src/term/state/mod.rs index 9c23ee850b..f457515c47 100644 --- a/userland/capsule_terminal/src/term/state/mod.rs +++ b/userland/capsule_terminal/src/term/state/mod.rs @@ -15,6 +15,7 @@ // along with this program. If not, see . mod new; +mod shade; mod types; pub use types::State; diff --git a/userland/capsule_terminal/src/term/state/new.rs b/userland/capsule_terminal/src/term/state/new.rs index 110db72079..9dfadad74d 100644 --- a/userland/capsule_terminal/src/term/state/new.rs +++ b/userland/capsule_terminal/src/term/state/new.rs @@ -44,6 +44,9 @@ impl State { jobs: JobTable::new(), fg_running: false, fg_started_ms: 0, + sel: None, + find: None, + cooked: crate::event::cooked::Cooked::default(), } } } diff --git a/userland/capsule_terminal/src/term/state/shade.rs b/userland/capsule_terminal/src/term/state/shade.rs new file mode 100644 index 0000000000..a5f66c814c --- /dev/null +++ b/userland/capsule_terminal/src/term/state/shade.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 . + +use super::types::State; +use crate::paint::Shade; + +impl State { + /// What the body shades over its text: the selection and the search hit. + pub fn shade(&self) -> Shade { + Shade { selection: self.sel, found: self.find.as_ref().and_then(|f| f.hit) } + } +} diff --git a/userland/capsule_terminal/src/term/state/types.rs b/userland/capsule_terminal/src/term/state/types.rs index cad5fdd145..27557c8312 100644 --- a/userland/capsule_terminal/src/term/state/types.rs +++ b/userland/capsule_terminal/src/term/state/types.rs @@ -59,4 +59,10 @@ pub struct State { // close_block and last_status to the job's reap in the on_tick pump. pub fg_running: bool, pub fg_started_ms: i64, + /// Text picked with the pointer, if any. + pub sel: Option, + /// The scrollback search: what is typed, and the match it is on. + pub find: Option, + /// The line editing a foreground program gets while it reads lines. + pub cooked: crate::event::cooked::Cooked, } diff --git a/userland/capsule_terminal/src/term/terminal/app_impl_on_event.rs b/userland/capsule_terminal/src/term/terminal/app_impl_on_event.rs index 88bb961900..5b133987d9 100644 --- a/userland/capsule_terminal/src/term/terminal/app_impl_on_event.rs +++ b/userland/capsule_terminal/src/term/terminal/app_impl_on_event.rs @@ -34,6 +34,9 @@ impl Terminal { if let Some(outcome) = self.rail_click(event) { return outcome; } + if let Some(outcome) = self.body_pointer(event) { + return outcome; + } let outcome = on_event(self.cur(), event); self.drain_chrome_req(); outcome diff --git a/userland/capsule_terminal/src/term/terminal/app_impl_paint.rs b/userland/capsule_terminal/src/term/terminal/app_impl_paint.rs index d3eecfe18c..f0bcd3b98e 100644 --- a/userland/capsule_terminal/src/term/terminal/app_impl_paint.rs +++ b/userland/capsule_terminal/src/term/terminal/app_impl_paint.rs @@ -29,6 +29,15 @@ impl Terminal { } self.width = fb.width; let theme = crate::term::theme::profiles::by_index(self.theme); + let rail_open = self.prefs.rails & RAIL_VISIBLE != 0; + let (cols, rows, m) = crate::paint::grid_size(fb, self.font_scale, rail_open); + let theme_ix = self.theme; + let sb = &mut self.cur().scrollback; + let resized = sb.fit(cols, rows, m.adv, m.lh); + sb.follow_theme(theme_ix, theme); + if resized { + crate::jobs::tty::resized(self.cur_ref()); + } let l = crate::paint::paint_tabs( &self.tabs, self.active, @@ -42,6 +51,17 @@ impl Terminal { &self.palette, self.prefs.rails & RAIL_VISIBLE != 0, ); + let s = self.cur_ref(); + let owned = s.scrollback.vt.alt_active() || s.fg_running; + self.cells = Some(super::pointer::CellGeom { + x: l.body.x + crate::paint::TEXT_LEFT, + y: l.body.y, + adv: m.adv, + lh: m.lh, + pad: crate::paint::TEXT_LEFT, + shell_rows: (l.body.h / m.lh.max(1)) as usize, + owned, + }); self.layout = Some(l); } } diff --git a/userland/capsule_terminal/src/term/terminal/mod.rs b/userland/capsule_terminal/src/term/terminal/mod.rs index 8769ffdb5f..0473afdc75 100644 --- a/userland/capsule_terminal/src/term/terminal/mod.rs +++ b/userland/capsule_terminal/src/term/terminal/mod.rs @@ -25,6 +25,7 @@ mod new; mod palette_act; mod palette_key; mod palette_pick; +mod pointer; mod rail_click; mod rail_wheel; #[cfg(feature = "nonos-autorun-selftest")] diff --git a/userland/capsule_terminal/src/term/terminal/new.rs b/userland/capsule_terminal/src/term/terminal/new.rs index ba19cde5e0..4f1c1007ce 100644 --- a/userland/capsule_terminal/src/term/terminal/new.rs +++ b/userland/capsule_terminal/src/term/terminal/new.rs @@ -41,6 +41,8 @@ impl Terminal { rail_scroll: 0, layout: None, palette: Palette::new(), + cells: None, + ptr: Default::default(), } } } diff --git a/userland/capsule_terminal/src/term/terminal/pointer/cells.rs b/userland/capsule_terminal/src/term/terminal/pointer/cells.rs new file mode 100644 index 0000000000..4bb0977bb2 --- /dev/null +++ b/userland/capsule_terminal/src/term/terminal/pointer/cells.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 the body's cells were drawn in the last paint, so a pointer event +//! can be read back as a cell and a line. + +use nonos_vt::Term; + +use crate::paint::Rows; + +#[derive(Clone, Copy)] +pub struct CellGeom { + pub x: u32, + pub y: u32, + pub adv: u32, + pub lh: u32, + /// The margin drawn around the text, which a press may land in. + pub pad: u32, + /// Rows of the body a shell drawing uses; the screen owns all rows. + pub shell_rows: usize, + pub owned: bool, +} + +impl CellGeom { + pub(super) fn body_rows(&self, vt: &Term) -> usize { + if self.owned { + vt.rows() + } else { + self.shell_rows + } + } + + pub fn rows(&self, vt: &Term) -> Rows { + if self.owned { + Rows::Screen + } else { + Rows::Shell { rows: self.shell_rows, back: vt.view_offset() } + } + } +} diff --git a/userland/capsule_terminal/src/term/terminal/pointer/cells_hit.rs b/userland/capsule_terminal/src/term/terminal/pointer/cells_hit.rs new file mode 100644 index 0000000000..281cfc859d --- /dev/null +++ b/userland/capsule_terminal/src/term/terminal/pointer/cells_hit.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 . + +//! Reading a pointer position back as a cell. + +use nonos_vt::{Pos, Term}; + +use super::cells::CellGeom; +use crate::paint::Rows; + +impl CellGeom { + /// The cell under `(px, py)`, clamped to the screen, and the row it is. + pub fn cell_at(&self, vt: &Term, px: i32, py: i32) -> (usize, usize) { + let col = ((px - self.x as i32).max(0) as u32 / self.adv.max(1)) as usize; + let row = ((py - self.y as i32).max(0) as u32 / self.lh.max(1)) as usize; + (col.min(vt.cols() - 1), row.min(self.body_rows(vt).saturating_sub(1))) + } + + pub fn pos_at(&self, vt: &Term, px: i32, py: i32) -> Pos { + let (col, row) = self.cell_at(vt, px, py); + let rows = self.rows(vt); + let line = match rows { + Rows::Screen => vt.abs_of_row(row), + Rows::Shell { .. } => rows.first(vt) + row as u64, + }; + Pos { line, col } + } + + /* + * Over the text or the margin around it: a press a few pixels short of + * the first column is aimed at it, and cell_at clamps it there. + */ + pub fn inside(&self, vt: &Term, px: i32, py: i32) -> bool { + let w = vt.cols() as u32 * self.adv; + let h = self.body_rows(vt) as u32 * self.lh; + let left = self.x as i32 - self.pad as i32; + px >= left + && py >= self.y as i32 + && px < (self.x + w + self.pad) as i32 + && py < (self.y + h) as i32 + } +} diff --git a/userland/capsule_terminal/src/term/terminal/pointer/mod.rs b/userland/capsule_terminal/src/term/terminal/pointer/mod.rs new file mode 100644 index 0000000000..fb0184752b --- /dev/null +++ b/userland/capsule_terminal/src/term/terminal/pointer/mod.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 pointer over the body: selecting text, and for a program that asked +//! for them, mouse reports. Shift held selects even then, as xterm does. + +mod cells; +mod cells_hit; +mod press; +mod report; +mod select; +mod state; +mod wheel; + +pub use cells::CellGeom; +pub use state::Pointer; diff --git a/userland/capsule_terminal/src/term/terminal/pointer/press.rs b/userland/capsule_terminal/src/term/terminal/pointer/press.rs new file mode 100644 index 0000000000..82dd34123b --- /dev/null +++ b/userland/capsule_terminal/src/term/terminal/pointer/press.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 . + +//! Choosing text: a press starts, a drag extends, a double click takes a +//! word and a triple click a line, and Alt makes it a block. + +use crate::term::select::Selection; +use crate::term::terminal::Terminal; +use nonos_app_skeleton::{InputEvent, MOD_ALT}; +use nonos_vt::Pos; + +/// Presses closer together than this count as a double or triple click. +const MULTI_CLICK_NS: u64 = 400_000_000; + +impl Terminal { + pub(super) fn press(&mut self, event: &InputEvent, pos: Pos) { + let p = &mut self.ptr; + let again = + event.timestamp_ns.saturating_sub(p.last_ns) < MULTI_CLICK_NS && p.last_pos == pos; + p.clicks = if again { p.clicks % 3 + 1 } else { 1 }; + (p.last_ns, p.last_pos, p.start) = (event.timestamp_ns, pos, pos); + p.block = event.flags & MOD_ALT != 0; + p.selecting = true; + let clicks = p.clicks; + let state = self.cur(); + let vt = &state.scrollback.vt; + let span = match clicks { + 2 => Some(vt.word_at(pos)), + 3 => Some(vt.line_bounds(pos)), + _ => None, + }; + state.sel = span.map(|(a, b)| Selection { anchor: a, head: b, block: false }); + } + + pub(super) fn drag(&mut self, pos: Pos) { + let (start, block, clicks) = (self.ptr.start, self.ptr.block, self.ptr.clicks); + let state = self.cur(); + if pos == start && state.sel.is_none() { + return; + } + let anchor = state.sel.map(|x| x.anchor).filter(|_| clicks > 1).unwrap_or(start); + state.sel = Some(Selection { anchor, head: pos, block }); + } +} diff --git a/userland/capsule_terminal/src/term/terminal/pointer/report.rs b/userland/capsule_terminal/src/term/terminal/pointer/report.rs new file mode 100644 index 0000000000..7fff56af41 --- /dev/null +++ b/userland/capsule_terminal/src/term/terminal/pointer/report.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 . + +//! Pointer events for a foreground program that asked for mouse reports. + +use nonos_app_skeleton::{InputEvent, InputKind, MOD_SHIFT}; +use nonos_vt::input::{encode_mouse, Button, MouseEvent, MouseKind}; +use nonos_vt::MouseMode; + +use super::select::{BTN_LEFT, BTN_RIGHT}; +use crate::event::keymap::mods; +use crate::event::send_to_program; +use crate::term::terminal::Terminal; + +impl Terminal { + /// Hand the event to the program when it asked for mouse reports and + /// Shift is not held. True when the program took it. + pub(super) fn program_mouse(&mut self, event: &InputEvent) -> bool { + let Some(g) = self.cells else { return false }; + let state = self.cur_ref(); + let vt = &state.scrollback.vt; + if !state.fg_running || vt.modes.mouse == MouseMode::Off || event.flags & MOD_SHIFT != 0 { + return false; + } + let (col, row) = g.cell_at(vt, event.x, event.y); + let (kind, button) = match event.kind { + InputKind::ButtonDown | InputKind::ButtonUp => { + let b = match event.code { + BTN_LEFT => Button::Left, + BTN_RIGHT => Button::Right, + _ => Button::Middle, + }; + let down = event.kind == InputKind::ButtonDown; + self.ptr.held = down.then_some(b); + (if down { MouseKind::Press } else { MouseKind::Release }, b) + } + InputKind::PointerAbs => (MouseKind::Motion, self.ptr.held.unwrap_or(Button::None)), + InputKind::Wheel if event.delta_y > 0 => (MouseKind::Press, Button::WheelUp), + InputKind::Wheel => (MouseKind::Press, Button::WheelDown), + _ => return false, + }; + let ev = MouseEvent { kind, button, col, row, mods: mods(event.flags) }; + let mut bytes = alloc::vec::Vec::new(); + let state = self.cur(); + if encode_mouse(&ev, &state.scrollback.vt.modes, &mut bytes) { + send_to_program(state, &bytes); + } + true + } +} diff --git a/userland/capsule_terminal/src/term/terminal/pointer/select.rs b/userland/capsule_terminal/src/term/terminal/pointer/select.rs new file mode 100644 index 0000000000..838e12db54 --- /dev/null +++ b/userland/capsule_terminal/src/term/terminal/pointer/select.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 . + +//! Routing pointer events over the body: to the program, the wheel, or +//! choosing text. + +use nonos_app_skeleton::{EventOutcome, InputEvent, InputKind}; + +use crate::term::terminal::Terminal; + +pub(super) const BTN_LEFT: u32 = 1; +pub(super) const BTN_RIGHT: u32 = 2; + +impl Terminal { + pub(crate) fn body_pointer(&mut self, event: InputEvent) -> Option { + let g = self.cells?; + let kinds = + [InputKind::ButtonDown, InputKind::ButtonUp, InputKind::PointerAbs, InputKind::Wheel]; + if !kinds.contains(&event.kind) { + return None; + } + let inside = g.inside(&self.cur_ref().scrollback.vt, event.x, event.y); + if !inside && !self.ptr.selecting { + return None; + } + if self.program_mouse(&event) { + return Some(EventOutcome::Idle); + } + let pos = g.pos_at(&self.cur_ref().scrollback.vt, event.x, event.y); + match event.kind { + InputKind::Wheel => return Some(self.wheel(event)), + InputKind::ButtonDown if event.code == BTN_LEFT => self.press(&event, pos), + InputKind::PointerAbs if self.ptr.selecting => self.drag(pos), + InputKind::ButtonUp if event.code == BTN_LEFT => self.ptr.selecting = false, + _ => return Some(EventOutcome::Idle), + } + Some(EventOutcome::Repaint) + } +} diff --git a/userland/capsule_terminal/src/term/terminal/pointer/state.rs b/userland/capsule_terminal/src/term/terminal/pointer/state.rs new file mode 100644 index 0000000000..95f8dd2262 --- /dev/null +++ b/userland/capsule_terminal/src/term/terminal/pointer/state.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 . + +//! What the pointer is in the middle of. + +use nonos_vt::input::Button; +use nonos_vt::Pos; + +#[derive(Default)] +pub struct Pointer { + /// A left-button drag is choosing text: from where, and as a block. + pub selecting: bool, + pub start: Pos, + pub block: bool, + /// Counting presses in one place for double and triple clicks. + pub clicks: u8, + pub last_ns: u64, + pub last_pos: Pos, + /// The button held, for motion reports to a program. + pub held: Option