diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1918283..bad4547 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -115,7 +115,10 @@ jobs: persist-credentials: false - name: Install pinned nightly + aarch64 target run: | - rustup toolchain install $NIGHTLY_PIN --component clippy --no-self-update + # llvm-tools-preview provides rust-objcopy for the userland build + # step below (ADR-0039); it is pinned in rust-toolchain.toml but this + # explicit install does not read that file, so name it here. + rustup toolchain install $NIGHTLY_PIN --component clippy --component llvm-tools-preview --no-self-update rustup override set $NIGHTLY_PIN rustup target add aarch64-unknown-none --toolchain $NIGHTLY_PIN - name: Cache cargo registry and build @@ -129,6 +132,11 @@ jobs: key: ${{ runner.os }}-cargo-aarch64-${{ env.NIGHTLY_PIN }}-${{ hashFiles('**/Cargo.lock') }} restore-keys: | ${{ runner.os }}-cargo-aarch64-${{ env.NIGHTLY_PIN }}- + - name: build userland image (ADR-0039) + # Produces userland/hello/hello.bin (rust-objcopy of the userland crate) + # which the BSP embeds via include_bytes!; MUST run before kernel-build, + # whose build.rs panics if the .bin is absent. + run: tools/build-userland.sh - name: cargo kernel-build run: cargo +$NIGHTLY_PIN kernel-build - name: cargo kernel-clippy @@ -262,4 +270,8 @@ jobs: restore-keys: | ${{ runner.os }}-llvmcov-${{ env.NIGHTLY_PIN }}- - name: cargo llvm-cov --summary-only - run: cargo llvm-cov --workspace --exclude tyrne-bsp-qemu-virt --summary-only + # Exclude the bare-metal crates: the BSP is no_std/no_main (cannot host- + # build) and the userland crates (tyrne-user / hello) host-compile only + # as aarch64-gated stubs (ADR-0039) — coverage of never-run stubs is + # meaningless and would only dilute the metric. + run: cargo llvm-cov --workspace --exclude tyrne-bsp-qemu-virt --exclude tyrne-user --exclude tyrne-userland-hello --summary-only diff --git a/Cargo.lock b/Cargo.lock index 7ac40a8..b953821 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -28,3 +28,14 @@ version = "0.0.1" dependencies = [ "tyrne-hal", ] + +[[package]] +name = "tyrne-user" +version = "0.0.1" + +[[package]] +name = "tyrne-userland-hello" +version = "0.0.1" +dependencies = [ + "tyrne-user", +] diff --git a/Cargo.toml b/Cargo.toml index 615f461..ea56fec 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,11 +10,16 @@ members = [ "hal", "bsp-qemu-virt", "test-hal", + "userland/tyrne-user", + "userland/hello", ] # Default set for workspace-wide commands (`cargo check`, `cargo test`, etc.): # everything host-buildable. The BSP is #![no_std, no_main] and requires the # explicit bare-metal target; build it via `cargo kernel-build` (or -# `cargo build --target aarch64-unknown-none -p tyrne-bsp-qemu-virt`). +# `cargo build --target aarch64-unknown-none -p tyrne-bsp-qemu-virt`). The +# userland crates (tyrne-user lib + the no_main hello bin) are likewise +# bare-metal-only — built via `tools/build-userland.sh` (ADR-0039), excluded +# here so host commands (`cargo build`/`test`/`host-clippy`) skip them. default-members = [ "kernel", "hal", diff --git a/bsp-qemu-virt/build.rs b/bsp-qemu-virt/build.rs index 1771adc..9cab460 100644 --- a/bsp-qemu-virt/build.rs +++ b/bsp-qemu-virt/build.rs @@ -4,6 +4,11 @@ //! path so resolution does not depend on the linker's working directory. //! See `docs/decisions/0012-boot-flow-qemu-virt.md` for the memory layout the //! linker script encodes. +//! +//! Also asserts the userland image (`userland/hello/hello.bin`) that +//! `main.rs` embeds via `include_bytes!` exists, and re-runs when it changes — +//! it is produced by `tools/build-userland.sh` (ADR-0039), which must run +//! before `cargo kernel-build`. fn main() { let manifest_dir = std::env::var("CARGO_MANIFEST_DIR") @@ -14,4 +19,18 @@ fn main() { println!("cargo:rerun-if-changed=build.rs"); println!("cargo:rerun-if-changed=src/boot.s"); println!("cargo:rerun-if-changed=src/vectors.s"); + + // Userland image (ADR-0039 / T-027): `main.rs` embeds this via + // `include_bytes!`. It is produced by `tools/build-userland.sh` + // (cargo build -> rust-objcopy) — NOT by this build script (no nested + // cargo). Fail loudly with the remedy if it is absent, rather than letting + // `include_bytes!` emit an opaque "file not found". + let hello_bin = format!("{manifest_dir}/../userland/hello/hello.bin"); + println!("cargo:rerun-if-changed={hello_bin}"); + assert!( + std::path::Path::new(&hello_bin).exists(), + "userland image not built: {hello_bin} is missing.\n \ + Run `tools/build-userland.sh` before `cargo kernel-build` \ + (ADR-0039); `tools/smoke.sh` and CI do this automatically." + ); } diff --git a/bsp-qemu-virt/src/main.rs b/bsp-qemu-virt/src/main.rs index 9524ac2..2d827f6 100644 --- a/bsp-qemu-virt/src/main.rs +++ b/bsp-qemu-virt/src/main.rs @@ -327,22 +327,26 @@ static BOOTSTRAP_AS_CAP: StaticCell = StaticCell::new(); /// untyped / memory-region authority caps. static BOOTSTRAP_AS_TABLE: StaticCell = StaticCell::new(); -// ─── T-019 task loader placeholder image (ADR-0029) ─────────────────────────── +// ─── Userland image (ADR-0029 format / ADR-0039 build pipeline) ─────────────── -/// Placeholder userspace image: 8 bytes of aarch64 `mov w0, #42; ret` -/// per [ADR-0029 §Decision outcome (Build pipeline — B4 / T-019)][adr-0029]. -/// The real B6 "hello" userspace binary lands with `userland/hello/` -/// per [ADR-0029 §Decision outcome (Build pipeline — B6)][adr-0029]; -/// T-019 ships with this hand-coded blob as the loader's smoke fixture. +/// The real B6 "hello" userspace image: the raw-flat (`rust-objcopy -O binary`) +/// output of the [`tyrne-userland-hello`] crate, embedded at compile time per +/// [ADR-0039][adr-0039]. Entry at offset 0 ([ADR-0029][adr-0029] raw-flat +/// format), mapped `USER | EXECUTE` by the loader. It replaces the B4/T-019 +/// hand-coded `mov w0, #42; ret` placeholder. /// -/// **Not executed.** T-019 produces a `LoadedImage` describing a -/// populated AS; running gates on B5 (syscall ABI per ADR-0030) + B6 -/// (first userspace "hello") which together provide the prerequisites -/// (kernel mappings in userspace AS, EL0-ready context, syscall -/// entry). +/// **Build dependency:** produced by `tools/build-userland.sh` (which the +/// BSP [`build.rs`] requires to have run — it `panic!`s if this `.bin` is +/// absent) before `cargo kernel-build`. `tools/smoke.sh` and the CI +/// kernel-build job both run that script first. +/// +/// **Not yet executed (T-027).** Embedding is T-027; loading it into a real +/// EL0 task and running the `+0x400` round-trip is the T-028 wire-up. Today the +/// boot-time loader smoke maps it (proving it loads) and the demo then idles. /// /// [adr-0029]: https://github.com/HodeTech/Tyrne/blob/main/docs/decisions/0029-initial-userspace-image-format.md -static USERSPACE_IMAGE: &[u8] = &[0x40, 0x05, 0x80, 0x52, 0xc0, 0x03, 0x5f, 0xd6]; +/// [adr-0039]: https://github.com/HodeTech/Tyrne/blob/main/docs/decisions/0039-userland-build-pipeline.md +static USERSPACE_IMAGE: &[u8] = include_bytes!("../../userland/hello/hello.bin"); /// Base VA the loader places the image at — userspace VA range per /// [ADR-0027 §Decision outcome (a)][adr-0027]'s `TTBR0_EL1` range. diff --git a/docs/analysis/tasks/phase-b/T-027-userland-build-pipeline.md b/docs/analysis/tasks/phase-b/T-027-userland-build-pipeline.md new file mode 100644 index 0000000..76eb48f --- /dev/null +++ b/docs/analysis/tasks/phase-b/T-027-userland-build-pipeline.md @@ -0,0 +1,59 @@ +# T-027 — `userland/hello` + `tyrne-user` + the raw-flat build pipeline (B6 step 5) + +- **Phase:** B +- **Milestone:** B6 — First userspace "hello" (step 5 of the [B6 dependency-ordered sequence](../../../roadmap/phases/phase-b.md#milestone-b6--first-userspace-hello) — `tyrne-user` crate + `userland/hello/` crate + the `cargo build → rust-objcopy -O binary → include_bytes!` pipeline + the shared base-VA source-of-truth) +- **Status:** In Review (implemented on `t-027-userland-build-pipeline` after the [ADR-0039](../../../decisions/0039-userland-build-pipeline.md) Accept; all gates green; the embedded `hello` image is **dormant** — loading + running it in EL0 is [T-028](T-028-el0-userspace-wireup.md)) +- **Created:** 2026-05-31 +- **Author:** @cemililik (+ Claude Opus 4.8 agent) +- **Dependencies:** [ADR-0039](../../../decisions/0039-userland-build-pipeline.md) (the build-pipeline orchestration this implements); [ADR-0029](../../../decisions/0029-initial-userspace-image-format.md) (the raw-flat format — entry at offset 0, image `USER|EXECUTE`); [ADR-0006](../../../decisions/0006-workspace-layout.md) (the workspace-member / `tyrne-` naming conventions); [ADR-0031](../../../decisions/0031-initial-syscall-set.md) + [ADR-0030](../../../decisions/0030-syscall-abi.md) (the syscall ABI the `tyrne-user` wrappers + the SVC sequence target); [T-019](T-019-task-loader.md) (the loader that maps this image; pins `USERSPACE_IMAGE_BASE_VA`). +- **Informs:** Produces the real userspace image (replacing the hand-coded `USERSPACE_IMAGE` placeholder) so [T-028](T-028-el0-userspace-wireup.md) can load + run it in EL0. Does **not** itself run a task — the image is embedded but dormant until T-028. +- **ADRs required:** [ADR-0039](../../../decisions/0039-userland-build-pipeline.md) (must be Accepted before implementation). No new `unsafe` in the kernel; the only kernel-side change is the `include_bytes!` source of `USERSPACE_IMAGE` (data, not code). + +--- + +## User story + +As a kernel developer, I want a **real** userspace program compiled from Rust source — not a hand-coded byte literal — embedded into the kernel image through a reproducible, auditable pipeline, so that B6's first EL0 task is a maintainable program (greeting + clean exit) and every future userspace program follows the same shape. + +## Context + +ADR-0029 chose the raw-flat image format and deferred the build orchestration to B6; ADR-0039 settles that orchestration (a `tools/build-userland.sh` step + `rust-objcopy` from the pinned `llvm-tools-preview` + `include_bytes!` of a git-ignored `.bin`, with the userland crates as `default-members`-excluded workspace members). T-027 implements it. The current `USERSPACE_IMAGE` ([`bsp-qemu-virt/src/main.rs`](../../../../bsp-qemu-virt/src/main.rs)) is the placeholder `mov w0, #42; ret`; T-027 replaces it with the objcopy output of a `userland/hello` crate that, when run (T-028), will `console_write` a greeting and `task_exit`. + +## Acceptance criteria + +- [x] **`tyrne-user` crate** (`userland/tyrne-user/`, package `tyrne-user`, `#![no_std]`): safe wrappers over the [ADR-0031](../../../decisions/0031-initial-syscall-set.md) syscalls needed by `hello` — at minimum `console_write(cap: CapWord, buf: &[u8]) -> Result` and `task_exit(code) -> !` — each wrapping a thin `svc #0` inline-asm shim (an **`unsafe` block** — see the userland-`unsafe`-audit AC) packing the ABI ([ADR-0030](../../../decisions/0030-syscall-abi.md): `x8` = number, `x0..x5` = args; `ConsoleWrite = 5`, `TaskExit = 4`). No dependency on kernel internals (the ABI constants are restated/owned userspace-side or shared via a leaf crate). +- [x] **Userland `unsafe` audit** (security policy applies to userspace too): the `tyrne-user` `svc #0` shims are `unsafe` (inline asm); per [unsafe-policy §Scope](../../../standards/unsafe-policy.md) ("the rules apply equally in kernel, HAL, and userspace code") **and** [CLAUDE.md non-negotiable #2](../../../../CLAUDE.md), each `unsafe` block carries a `// SAFETY:` comment (why the asm upholds the ABI contract — args in `x0..x5`/`x8`, the clobber list, no UB) **and** a dated [`docs/audits/unsafe-log.md`](../../../audits/unsafe-log.md) entry (`UNSAFE-2026-NNNN`). The workspace `clippy::undocumented_unsafe_blocks` and `clippy::missing_safety_doc` lints (set to `deny`) require this comment; the audit entry is the policy obligation. +- [x] **`userland/hello` crate** (package `tyrne-userland-hello`, `#![no_std] #![no_main]`): a `_start` entry placed at **offset 0** of the linked image (linker `ENTRY` + a `KEEP`'d first section) that calls `tyrne_user::console_write` with a greeting string (living in the image's read-only data — reachable as a `USER` page, [T-025](T-025-user-access-translation.md) gate #1 requires only the `USER` flag for a read) then `tyrne_user::task_exit(0)`; a minimal `#[panic_handler]` that `task_exit`s or loops (no unwinder — `panic=abort` inherited). +- [x] **Console-cap handle contract (T-027 ↔ T-028 interface):** `hello` names its `DebugConsole` capability via a **single documented named constant** (e.g. `pub const HELLO_CONSOLE_CAP: u64` in `tyrne-user`, the `cap_word` passed in `x0` — `encode_cap_handle` of the handle the wire-up seeds, typically the root cap → index 0 / generation 0). [T-028](T-028-el0-userspace-wireup.md)'s cap-table seeding **must** insert the `DebugConsole` cap so it resolves to that exact handle. The constant is the shared interface — defined once, not duplicated by value across the two tasks. +- [x] **Userland linker script** places `.text` (entry first) + `.rodata` contiguously at `USERSPACE_IMAGE_BASE_VA`, **no `.data`/`.bss`** (writable globals would fault — image is `USER|EXECUTE`, no `WRITE`), 16-byte aligned; produces a contiguous byte stream with no ELF artifacts under `rust-objcopy -O binary`. +- [x] **Base-VA source-of-truth:** a single Rust-side `pub const USERSPACE_IMAGE_BASE_VA = 0x0080_0000` (read by the BSP loader call site); the userland linker script restates the literal with a documented `keep-in-sync-with` comment (LD cannot import a Rust const — per [ADR-0039](../../../decisions/0039-userland-build-pipeline.md)). +- [x] **Build orchestration** ([`tools/build-userland.sh`](../../../../tools/build-userland.sh)): `cargo build -p tyrne-userland-hello --target aarch64-unknown-none` (release + debug as the kernel profile dictates) → `rust-objcopy -O binary` (resolved from the active toolchain's `llvm-tools` — **no Cargo dependency**, K3-8 unfired) → a git-ignored `.bin` at a stable path (`userland/hello/hello.bin`). Clear error if `llvm-tools` / the ELF is missing. (The `.bin` is git-ignored by the repo's existing `*.bin` rule — no `.gitignore` change needed.) +- [x] **Workspace integration:** `userland/hello` + `tyrne-user` added to `members`, **excluded from `default-members`** (host commands skip them — mirrors `bsp-qemu-virt`); `cargo host-test` / `cargo build` / `cargo host-clippy` unaffected (verified). The crates additionally **cfg-gate their aarch64 asm / `no_main`** (degenerate host stubs, never run) so the explicit `--workspace` commands — notably `cargo miri test --workspace --exclude tyrne-bsp-qemu-virt` — host-compile them rather than choking on aarch64 asm (verified: `cargo check --workspace --exclude tyrne-bsp-qemu-virt` clean). +- [x] **BSP embed:** `USERSPACE_IMAGE` becomes `include_bytes!()`; [`bsp-qemu-virt/build.rs`](../../../../bsp-qemu-virt/build.rs) gains `rerun-if-changed` on the `.bin` and a `panic!` naming `tools/build-userland.sh` if it is absent. +- [x] **`tools/smoke.sh` runs the userland build first** so the canonical local smoke path stays one entry point. +- [x] **CI workflow updated:** [`.github/workflows/ci.yml`](../../../../.github/workflows/ci.yml) runs `tools/build-userland.sh` **before** its `kernel-build` / `kernel-clippy` jobs — otherwise, once `USERSPACE_IMAGE` becomes `include_bytes!` of the git-ignored `.bin`, those jobs hit the BSP `build.rs` missing-`.bin` `panic!` on a clean checkout and **CI breaks**. (The QEMU-smoke-in-CI regression gate itself stays the conditionally-deferred flag K3-7 — no QEMU-smoke CI job exists today.) +- [x] **Disassembly verification:** the objcopy'd `.bin` round-trips — a host test (or a documented `rust-objdump -d` check in the task's review-history) confirms offset 0 is the entry instruction and the SVC sequence + greeting match `hello`'s source. (The image is **not run** in T-027 — running is T-028.) +- [x] **All gates green:** `cargo fmt --all --check`; host + kernel clippy `-D warnings`; **the new userland crates linted** (`cargo clippy --target aarch64-unknown-none -p tyrne-userland-hello -p tyrne-user -- -D warnings` — neither `kernel-clippy` (only `-p tyrne-bsp-qemu-virt`) nor `host-clippy` (`default-members`, which excludes them) covers them, so this explicit invocation is required); host tests unchanged + green; `cargo kernel-build`; `tools/smoke.sh` PASS — the embedded real image loads via the existing loader smoke (maps `USER|EXECUTE` image + `USER|WRITE` stack, `LoadedImage` metadata printed), **byte-stable boot otherwise**, zero new fault class. Miri unaffected (no kernel logic change). + +## Out of scope + +- **Running the image in EL0 / the `+0x400` round-trip / `task_create_from_image` + `add_user_task` wiring** — [T-028](T-028-el0-userspace-wireup.md). +- **Seeding the task's capability table with a `DebugConsole` cap** — T-028 (the `hello` source hard-codes the *handle value* it will use; minting + inserting the cap is the wire-up's job). +- **Per-section permissions (RX `.text` / R `.rodata` / RW `.data`)** — the future ADR-0034 placeholder; v1 `hello` is code + read-only data only. +- **`cargo xtask` multi-binary orchestration** — the named B7+ upgrade ([ADR-0039](../../../decisions/0039-userland-build-pipeline.md)); v1 uses the shell script. + +## Approach + +Per [ADR-0039](../../../decisions/0039-userland-build-pipeline.md) §Decision outcome: two `default-members`-excluded crates (`tyrne-user` lib + `userland/hello` bin) targeting `aarch64-unknown-none`; a minimal userland linker script fixing the entry at `USERSPACE_IMAGE_BASE_VA` offset 0; `tools/build-userland.sh` driving `cargo build` + `rust-objcopy -O binary` (llvm-tools, no Cargo dep) to a git-ignored `.bin`; the BSP `build.rs` embedding it via `include_bytes!` with a `rerun-if-changed` + missing-file panic; `tools/smoke.sh` chaining the script. The hello program is deliberately tiny (greeting + `task_exit`) so the v1 no-`.data` constraint is non-binding. Verification is by disassembly + the existing loader smoke (the image maps cleanly); **execution is deferred to T-028**, mirroring how T-023's EL0-entry mechanism landed dormant before its wire-up. + +## Definition of done + +All acceptance criteria checked; gates green; the embedded real image replaces the placeholder and loads cleanly under `tools/smoke.sh` (dormant — not run); `current.md` + [phase-b.md §B6 step 5](../../../roadmap/phases/phase-b.md#milestone-b6--first-userspace-hello) updated. Lands **after** [ADR-0039](../../../decisions/0039-userland-build-pipeline.md) is Accepted. The EL0 wire-up + the explicit EL0-boundary security review are [T-028](T-028-el0-userspace-wireup.md). + +## Review history + +- **2026-05-31 — opened Draft** in the [ADR-0039](../../../decisions/0039-userland-build-pipeline.md) Propose commit (the ADR's dependency chain names it — step 5; [ADR-0025 §Rule 1](../../../decisions/0025-adr-governance-amendments.md)). Implementation follows the ADR Accept. +- **2026-05-31 — pre-Accept review-round (relayed, verified against source):** **(HIGH)** added the **userland-`unsafe`-audit AC** — the `tyrne-user` `svc #0` shims are `unsafe`; [unsafe-policy §Scope](../../../standards/unsafe-policy.md) applies "equally in … userspace code", so each needs a `// SAFETY:` comment + a `docs/audits/unsafe-log.md` entry (previously unstated). **(Med)** pinned the `tyrne-user` path to `userland/tyrne-user/` (was "or workspace root", contradicting the ADR). **(Med)** added the **`HELLO_CONSOLE_CAP` handle-contract AC** (the T-027↔T-028 interface — closes a silent handle-mismatch risk). **(Low)** added the explicit userland clippy gate (`-p tyrne-userland-hello -p tyrne-user` — neither `kernel-clippy` nor `host-clippy` covers the new crates). All against the still-`Proposed` ADR-0039, pre-Accept. +- **2026-05-31 — adversarial ADR-0039 review (5-lens workflow, 81 agents, per-finding 2-skeptic verification): 38 findings, 37 refuted, 1 confirmed; governance lens fully clean.** **Confirmed (fixed):** CI's `kernel-build` job runs `cargo kernel-build` directly on a clean checkout ([`.github/workflows/ci.yml`](../../../../.github/workflows/ci.yml)), so once `USERSPACE_IMAGE` becomes `include_bytes!` of a git-ignored `.bin`, the BSP `build.rs` missing-`.bin` `panic!` would **break CI** — added the CI-workflow-update AC here + corrected ADR-0039's stale "what CI runs" claim (CI runs `kernel-build`, not the smoke). **Refuted (37):** the adversarial pass confirmed the prior review-round's fixes are present (AS-lifetime SEC-T024-01, the `HELLO_CONSOLE_CAP` contract, the no-`.data` constraint, the greeting-marker gate — all already in T-027/T-028), that the 4-option analysis is fair (Option 2/4 not strawmanned; artifact-dependencies considered + rejected as unstable-on-pinned-nightly), that governance/`write-adr` compliance is clean, and that the rust-objcopy / offset-0 / default-members feasibility holds. +- **2026-06-01 — implemented (→ In Review)** on `t-027-userland-build-pipeline` (off `main` at the merged-B6-gates `a8ce11e`, after the ADR-0039 Accept `df3fcd4`). Built: `userland/tyrne-user` (safe `console_write`/`task_exit` shims + `HELLO_CONSOLE_CAP = 0` + UNSAFE-2026-0033) + `userland/hello` (`_start` at `.text._start` → greet → `task_exit`; `hello.ld` fixing the entry at offset 0 / `0x0080_0000`, ASSERT-no-`.data`/`.bss`) + `tools/build-userland.sh` (cargo → `rust-objcopy` from `llvm-tools-preview`, no Cargo dep → git-ignored `userland/hello/hello.bin`). Wired: root `Cargo.toml` members (excluded from `default-members`); BSP `USERSPACE_IMAGE` → `include_bytes!` + `build.rs` `assert!`-if-absent; `tools/smoke.sh` now builds userland + kernel (one-command); `.github/workflows/ci.yml` installs `llvm-tools-preview` + runs the userland build before `kernel-build`. **Verification:** the 117-byte `.bin` disassembles at offset 0 to the `_start` prologue → `x0 = 0` (`HELLO_CONSOLE_CAP`) → `adr x1` greeting → `w2 = 0x15` (21 = len) → `bl console_write` → `x0 = 0` → `bl task_exit`. Gates: `cargo fmt --all --check`; host + kernel clippy `-D warnings`; **userland clippy** (`-p tyrne-userland-hello -p tyrne-user`); host tests **46 hal / 258 kernel / 58 test-hal / 3 doc** (unchanged — userland is bare-metal, outside the host set); `cargo kernel-build`; **`tools/smoke.sh --int` PASS** — the real 117-byte image loads (`entry = 0x800000`, `image bytes 117`, `stack bytes 4096`), boot reaches `all tasks complete`, only the 2 expected EL1-stub SVCs, **zero new fault class**. Miri unaffected (no kernel/host logic changed — only the embedded `USERSPACE_IMAGE` bytes + build infra). The `hello` image is embedded + loads **dormant** — running it in EL0 is [T-028](T-028-el0-userspace-wireup.md). +- **2026-06-01 — PR #41 review-round (relayed bots, verified against source):** **(High, fixed)** the userland crates broke `--workspace` host builds — `cargo check --workspace --exclude tyrne-bsp-qemu-virt` (the Miri-equivalent) failed on `tyrne-user`'s aarch64 asm. Cfg-gated the `svc` asm + `no_std`/`no_main` to `target_arch = "aarch64"` with degenerate host stubs (never run), so `--workspace` / Miri host-compile cleanly while keeping the ADR-0039 root-members structure (`cargo check --workspace --exclude tyrne-bsp-qemu-virt` now clean). UNSAFE-2026-0033 noted the asm is aarch64-gated. **(Low, fixed)** `hello.ld` now also ASSERTs `.got` empty (non-PIC); `tools/build-userland.sh` resolves `rust-objcopy` deterministically via `rustc --print target-libdir` (was a brittle sysroot `find`) + respects `CARGO_TARGET_DIR`; the §AC "denies enforce" typo fixed. **Skipped:** the "future-dated 2026-06-01" findings (today **is** 2026-06-01 — the dates are accurate, not future); the `build.rs` `format!`→`Path::join` idiom (the `/` paths work on Tyrne's Unix-only dev/CI — macOS + ubuntu — not a bug); a console message in the `panic_handler` (over-engineering for the trivial v1 `hello` — the exit code is the signal); `SyscallError: Display` (unused in v1 — YAGNI). Gates re-run green. diff --git a/docs/analysis/tasks/phase-b/T-028-el0-userspace-wireup.md b/docs/analysis/tasks/phase-b/T-028-el0-userspace-wireup.md new file mode 100644 index 0000000..bab4d00 --- /dev/null +++ b/docs/analysis/tasks/phase-b/T-028-el0-userspace-wireup.md @@ -0,0 +1,54 @@ +# T-028 — EL0 userspace wire-up + the `+0x400` round-trip smoke (B6 step 6) + +- **Phase:** B +- **Milestone:** B6 — First userspace "hello" (step 6 of the [B6 dependency-ordered sequence](../../../roadmap/phases/phase-b.md#milestone-b6--first-userspace-hello) — a true EL0 task takes the lower-EL `VBAR_EL1+0x400` vector, the dispatcher copies `console_write` from its `TTBR0_EL1`, `ERET` returns to EL0, and `task_exit` terminates it — the EL0↔EL1 round-trip B5's `+0x200` proxy could not prove) +- **Status:** Draft (opened in the [ADR-0039](../../../decisions/0039-userland-build-pipeline.md) Propose commit per [ADR-0025 §Rule 1](../../../decisions/0025-adr-governance-amendments.md); implementation follows [T-027](T-027-userland-build-pipeline.md)) +- **Created:** 2026-05-31 +- **Author:** @cemililik (+ Claude Opus 4.8 agent) +- **Dependencies:** [T-027](T-027-userland-build-pipeline.md) (the embedded real `hello` image this loads + runs); [T-024](T-024-task-create-from-image.md) (`task_create_from_image` — the `LoadedImage` → runnable `CapHandle{CapObject::Task}` bridge); [T-019](T-019-task-loader.md) (`load_image`); [T-023](T-023-el0-entry-context.md) / [ADR-0037](../../../decisions/0037-el0-entry-context.md) (`add_user_task` + `init_user_context` + the `enter_el0`/`ERET` trampoline + per-task `SP_EL1`); [T-025](T-025-user-access-translation.md) (gate #1 — the translate-based copy the `console_write` buffer is checked through); [T-026](T-026-current-task-cap-table.md) (gate #3 — the scheduler sources the running task's table + AS + window); [T-022](T-022-high-half-kernel-mapping.md) / [ADR-0033](../../../decisions/0033-kernel-high-half-migration.md) (the high-half regime that keeps the kernel reachable from the task's `TTBR0`). +- **Informs:** Closes B6's functional milestone — the first real userspace program runs in EL0. Triggers the **explicit EL0-boundary security review** carried forward from the [T-026 Definition of done](T-026-current-task-cap-table.md) (now that the boundary is attacker-observable). +- **ADRs required:** **None** — composes the already-Accepted mechanisms (ADR-0033 high-half, ADR-0037 EL0 entry, ADR-0038 translate, ADR-0030/0031 syscall ABI). Any new `unsafe` in the wire-up (e.g. a per-task kernel stack) gets an audit-log entry per [unsafe-policy](../../../standards/unsafe-policy.md). + +--- + +## User story + +As the kernel, I want to load the embedded `hello` image into its own address space, create a runnable EL0 task from it, schedule it, and `ERET` into it — so that the task greets via a real `console_write` syscall taking the lower-EL `+0x400` vector (copied from its own `TTBR0_EL1`, gate-#1-checked) and exits via `task_exit`, proving the EL0↔EL1 round-trip end-to-end. + +## Context + +Every B6 mechanism is in place and merged: high-half ([T-022](T-022-high-half-kernel-mapping.md)) keeps the kernel reachable from a task's `TTBR0`; the loader ([T-019](T-019-task-loader.md)) + `task_create_from_image` ([T-024](T-024-task-create-from-image.md)) turn a raw image into a runnable Task cap; `add_user_task` + `enter_el0` ([T-023](T-023-el0-entry-context.md)) seed and drop to EL0 with a register scrub; gate #1 ([T-025](T-025-user-access-translation.md)) translate-checks the `console_write` buffer; gate #3 ([T-026](T-026-current-task-cap-table.md)) sources the running task's table + AS + window. T-027 produces the real `hello` image. T-028 is the **wire-up**: it replaces the boot-time loader smoke (which loads + discards) with the full load → create → schedule → run path, and replaces the dormant `+0x200` fail-closed smoke with the live `+0x400` round-trip. + +## Acceptance criteria + +- [ ] **Load + create:** on boot, `load_image(USERSPACE_IMAGE, …)` → `task_create_from_image(&loaded, …)` mints the EL0 task cap (replacing the load-and-discard smoke). +- [ ] **AS-lifetime coupling** (security; the [T-024 §SEC-T024-01](T-024-task-create-from-image.md) carry-forward — "**before a real EL0 task runs**, a Task must not be able to outlive, or alias a reused slot of, its bound AS"): the minted Task stores an `AddressSpaceHandle` whose liveness is **not** coupled to the AS cap. T-028 satisfies the precondition by keeping the loaded AS cap **and** its arena slot live for the task's lifetime (the boot-created task + AS are never destroyed in v1) and **not** introducing an AS-destroy / slot-reuse path that could leave the Task's handle stale (a confused-deputy / use-after-free class). If a destroy path is added, it must check `references_object` across the task arena before freeing the AS. Called out in the security review. +- [ ] **Per-task capability table seeded:** a `CapabilityTable` for the EL0 task holding a **`DebugConsole`** capability (`CapRights::CONSOLE_WRITE`); bound to the task via `add_user_task`'s `cap_table` parameter (gate #3 — [T-026](T-026-current-task-cap-table.md)). The cap **must** be seeded so it resolves to the handle named by [T-027](T-027-userland-build-pipeline.md)'s `HELLO_CONSOLE_CAP` constant (the T-027↔T-028 interface — the seeding does not duplicate the value, it satisfies the constant). +- [ ] **Per-task kernel stack:** a valid, 16-byte-aligned `SP_EL1` kernel stack (**floor: at least one 4 KiB page**; ≥ the 272-byte trap frame + the gate-#1 dispatch call-tree depth) passed to `add_user_task` (the EL0→EL1 trap runs the trampoline's `sub sp, sp, #272` on `SP_EL1`, which the CPU does **not** auto-initialise — [T-023](T-023-el0-entry-context.md) gate #2). Source (BSP static vs PMM-allocated) decided in implementation; asserted non-null + aligned. +- [ ] **Schedule + run:** `add_user_task(…)` enqueues the task Ready; the scheduler `start()`/dispatch activates the task's AS (`TTBR0_EL1` installed, `EPD0` cleared — the activation hook fires by construction, [ADR-0028](../../../decisions/0028-address-space-data-structure.md)) and `enter_el0` `ERET`s into EL0 at `entry_va`. +- [ ] **`+0x400` round-trip:** the `hello` `console_write` SVC traps to the **lower-EL** sync vector (`VBAR_EL1+0x400`), `syscall_entry` resolves the task's own table + window + AS (gate #3), gate #1 translates the buffer through the task's `TTBR0`, the dispatcher copies + emits, `x0` = status, the trampoline `ERET`s back to EL0 — the round-trip B5's `+0x200` proxy could not prove. +- [ ] **Clean exit:** the `hello` `task_exit` SVC terminates the task; the kernel reports termination and continues (the cooperative demo still reaches `tyrne: all tasks complete` or the documented post-EL0 shutdown line). +- [ ] **QEMU smoke (acceptance, debug profile):** in a **debug build** (`console_write`, syscall number 5, is **debug-gated** — [ADR-0031](../../../decisions/0031-initial-syscall-set.md) / [`dispatch.rs`](../../../../kernel/src/syscall/dispatch.rs); in **release** it returns `BadSyscallNumber` and emits no output, so the greeting is observable only in debug) the serial trace shows the kernel greeting, then **"hello from userspace"** (the chosen greeting) in the correct order, then clean `task_exit`; `-d int` shows the `+0x400` lower-EL `SVC` exception (EL0→EL1) with a clean `ERET`; **no new fault class**. The expected **release** behaviour (greeting suppressed; `console_write` → `BadSyscallNumber`; the `+0x400` trap + `task_exit` still occur) is documented in the PR. +- [ ] **Smoke gate updated:** [`tools/smoke.sh`](../../../../tools/smoke.sh) gates (rc=1) on the **userspace greeting marker** (e.g. `grep -q "hello from userspace"`) **in addition to** the existing `"all tasks complete"` check — otherwise the greeting could be absent from the trace and the smoke would still PASS, so the acceptance above would not actually be enforced by the gate. (The greeting-marker gate is debug-profile; the release smoke gates on the trap + completion only.) +- [ ] **Explicit EL0-boundary security review** (the [T-026](T-026-current-task-cap-table.md) carry-forward DoD): a review of the now-attacker-observable boundary covering gate #1 ([UNSAFE-2026-0030](../../../audits/unsafe-log.md)), gate #2 ([UNSAFE-2026-0032](../../../audits/unsafe-log.md)), the gate-#3 cap-table sourcing (UNSAFE-2026-0014 Amendment), the register scrub, **and the AS-lifetime coupling (SEC-T024-01)** — filed under `docs/analysis/reviews/security-reviews/`. +- [ ] **All gates green:** `cargo fmt --all --check`; host + kernel clippy `-D warnings`; host tests (+ any wire-up regression tests) green; `cargo kernel-build`; `tools/smoke.sh` PASS (the round-trip trace above); Miri 0 UB; any new `unsafe` audited. + +## Out of scope + +- **The build pipeline + the `hello`/`tyrne-user` crates** — [T-027](T-027-userland-build-pipeline.md). +- **Multiple EL0 tasks / per-task cap-table arena** — later (the BSP-static single-task shape suffices for B6; a registry is a later ADR if outgrown). +- **Per-section permissions (ADR-0034)** + **EL0 non-`SVC` fault containment** (illegal instruction / unmapped deref) — later-phase (the dispatcher is already panic-free; fault containment is Phase E / flag K3-4). +- **The B6 closure trio** (guide + performance cycle + Phase B retrospective) — [B6 step 7](../../../roadmap/phases/phase-b.md#milestone-b6--first-userspace-hello). + +## Approach + +Replace the boot-time loader smoke with the full path: `load_image` → `task_create_from_image` → seed a `CapabilityTable` with a `DebugConsole` cap at the handle `hello` names → `add_user_task` (binding the table + a per-task `SP_EL1` stack) → let the scheduler activate the task's AS and `enter_el0`. The `hello` greeting buffer is a user VA inside `[entry_va, stack_top_va)`, so gate #1 translates it through the task's own `TTBR0` and the gate-#3 window admits it. Retire the dormant `+0x200` fail-closed smoke (its property is now covered by host tests) in favour of the live `+0x400` round-trip. Because this is the first attacker-observable EL0 boundary, the explicit security review is part of the Definition of done — not deferred. + +## Definition of done + +All acceptance criteria checked; gates green (incl. Miri); the `+0x400` round-trip demonstrated in the smoke trace; the explicit EL0-boundary security review filed (**Approve** before merge); `current.md` + [phase-b.md §B6](../../../roadmap/phases/phase-b.md#milestone-b6--first-userspace-hello) updated. Lands **after** [T-027](T-027-userland-build-pipeline.md). Closes B6's functional milestone; the closure trio (step 7 — Phase B retrospective) follows. + +## Review history + +- **2026-05-31 — opened Draft** in the [ADR-0039](../../../decisions/0039-userland-build-pipeline.md) Propose commit (the ADR's dependency chain names it — step 6; [ADR-0025 §Rule 1](../../../decisions/0025-adr-governance-amendments.md)). Composes already-Accepted mechanisms; no ADR of its own. Implementation follows T-027. +- **2026-05-31 — pre-Accept review-round (relayed, verified against source):** **(HIGH)** added the **AS-lifetime-coupling AC** — the [T-024 §SEC-T024-01](T-024-task-create-from-image.md) carry-forward ("before a real EL0 task runs, a Task must not outlive its bound AS") was not carried into this wire-up. **(Med)** clarified the QEMU-smoke AC is **debug-profile** (`console_write` is debug-gated — release returns `BadSyscallNumber`, suppressing the greeting) + documented the release behaviour. **(Med)** added the **`tools/smoke.sh` greeting-marker gate AC** (the existing gate only checks `"all tasks complete"`, so the greeting could be absent and smoke still PASS — the acceptance would not be enforced). **(Low)** pinned the kernel-stack floor at ≥ one 4 KiB page; linked the cap-seeding to T-027's `HELLO_CONSOLE_CAP`; added SEC-T024-01 to the security-review coverage. diff --git a/docs/audits/unsafe-log.md b/docs/audits/unsafe-log.md index 8287b52..cc7f389 100644 --- a/docs/audits/unsafe-log.md +++ b/docs/audits/unsafe-log.md @@ -739,3 +739,22 @@ Neither change touches the `copy_nonoverlapping` site itself; both correct contr - **Trap EL0 FP instead of scrubbing SIMD.** Rejected for v1: `CPACR_EL1.FPEN` is global (`boot.s` enables FP at EL0+EL1); trapping EL0 FP would need a per-system CPACR change + an unhandled-trap path. Scrubbing `v0`–`v31` is self-contained and conservative. - **Reviewed by:** @cemililik (+ Claude Opus 4.8 agent). Security-sensitive (the EL1→EL0 trust-boundary first-entry primitive — a wrong `SPSR_EL1` is a privilege/isolation defect; an un-scrubbed file is a disclosure) → second-reviewer required per [unsafe-policy §Review.4](../standards/unsafe-policy.md). The 2026-05-31 review-round added the register-scrub invariant (HIGH) and corrected the SPSR write to the register form. - **Status:** Active. **Dormant in v1** — no caller yet constructs a userspace task (`Scheduler::add_user_task` is unused until the B6 `task_create_from_image` + `userland/hello` wire-up); T-023 verifies the asm assembles + the kernel boots unchanged, plus the BSP/scheduler plumbing via the `init_user_context` host tests (the *scheduler route*; the real `QemuVirtCpu` slot write + the scrub are verified by code/asm audit + `kernel-build`, the BSP being bare-metal and outside the host-test set). The runtime `ERET`-into-EL0 proof (the real `+0x400` round-trip) is the B6 wire-up task — the same staging T-021's `+0x400` handler used. + +### UNSAFE-2026-0033 — `tyrne-user` EL0 syscall `svc #0` shims + +- **Introduced:** 2026-06-01, [T-027 — userland build pipeline](../analysis/tasks/phase-b/T-027-userland-build-pipeline.md) / [ADR-0039](../decisions/0039-userland-build-pipeline.md). The **userspace-side** inline-asm wrappers a userspace program calls to invoke a syscall — the first `unsafe` in userspace code (per [unsafe-policy §Scope](../standards/unsafe-policy.md), "the rules apply equally in kernel, HAL, and userspace code"). +- **Location:** [`userland/tyrne-user/src/lib.rs`](../../userland/tyrne-user/src/lib.rs) — two `unsafe { core::arch::asm!("svc #0", …) }` blocks: `console_write` (returns) and `task_exit` (`options(noreturn)`). Linked into the `tyrne-userland-hello` raw-flat image; runs at **EL0**, not in the kernel. +- **Operation:** a single `svc #0` per wrapper, with the [ADR-0030](../decisions/0030-syscall-abi.md) register frame loaded around it: + - `console_write(cap, buf)`: `x8 = 5` (ConsoleWrite), `inout x0 = cap → status`, `inout x1 = buf.as_ptr() → bytes-written`, `in x2 = buf.len()`, `x3..x7` clobbered. Returns `Ok(written)` iff `x0 == 0`, else `Err(status)`. + - `task_exit(code)`: `x8 = 4` (TaskExit), `in x0 = code`, `options(noreturn)`. +- **Invariants relied on:** + - **The `svc` traps to EL1; the kernel side is the trusted, panic-free dispatcher.** From EL0 this `svc` is the *only* way to enter the kernel; the EL1 handler ([`syscall_entry`](../../bsp-qemu-virt/src/syscall.rs) → `dispatch`) is panic-free and resolves the call against the **running task's own** capability table + address space (gate #3, [T-026](../analysis/tasks/phase-b/T-026-current-task-cap-table.md)). The shim cannot escalate privilege: it can only name capabilities the task already holds (`cap` is a packed handle into the task's own table) and copy from buffers gate #1 ([T-025](../analysis/tasks/phase-b/T-025-user-access-translation.md)) translates through the task's own `TTBR0`. + - **No memory aliasing / no out-of-bounds.** `console_write` passes `buf.as_ptr()` + `buf.len()`; `buf` is a shared borrow read by the kernel (at most `len` bytes, USER-checked). The shim writes no memory. `task_exit` accesses no memory. + - **ABI register discipline.** The `inout`/`in`/`lateout` operands match ADR-0030 exactly; `x3..x7` are declared clobbered (the kernel may overwrite them as payload/scratch). `task_exit`'s `options(noreturn)` is sound because the kernel terminates the task and never returns to EL0 — the `-> !` signature holds. + - **Syscall numbers restated, not imported.** `4`/`5` are const in this crate (no kernel dependency, per ADR-0039); the kernel authority is `SyscallNumber::as_u64`. Drift is caught by the kernel's ABI host tests + this entry's cross-reference. +- **Rejected alternatives:** + - **Express the `svc` in safe Rust.** Impossible — a supervisor call has no safe-Rust equivalent; inline asm is the minimal architected surface, and the wrapper *is* the safe boundary the rest of userspace calls. + - **Import the kernel's `SyscallNumber`/ABI types.** Rejected per [ADR-0039](../decisions/0039-userland-build-pipeline.md): userspace must not depend on kernel internals; the contract is the stable ABI, restated userspace-side. + - **`options(nostack)` / `preserves_flags`.** Not asserted (conservative): the wrappers do not claim the asm leaves the stack/flags untouched, so no over-assertion can become UB if the contract is wrong. +- **Reviewed by:** @cemililik (+ Claude Opus 4.8 agent). The `unsafe` is confined to two tiny shims and is the *userspace* side of an already-audited trap (the EL1 handler is UNSAFE-2026-0029; the boundary is UNSAFE-2026-0014/0030/0032). The security-critical direction (privilege escalation, memory disclosure) is the **kernel→user** boundary, audited there and reviewed for T-028's first real run; this entry covers the user→kernel call shim. +- **Status:** Active. **Dormant in v1** — the `hello` image is embedded (T-027) but not yet run; the shims execute for the first time when [T-028](../analysis/tasks/phase-b/T-028-el0-userspace-wireup.md) wires the EL0 task and the `+0x400` round-trip fires. T-027 verifies the crate compiles + `rust-objcopy`s to a 117-byte raw-flat image whose offset-0 disassembly is the `_start` → `console_write` → `task_exit` sequence (the userland crates are bare-metal, outside the host-test set; verified by `cargo clippy --target aarch64-unknown-none` + disassembly). The `svc` asm is `#[cfg(target_arch = "aarch64")]`-gated; non-aarch64 (`--workspace` / Miri host) builds get safe `unimplemented!()` stubs with **no `unsafe`**, so the `unsafe` this entry covers exists only on the aarch64 target. diff --git a/docs/decisions/0039-userland-build-pipeline.md b/docs/decisions/0039-userland-build-pipeline.md new file mode 100644 index 0000000..b2887d3 --- /dev/null +++ b/docs/decisions/0039-userland-build-pipeline.md @@ -0,0 +1,115 @@ +# 0039 — Userland build pipeline (B6 — `userland/hello` + `tyrne-user` + raw-flat embed orchestration) + +- **Status:** Accepted +- **Date:** 2026-05-31 +- **Deciders:** @cemililik + +## Context + +[ADR-0029](0029-initial-userspace-image-format.md) settled the userspace image **format** (raw flat binary, entry at offset 0) and explicitly **deferred the build orchestration**: its §Decision-outcome "Build pipeline (B6)" row says B6's `userland/hello/` crate is built via `cargo build --target aarch64-unknown-none` + `objcopy -O binary` and embedded via `include_bytes!`, with "the exact path lands with B6." B4 ([T-019](../analysis/tasks/phase-b/T-019-task-loader.md)) shipped a hand-coded placeholder blob (`USERSPACE_IMAGE` at [`bsp-qemu-virt/src/main.rs`](../../bsp-qemu-virt/src/main.rs) — `mov w0, #42; ret`); that is the only userspace image in tree today, and it is loaded-then-discarded by a boot-time loader smoke (never run in EL0). + +With B6's three [T-021 carry-forward gates](../roadmap/phases/phase-b.md#t-021-carry-forward-gates-must-close-before-a-real-el0-task-runs) now closed (gate #1 [T-025](../analysis/tasks/phase-b/T-025-user-access-translation.md), gate #2 [T-023](../analysis/tasks/phase-b/T-023-el0-entry-context.md) EL0-context, gate #3 [T-026](../analysis/tasks/phase-b/T-026-current-task-cap-table.md)), the high-half prerequisite ([T-022](../analysis/tasks/phase-b/T-022-high-half-kernel-mapping.md) / [ADR-0033](0033-kernel-high-half-migration.md)) in place, and `task_create_from_image` ([T-024](../analysis/tasks/phase-b/T-024-task-create-from-image.md)) merged, the remaining B6 work is to (1) produce a **real** userspace program from Rust source and embed it, and (2) wire it to run in EL0. This ADR settles the **process** ADR-0029 deferred: **how** the userland crate is built, objcopy'd, and embedded; **where** the `userland/hello` + `tyrne-user` crates live in the workspace; **which** objcopy tool is used; and **where** the userspace base VA lives so the loader and the userspace linker script cannot drift. + +The stakes are precedent, not just plumbing. This is the project's **first** cross-target build artifact (a binary compiled for a *different* privilege domain and embedded into the kernel image). Getting the orchestration wrong has three concrete failure modes: a `build.rs` that invokes `cargo` recursively can deadlock on the workspace target-directory lock; an integration that builds the bare-metal userland under host commands (`cargo test`/`cargo build`) breaks the host test suite; and "build magic" hidden in a build script is hard to audit in a security-first kernel. The shape chosen here is the shape every future userland crate (B7+) will follow. + +## Decision drivers + +- **Host commands stay untouched.** `cargo host-test` / `cargo build` / `cargo host-clippy` (workspace `default-members` = kernel, hal, test-hal) must not attempt to cross-compile the bare-metal userland — exactly as `bsp-qemu-virt` is already excluded from `default-members`. +- **No new Cargo dependency.** [infrastructure.md](../standards/infrastructure.md) §Dependency policy and the [ADR-0029](0029-initial-userspace-image-format.md) toolchain-alignment driver push to avoid `cargo-binutils` (it would trigger the **K3-8 `cargo-vet init`** flag, [phase-b §Flags](../roadmap/phases/phase-b.md#flags-to-resolve-during-b6)). The `llvm-tools-preview` component — which ships `rust-objcopy` — is **already pinned** ([`rust-toolchain.toml`](../../rust-toolchain.toml)). +- **Auditable, no magic.** A security-first kernel ([CLAUDE.md non-negotiable #1](../../CLAUDE.md)) favours an explicit, reviewable orchestrator over implicit build-script side effects that silently shell out to `cargo`. +- **Reviewer / CI ergonomics.** The canonical build + smoke path must stay simple. The repo already has [`tools/smoke.sh`](../../tools/smoke.sh) as the canonical integration entry point. +- **Smallest shape that works for v1.** B6 ships **one** userspace program. The orchestration should not pay for multi-binary generality the project does not yet have (the [ADR-0027](0027-kernel-virtual-memory-layout.md) / [ADR-0035](0035-physical-memory-manager.md) "smallest shape now, defer richness" pattern). +- **Single source-of-truth for the userspace base VA.** ADR-0029 §Consequences flagged "linker-script awareness leaks into the loader … spec drift potential." The base VA (`0x0080_0000`) is read by the kernel loader *and* by the userspace linker script; a divergence is a silent, hard-to-debug wrong-VA load. +- **Scales to B7+ without a rewrite.** The chosen shape should have a named, additive upgrade path when a second userspace program lands. + +## Considered options + +1. **`tools/build-userland.sh` pre-build step** — an explicit shell script (peer to `tools/smoke.sh`) runs `cargo build` for the userland crate + `rust-objcopy -O binary`, writing a git-ignored `.bin`; the BSP `build.rs` `include_bytes!`s it (with a clear error if absent); `tools/smoke.sh` runs the script before the kernel build. +2. **BSP `build.rs` with a separate `CARGO_TARGET_DIR`** — the BSP build script invokes `cargo build -p tyrne-userland-hello` into an isolated target dir (the standard nested-cargo lock mitigation), then objcopy + embed; `cargo kernel-build` "just works" with no pre-step. +3. **`cargo xtask` orchestrator** — a workspace `xtask` crate with a `build-userland` subcommand; `cargo` aliases chain it before the kernel build. +4. **Committed pre-built `.bin`** — check the objcopy output into the repo; the BSP `build.rs` only `include_bytes!`s it; a `tools/` script regenerates it on source change. + +## Decision outcome + +Chosen option: **Option 1 — `tools/build-userland.sh`**, with these bundled sub-decisions: + +- **Crates.** Add two workspace members under a new `userland/` directory: `userland/hello/` (package `tyrne-userland-hello`, `#![no_std] #![no_main]`, the raw-flat program) and `userland/tyrne-user/` (package `tyrne-user`, `#![no_std]` library of **safe** syscall wrappers `console_write` / `task_exit`, which `hello` depends on). Both are added to `members` and **excluded from `default-members`** (so host commands skip them, mirroring `bsp-qemu-virt`). `tyrne-` package-name prefix per [ADR-0006](0006-workspace-layout.md). +- **objcopy.** `rust-objcopy -O binary` from the already-pinned `llvm-tools-preview` component — **no Cargo dependency**, so the K3-8 `cargo-vet` flag stays unfired. The script resolves the binary via the active toolchain (`rustc --print sysroot` + the `llvm-tools` bin dir) and fails with a clear diagnostic if the component is missing. +- **Base-VA source-of-truth.** A single `pub const USERSPACE_IMAGE_BASE_VA` lives Rust-side (read by the BSP loader call site and any kernel consumer); the userspace linker script **restates** the same literal with a `keep-in-sync-with` comment, because a linker script cannot import a Rust const. A `ld --defsym`-from-const upgrade is named for full drift-elimination when it earns its keep. +- **Artifact.** The `.bin` is **git-ignored** (regenerated from source; no binary blob in the repo — the Rust source stays the auditable truth). The BSP `build.rs` adds `rerun-if-changed` on it and `include_bytes!`s a stable path; if the file is absent it emits a `panic!` naming `tools/build-userland.sh`. `tools/smoke.sh` runs the script before `cargo kernel-build`, so the canonical path is unchanged. + +Option 1 wins on the four most load-bearing drivers: it keeps host commands untouched (the userland is never in a host build set), adds no Cargo dependency, is fully auditable (a shell script a reviewer reads at a glance — the same shape as `tools/smoke.sh`), and is the smallest shape for one binary (no new crate beyond the two the milestone requires). It avoids the nested-cargo deadlock entirely (the script runs `cargo` at top level, not inside a `build.rs`) and commits no binary blob. Its cost — a build step before a bare `cargo kernel-build` — is mitigated by the build script's clear error and by `tools/smoke.sh` chaining it, and the **`cargo xtask` pattern (Option 3) is the named, additive upgrade** when B7+ introduces multiple userspace programs (a shell `for`-loop or an `xtask build-userland` subcommand drops in without disturbing the kernel build). + +### Simulation + +**Not applicable** — this ADR settles a single-shape process / build-orchestration decision; there is no runtime state machine to simulate. (The EL0↔EL1 round-trip the resulting image exercises is the subject of [T-028](../analysis/tasks/phase-b/T-028-el0-userspace-wireup.md)'s wire-up, walked through [ADR-0030 §Simulation](0030-syscall-abi.md#simulation) and [ADR-0037](0037-el0-entry-context.md); this ADR's subject is how the bytes are produced, not how they run.) + +### Dependency chain + +For this decision to be fully in effect: + +```text +1. Raw-flat image format (entry at offset 0, USER|EXECUTE image) — ADR-0029 (Accepted) +2. rust-objcopy via the llvm-tools-preview component — rust-toolchain.toml (pinned, present) +3. Loader + LoadedImage + task_create_from_image + the syscall gates — T-019 / T-024 / T-025 / T-026 (Done / merged) +4. EL0 entry context + enter-EL0 path + add_user_task — T-023 / ADR-0037 (Done) +5. The build pipeline + userland/hello + tyrne-user crates — T-027 (Draft, opens with this ADR) +6. EL0 wire-up + the +0x400 round-trip QEMU smoke — T-028 (Draft, opens with this ADR) +``` + +T-027 closes steps 5 (the build pipeline + the two crates + the embedded real image, replacing the placeholder; dormant — not yet run). T-028 closes step 6 (load → `task_create_from_image` → `add_user_task` → the scheduler runs the task in EL0; the real round-trip) and triggers the explicit EL0-boundary security review the [T-026 Definition of done](../analysis/tasks/phase-b/T-026-current-task-cap-table.md) carries forward. Both slots are opened at `Draft` in the same commit as this ADR per [ADR-0025 §Rule 1](0025-adr-governance-amendments.md). + +## Consequences + +### Positive + +- **No new dependency, no new attack surface in the build.** `rust-objcopy` is already in the pinned toolchain; the `cargo-vet` K3-8 flag stays unfired; the orchestrator is hand-written shell + Rust the project owns end-to-end. +- **Host suite is provably unaffected.** The userland crates sit outside `default-members`; `cargo host-test` / `cargo build` / `cargo host-clippy` never touch them, exactly as `bsp-qemu-virt` is excluded today — a pattern already proven in tree. +- **Auditable.** The build order is a short shell script in `tools/`, reviewed like any source file; the embedded image is reproduced from committed Rust source, so the byte stream's provenance is the crate, not an opaque blob. +- **No nested-cargo deadlock.** The script invokes `cargo` at top level (not from inside a `build.rs`), so there is no workspace target-directory lock contention or recursive-cargo fragility. + +### Negative + +- **A bare `cargo kernel-build` needs the `.bin` present first.** On a fresh checkout, `cargo kernel-build` alone fails until `tools/build-userland.sh` has run. *Mitigation:* the BSP `build.rs` emits a `panic!` that names the script; `tools/smoke.sh` (the canonical **local** entry point) chains the script before the kernel build; the dev loop is `tools/build-userland.sh && cargo kernel-build` or simply `tools/smoke.sh`. (CI is a separate concern — see the next item.) We accept this small ergonomic cost in exchange for no build-script magic and no committed binary. +- **The base VA is stated in two places** (the Rust const and the userspace linker script), because LD cannot import a Rust const. *Mitigation:* a `keep-in-sync-with` comment on the linker-script line, the same discipline ADR-0029 already imposes ("linker-script awareness leaks into the loader"); the `ld --defsym`-from-const path is named for when full drift-elimination earns its keep (B7+ multi-program / multi-BSP). +- **The `.bin` is git-ignored, so CI must build it — and the existing CI `kernel-build` job will break without it.** CI today runs `cargo kernel-build` (and `kernel-clippy`) directly on a clean checkout ([`.github/workflows/ci.yml`](../../.github/workflows/ci.yml)); post-T-027, that job hits the BSP `build.rs` missing-`.bin` `panic!`, so **T-027 must add the userland build step to the CI workflow ahead of the `kernel-build` / `kernel-clippy` jobs** (and the maintainer-launched `tools/smoke.sh` already chains it locally). *Mitigation:* one step in the workflow; standard for embedded projects. The alternative (committing the blob) trades it for a binary in git history and a freshness-drift check, which we reject. (The QEMU-smoke-in-CI regression gate itself remains the conditionally-deferred flag K3-7 — [phase-b §Flags](../roadmap/phases/phase-b.md#flags-to-resolve-during-b6); no QEMU-smoke CI job exists today.) + +### Neutral + +- **`cargo xtask` is the named scaling path, not a rejection.** When a second userspace program lands (B7+), the shell script either grows a loop or is replaced by an `xtask build-userland` subcommand; nothing in T-027's shape blocks that, and the kernel build is unaffected either way. +- **Reversible.** Switching to the `build.rs`-auto (Option 2) or committed-`.bin` (Option 4) shape later is a localized change to `build.rs` + `tools/`, touching no kernel or userland source. +- **The userland inherits the `[target.aarch64-unknown-none]` rustflags** (`panic=abort`, `force-frame-pointers`). Both are correct for a userspace blob (no unwinder; frame pointers are negligible overhead for a ~100-byte program); a future performance-sensitive userland can add a crate-local `.cargo/config.toml` override. + +## Pros and cons of the options + +### Option 1 — `tools/build-userland.sh` pre-build step (chosen) + +- **Pro:** Smallest shape for one binary; no new crate; explicit + auditable (peer to `tools/smoke.sh`); no nested-cargo; no Cargo dep; no committed binary; host commands untouched. +- **Pro:** `xtask` remains a clean additive upgrade for B7+ multi-binary. +- **Con:** A bare `cargo kernel-build` needs the script run first (mitigated by a clear `build.rs` error + `tools/smoke.sh` chaining). +- **Con:** Shell is less portable than Rust (the project's dev/CI hosts are Unix; `tools/smoke.sh` already assumes this). + +### Option 2 — BSP `build.rs` with a separate `CARGO_TARGET_DIR` + +- **Pro:** `cargo kernel-build` "just works" with no pre-step; the standard nested-cargo mitigation (isolated target dir) is well-trodden (bootimage, embedded Rust). +- **Con:** A build script that shells out to `cargo` is exactly the implicit "build magic" an auditable kernel should avoid; the embedded artifact is hidden under `target/**/OUT_DIR` (reviewers cannot inspect it without rebuilding); adds 5–15 s to every kernel build; couples to Cargo internals. + +### Option 3 — `cargo xtask` orchestrator + +- **Pro:** Explicit, scales cleanly to multiple userspace programs; idiomatic in the Rust ecosystem; `cargo` alias keeps the one-liner. +- **Con:** Adds a whole workspace crate for what B6 needs once — more than the "smallest shape" the single-binary milestone justifies; a cargo alias cannot itself chain a shell step, so the orchestration still needs the alias wired carefully. Reserved as the **named B7+ upgrade**, not the v1 choice. + +### Option 4 — Committed pre-built `.bin` + +- **Pro:** `cargo kernel-build` is truly standalone (the blob is in tree); reviewers can disassemble the committed artifact. +- **Con:** Puts a binary blob in git history (drift risk if source changes but the blob is not regenerated; a freshness check becomes a CI burden); a security review of a committed binary means trusting/disassembling it rather than reading the source it came from — the opposite of the "Rust source is the auditable truth" posture. + +## References + +- [ADR-0029 — Initial userspace image format](0029-initial-userspace-image-format.md) — the raw-flat **format** this ADR builds the pipeline for; its §Decision-outcome deferred the build orchestration to B6. +- [ADR-0006 — Workspace layout](0006-workspace-layout.md) — the `members` / `default-members` split and the `tyrne-` package-name prefix this ADR extends to the userland crates. +- [ADR-0027 — Kernel virtual memory layout](0027-kernel-virtual-memory-layout.md) — the `TTBR0_EL1` userspace VA range the base VA (`0x0080_0000`) sits within. +- [rust-toolchain.toml](../../rust-toolchain.toml) — the pinned `llvm-tools-preview` component providing `rust-objcopy`. +- [tools/smoke.sh](../../tools/smoke.sh) — the canonical integration entry point the build script chains into. +- [`cargo-xtask` pattern (matklad)](https://github.com/matklad/cargo-xtask) — the named B7+ multi-binary upgrade path. +- [The `embedded-bootimage` / blog_os build-step pattern](https://os.phil-opp.com/) — prior art for objcopy-and-embed orchestration of a bare-metal artifact. diff --git a/docs/decisions/README.md b/docs/decisions/README.md index 7643c02..f1aeb14 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -66,6 +66,7 @@ Each ADR contains: | 0036 | [QEMU virt is GICv2 / no-IOMMU in v1; corrects GICv3/SMMUv3 in ADR-0004/0006/0012](0036-qemu-virt-gicv2-no-iommu-v1.md) | Accepted | 2026-05-22 | | 0037 | [EL0 entry context (B6 — userspace register file + enter-EL0/`ERET` path + per-task `SP_EL1`)](0037-el0-entry-context.md) | Accepted | 2026-05-31 | | 0038 | [`Mmu::translate` read-only walk + per-task user-access translation (B6 gate #1)](0038-mmu-translate-and-user-access.md) | Accepted | 2026-05-31 | +| 0039 | [Userland build pipeline (B6 — `userland/hello` + `tyrne-user` + raw-flat embed orchestration)](0039-userland-build-pipeline.md) | Accepted | 2026-05-31 | > **Numbering gaps.** Slot **0034** is intentionally reserved, not missing: 0034 (kernel-image section permissions) is a named-but-unallocated placeholder forward-flagged in ADR-0027. No file exists for it yet; it opens when the corresponding work surfaces (the first attacker-observable EL0 execution — likely B6). (Slot **0033** (high-half migration) was filed `Proposed` on 2026-05-29 to open B6 and `Accepted` on 2026-05-30, and is no longer a gap; slots **0030**/**0031** were filed and `Accepted` on 2026-05-29 for B5.) ADR numbers are stable history and are never renumbered. diff --git a/docs/roadmap/current.md b/docs/roadmap/current.md index ee9d3d5..e811dcb 100644 --- a/docs/roadmap/current.md +++ b/docs/roadmap/current.md @@ -4,6 +4,8 @@ A short pointer file updated as work progresses. For the full plan see [`phases/ --- +> **2026-06-01 update — B6 step 5 landed: ADR-0039 Accepted + T-027 (`userland/hello` + `tyrne-user` + the raw-flat build pipeline) implemented, In Review.** [ADR-0039](../decisions/0039-userland-build-pipeline.md) **Accepted** (the build orchestration ADR-0029 deferred — settled via two review rounds: a relayed 8-fix round + an adversarial 5-lens/81-agent pass, 37/38 refuted). **[T-027](../analysis/tasks/phase-b/T-027-userland-build-pipeline.md)** builds Tyrne's first **real** userspace program from Rust source: a `userland/tyrne-user` crate (safe `console_write`/`task_exit` shims over the `svc #0` ABI — UNSAFE-2026-0033, the first userspace `unsafe`) + a `userland/hello` `#![no_std] #![no_main]` crate (`_start` at offset 0 → greet → `task_exit`; an `hello.ld` linker script fixing the entry at `0x0080_0000`, ASSERT-no-`.data`/`.bss`). The pipeline (per ADR-0039): `tools/build-userland.sh` → `cargo build` → **`rust-objcopy -O binary`** (from the pinned `llvm-tools-preview` — **no Cargo dep**, K3-8 unfired) → a git-ignored `hello.bin`, embedded into the BSP via `include_bytes!` (replacing the hand-coded `mov w0,#42` placeholder; `build.rs` `assert!`s the `.bin` exists). Both crates are `default-members`-excluded workspace members (host commands skip them). `tools/smoke.sh` + CI build userland first (CI gains `llvm-tools-preview` + a build step — the one adversarial-confirmed finding: else the clean-checkout `kernel-build` job would hit the missing-`.bin` panic). **The 117-byte image disassembles at offset 0 to `_start` → `x0=0` (`HELLO_CONSOLE_CAP`) → greeting → `bl console_write` → `bl task_exit`**, and **loads dormant** under the smoke (`entry=0x800000`, `image bytes 117`, `stack bytes 4096`) — **running it in EL0 is [T-028](../analysis/tasks/phase-b/T-028-el0-userspace-wireup.md)** (the `+0x400` round-trip + the explicit EL0-boundary security review). Gates: **host tests 258 kernel / 46 hal / 58 test-hal / 3 doc** (unchanged — userland is bare-metal), fmt, host + kernel + **userland** clippy `-D warnings`, kernel build, `tools/smoke.sh --int` PASS (image loads, `all tasks complete`, 2 stub SVCs, **zero new fault class**), Miri unaffected. **Next:** [T-028](../analysis/tasks/phase-b/T-028-el0-userspace-wireup.md) — wire the EL0 task (load → `task_create_from_image` → seed a `DebugConsole` cap at `HELLO_CONSOLE_CAP` → `add_user_task` → run) + the `+0x400` smoke + the security review; then B6 closure (Phase B retrospective trio). This banner supersedes the gate-#3 banner below. +> > **2026-05-31 update — B6 gate #3 closed: T-026 (current-task capability table + per-task window in `syscall_entry`) implemented, In Review. All three T-021 carry-forward gates now closed.** **[T-026](../analysis/tasks/phase-b/T-026-current-task-cap-table.md)** (on `t-026-current-task-cap-table` off the merged T-025 `f21eece`; **no new ADR** — rides ADR-0030/0021/0014) makes `syscall_entry` source the **running EL0 task's** capability table + address space + user-access window from `SCHED.current` (scheduler `task_cap_tables` / `task_user_windows` parallel arrays + `current_*` accessors, written by `add_user_task`), **failing closed** when no task is current — the empty `FAILCLOSED_TABLE` (every lookup → `InvalidHandle`) + an empty window. **Control-plane** (`task_yield`/`task_exit`, which consult no capability) is gated in the dispatcher on a new `SyscallContext.has_current_task` (→ `InvalidHandle` when no current task — the H2 review fix; host-testable, since the BSP can't construct the `#[non_exhaustive]` `SyscallError`). `AddressSpace::inner()` widened to `pub` (read-only — no cap-gate bypass) so the BSP can pass the task's `&QemuVirtAddressSpace` to gate-#1 `Mmu::translate`. The `+0x200` smoke is **re-sequenced after `SCHED` init** (so `SCHED.current` is `None`) and now demonstrates gate-#3 **fail-closed** (`console_write` → `InvalidHandle` `0x102`; `bad-number` → `BadSyscallNumber`); `SYSCALL_STUB_TABLE` retired. Two relayed pre-impl reviews hardened the design (H1 window persistence, H2 control-plane, M4 mandatory audit). Mandatory **UNSAFE-2026-0014 Amendment** (the cap-table-pointer deref) + a UNSAFE-2026-0029 Amendment (syscall-arc statics + smoke re-sequence). Gates: **host tests 257 kernel / 46 hal / 58 test-hal / 3 doc**, fmt, host + kernel clippy `-D warnings`, kernel build, QEMU smoke (2 SVC, clean `ERET`, **zero new fault class**), **Miri 0 UB**. **All three [T-021 carry-forward gates](phases/phase-b.md#t-021-carry-forward-gates-must-close-before-a-real-el0-task-runs) are closed** (#1 mechanism T-025, #2 T-023, #3 T-026). **Next:** the `tyrne-user` + `userland/hello` crate + `cargo → objcopy → include_bytes!` build pipeline, then the EL0 `+0x400` wire-up smoke, then B6 closure. **Carry-forward DoD:** the explicit EL0-boundary security review (UNSAFE-2026-0030/0032 + cap-table sourcing) before a real EL0 task runs. This banner supersedes the gate-#1 banner below. > > **2026-05-31 update — B6 gate #1 mechanism landed: ADR-0038 Accepted + T-025 (`Mmu::translate` + per-page user-access translation) implemented, In Review.** [ADR-0038](../decisions/0038-mmu-translate-and-user-access.md) **Accepted** (the read-only `Mmu::translate` walk query — the realised [ADR-0009](../decisions/0009-mmu-trait.md) §Open-questions "translation walk query" — + the per-task user-access policy; arc on `t-025-user-access-translation`: propose → review-round (8 valid / 3 skipped across two relayed reviews) → accept). **[T-025](../analysis/tasks/phase-b/T-025-user-access-translation.md)** implements gate #1's **mechanism** (the security-critical [T-021 carry-forward gate #1](phases/phase-b.md#t-021-carry-forward-gates-must-close-before-a-real-el0-task-runs)): `copy_from_user` / `copy_to_user` become **two-pass** (probe-all-then-copy), generic over ``, resolving **every** user page through the task's own address space and **requiring `USER`** (`FaultAddress` on any miss / non-`USER` / block-mapped page — the confused-deputy defence, never a panic), behind a per-task `[entry_va, stack_top_va)` window first-gate; `SyscallContext` gains `mmu` / `task_as`. `vmsav8` gains the inverse decoder `descriptor_bits_to_flags` (lock-shut). The B5 `+0x200` stub `console_write` of a **kernel** VA is now correctly **rejected** (smoke: `status=0x3 bytes=0`, no greeting emitted) — a positive gate-#1 demonstration and the only smoke-trace change. Gates: **host tests 252 kernel / 46 hal / 58 test-hal / 3 doc**, `cargo fmt`, host + kernel clippy `-D warnings`, kernel build, QEMU smoke (exactly 2 SVC exceptions, clean `ERET`, **zero new fault class**), **Miri 0 UB**. UNSAFE-2026-0030 + 0025 Amendments (per-page translation + read-only `translate` caller — no new entries). **Next:** gate #3 ([T-026](../analysis/tasks/phase-b/T-026-current-task-cap-table.md)) sources the running EL0 task's AS + capability table from the scheduler, then the `tyrne-user` + `userland/hello` build pipeline + the EL0 `+0x400` wire-up smoke. This banner supersedes the B6-opening banner below. diff --git a/docs/roadmap/phases/phase-b.md b/docs/roadmap/phases/phase-b.md index c35d8ec..1784fdc 100644 --- a/docs/roadmap/phases/phase-b.md +++ b/docs/roadmap/phases/phase-b.md @@ -251,7 +251,7 @@ A real userspace task, loaded by B4, running in EL0 in its own address space, ma 2. **EL0 task context register file + the enter-EL0 path + per-task `SP_EL1`** (closes [T-021 carry-forward gate #2](#milestone-b6--first-userspace-hello)). 3. **`task_create_from_image`** — `LoadedImage` → runnable `CapHandle{CapObject::Task(...)}` (composes steps 1 + 2; the deferred [§B4 §3](#milestone-b4--task-loader) bridge). 4. **Close the remaining T-021 carry-forward gates:** the per-task `console_write` window + per-page user-VA → kernel-VA translation returning `FaultAddress` (**gate #1 — security-critical**; without it an EL0 debug-console-cap holder reads arbitrary kernel memory), and `SYSCALL_STUB_TABLE` → the scheduler's current-task table (**gate #3**). -5. **`tyrne-user` crate** (safe wrappers) + **`userland/hello/` crate** + the `cargo build → objcopy -O binary → include_bytes!` pipeline + the shared `userland-layout` source-of-truth ([ADR-0029 §"Build pipeline (B6)"](../../decisions/0029-initial-userspace-image-format.md)). *(§Sub-breakdown items 1 + 3.)* +5. ✅ **`tyrne-user` crate** (safe wrappers) + **`userland/hello/` crate** + the `cargo build → rust-objcopy -O binary → include_bytes!` pipeline — **landed (2026-06-01, [T-027](../../analysis/tasks/phase-b/T-027-userland-build-pipeline.md) / [ADR-0039](../../decisions/0039-userland-build-pipeline.md), In Review).** `tools/build-userland.sh` (rust-objcopy from the pinned `llvm-tools-preview`, no Cargo dep) produces the git-ignored `hello.bin`; the BSP embeds it via `include_bytes!` (`build.rs` asserts it exists); `smoke.sh` + CI build userland first; UNSAFE-2026-0033 for the userspace `svc` shims. The real 117-byte image **loads dormant** (entry `0x800000`); **running** it is step 6. *(§Sub-breakdown items 1 + 3.)* 6. **Wire-up + QEMU smoke** *(§Sub-breakdown items 2 + 4)*: a true EL0 task takes the lower-EL `+0x400` vector, the dispatcher copies `console_write` from the task's `TTBR0_EL1`, `ERET` returns to EL0, and `task_exit` terminates it — the EL0↔EL1 round-trip B5's `+0x200` proxy could not prove. 7. **Closure = the Phase B retrospective** *(§Sub-breakdown items 5–7)*: the guide, the first hypothesis-driven performance cycle (real EL0 round-trip / IPC / context-switch vs the A6 baseline — and a same-host control, given the [B5 perf leg](../../analysis/reviews/performance-optimization-reviews/2026-05-29-B5-closure.md)'s finding that the harness is nearing its resolving floor), and a security review of the now-attacker-observable boundary. diff --git a/tools/build-userland.sh b/tools/build-userland.sh new file mode 100755 index 0000000..6f979ae --- /dev/null +++ b/tools/build-userland.sh @@ -0,0 +1,62 @@ +#!/usr/bin/env bash +# Build the Tyrne userland program(s) and strip to raw flat binaries (ADR-0039). +# +# Produces userland/hello/hello.bin (git-ignored via the repo's `*.bin` rule) +# by compiling the `tyrne-userland-hello` crate for aarch64-unknown-none and +# stripping it with `rust-objcopy -O binary` from the pinned `llvm-tools-preview` +# component (NO Cargo dependency — keeps the K3-8 cargo-vet flag unfired). +# +# Must run BEFORE `cargo kernel-build`: the BSP embeds the .bin via +# `include_bytes!`, and its build.rs panics if the .bin is absent. `tools/smoke.sh` +# and the CI kernel-build job both run this first. +# +# Usage: +# tools/build-userland.sh — debug profile (matches a debug kernel) +# tools/build-userland.sh --release — release profile (matches --release kernel) +set -euo pipefail + +PROFILE="debug" +case "${1:-}" in + --release) PROFILE="release"; shift ;; + "") ;; + -h|--help) sed -n '2,/^set -/p' "$0" | sed 's/^# \{0,1\}//;/^set -/d' >&2; exit 0 ;; + *) echo "error: unknown argument: $1 (usage: $0 [--release])" >&2; exit 2 ;; +esac + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT" + +echo "build-userland: cargo build -p tyrne-userland-hello --target aarch64-unknown-none (${PROFILE})" >&2 +if [[ "$PROFILE" == "release" ]]; then + cargo build -p tyrne-userland-hello --target aarch64-unknown-none --release +else + cargo build -p tyrne-userland-hello --target aarch64-unknown-none +fi + +# Resolve rust-objcopy from the active toolchain's llvm-tools (no Cargo dep). +# Resolve rust-objcopy deterministically: target-libdir is +# .../rustlib//lib, whose sibling bin/ holds the llvm-tools binaries. +# Fall back to a sysroot search for unusual layouts. +OBJCOPY="$(rustc --print target-libdir)/../bin/rust-objcopy" +if [[ ! -x "$OBJCOPY" ]]; then + OBJCOPY="$(find "$(rustc --print sysroot)" -type f -name 'rust-objcopy' 2>/dev/null | head -n1)" +fi +if [[ -z "$OBJCOPY" || ! -x "$OBJCOPY" ]]; then + echo "error: rust-objcopy not found in the active toolchain" >&2 + echo " install the llvm-tools-preview component (pinned in rust-toolchain.toml):" >&2 + echo " rustup component add llvm-tools-preview" >&2 + exit 1 +fi + +# Respect CARGO_TARGET_DIR (set in some CI / nested-build layouts); default to +# the workspace `target/` dir cargo uses otherwise. +TARGET_DIR="${CARGO_TARGET_DIR:-target}" +ELF="${TARGET_DIR}/aarch64-unknown-none/${PROFILE}/hello" +BIN="userland/hello/hello.bin" +if [[ ! -f "$ELF" ]]; then + echo "error: expected userland ELF not found at $ELF" >&2 + exit 1 +fi + +"$OBJCOPY" -O binary "$ELF" "$BIN" +echo "build-userland: wrote $BIN ($(wc -c < "$BIN" | tr -d ' ') bytes)" >&2 diff --git a/tools/smoke.sh b/tools/smoke.sh index cdf9c5a..b906152 100755 --- a/tools/smoke.sh +++ b/tools/smoke.sh @@ -43,6 +43,19 @@ if ! [[ "$TO" =~ ^[1-9][0-9]*$ ]]; then exit 2 fi +# When no explicit ELF is given, build the userland image first (ADR-0039: its +# raw-flat .bin must exist before the BSP embeds it via include_bytes! — the BSP +# build.rs panics otherwise) and then the kernel, making this the one-command +# integration entry point. An explicit ELF argument is used as-is (no build). +if [[ -z "$KERNEL" ]]; then + REL_FLAG="" + [[ "$PROFILE" == "release" ]] && REL_FLAG="--release" + # shellcheck disable=SC2086 # word-split the optional --release flag + "$(dirname "$0")/build-userland.sh" $REL_FLAG + # shellcheck disable=SC2086 + cargo kernel-build $REL_FLAG +fi + [[ -z "$KERNEL" ]] && KERNEL="target/aarch64-unknown-none/${PROFILE}/tyrne-bsp-qemu-virt" if [[ ! -f "$KERNEL" ]]; then echo "error: kernel image not found at $KERNEL (run 'cargo kernel-build' first)" >&2 diff --git a/userland/hello/Cargo.toml b/userland/hello/Cargo.toml new file mode 100644 index 0000000..11736a4 --- /dev/null +++ b/userland/hello/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "tyrne-userland-hello" +version.workspace = true +edition.workspace = true +authors.workspace = true +license.workspace = true +repository.workspace = true +description = "Tyrne first userspace program — greets via console_write, exits via task_exit (B6)." + +[[bin]] +name = "hello" +path = "src/main.rs" + +[dependencies] +tyrne-user = { path = "../tyrne-user" } + +[lints] +workspace = true diff --git a/userland/hello/build.rs b/userland/hello/build.rs new file mode 100644 index 0000000..cdc217d --- /dev/null +++ b/userland/hello/build.rs @@ -0,0 +1,23 @@ +//! Build script for `tyrne-userland-hello`. +//! +//! Passes the userland linker script (`hello.ld`) to the linker — **only when +//! building for the real aarch64 target**. The script fixes the image at the +//! userspace base VA with the entry at offset 0 (ADR-0029/0039) and ASSERTs no +//! `.data`/`.bss`/`.got`. On a HOST build (the `--workspace` member is host- +//! compiled by `cargo check`/`miri`/`llvm-cov`), those ASSERTs would fire +//! against the host std + coverage-instrumentation artifacts at link time, so +//! the script must NOT be applied there — the host build links as an ordinary +//! `std` stub (see `src/main.rs`) and is never run. + +fn main() { + let manifest_dir = std::env::var("CARGO_MANIFEST_DIR") + .expect("CARGO_MANIFEST_DIR must be set by Cargo when running build scripts"); + + // CARGO_CFG_TARGET_ARCH reflects the *target* being built for (aarch64 for + // the real image; the host arch for coverage/miri/check of the workspace). + if std::env::var("CARGO_CFG_TARGET_ARCH").as_deref() == Ok("aarch64") { + println!("cargo:rustc-link-arg=-T{manifest_dir}/hello.ld"); + } + println!("cargo:rerun-if-changed=hello.ld"); + println!("cargo:rerun-if-changed=build.rs"); +} diff --git a/userland/hello/hello.ld b/userland/hello/hello.ld new file mode 100644 index 0000000..1bbdaea --- /dev/null +++ b/userland/hello/hello.ld @@ -0,0 +1,59 @@ +/* + * Userspace linker script for the v1 "hello" raw-flat image (ADR-0029 / ADR-0039). + * + * The loader (kernel/src/obj/task_loader.rs) maps the objcopy'd bytes at a + * fixed userspace VA with the entry instruction at OFFSET 0. This script: + * - fixes the image at USERSPACE_IMAGE_BASE_VA (0x0080_0000), + * - places `_start` (in `.text._start`) FIRST in `.text` so offset 0 is the + * entry, then the rest of `.text`, then `.rodata` (the greeting), + * - forbids `.data` / `.bss`: the single image region maps USER|EXECUTE (no + * WRITE), so a writable byte would fault on first write — the ASSERTs below + * fail the build LOUDLY rather than silently embed unwritable globals. + * + * USERSPACE_IMAGE_BASE_VA must be kept in sync with the Rust-side constant + * `USERSPACE_IMAGE_BASE_VA` in bsp-qemu-virt/src/main.rs (a linker script + * cannot import a Rust const — ADR-0039 §Decision outcome; the `--defsym` + * upgrade is named there for full drift-elimination if it earns its keep). + */ + +USERSPACE_IMAGE_BASE_VA = 0x00800000; + +ENTRY(_start) + +SECTIONS { + . = USERSPACE_IMAGE_BASE_VA; + + .text : { + KEEP(*(.text._start)) /* _start first => offset 0 is the entry */ + *(.text .text.*) + } + + .rodata : ALIGN(8) { + *(.rodata .rodata.*) + } + + /* v1 forbids writable data (image is USER|EXECUTE, no WRITE) and is + * non-PIC (fixed VA — no relocations). Collect any stray .data/.bss/.got so + * the ASSERTs can prove they are empty rather than let a writable byte or a + * PIC artifact silently slip into the raw-flat image. */ + .data : { *(.data .data.*) } + .bss : { *(.bss .bss.* COMMON) } + .got : { *(.got .got.plt) } + + ASSERT(SIZEOF(.data) == 0, + "userland hello: .data must be empty (image maps USER|EXECUTE, no WRITE)") + ASSERT(SIZEOF(.bss) == 0, + "userland hello: .bss must be empty (image maps USER|EXECUTE, no WRITE)") + ASSERT(SIZEOF(.got) == 0, + "userland hello: .got must be empty (v1 is non-PIC, fixed-VA — no relocations)") + + /* Drop metadata/unwind/debug sections — not part of the raw-flat image. */ + /DISCARD/ : { + *(.comment) + *(.note .note.*) + *(.eh_frame .eh_frame_hdr) + *(.ARM.exidx .ARM.exidx.*) + *(.ARM.attributes) + *(.debug .debug.*) + } +} diff --git a/userland/hello/src/main.rs b/userland/hello/src/main.rs new file mode 100644 index 0000000..a792c09 --- /dev/null +++ b/userland/hello/src/main.rs @@ -0,0 +1,64 @@ +//! # tyrne-userland-hello +//! +//! Tyrne's first real userspace program (B6). It runs in EL0 in its own address +//! space, greets through a `console_write` syscall, and exits via `task_exit` — +//! the EL0↔EL1 round-trip the B5 EL1-stub proxy could not prove. +//! +//! This is a `#![no_std] #![no_main]` raw-flat image per [ADR-0029][adr-0029]: +//! the loader maps the objcopy'd bytes at a fixed userspace VA with the entry +//! instruction at **offset 0**, so [`_start`] is placed first via the +//! `.text._start` section + the `hello.ld` linker script. There is no `.data` +//! or `.bss` — the image region maps `USER | EXECUTE` (no `WRITE`), so the +//! greeting is a read-only string literal (`.rodata`, in-image) and there are +//! no writable globals. The build pipeline (cargo → `rust-objcopy -O binary` → +//! `include_bytes!`) is [ADR-0039][adr-0039] / T-027. +//! +//! [adr-0029]: https://github.com/HodeTech/Tyrne/blob/main/docs/decisions/0029-initial-userspace-image-format.md +//! [adr-0039]: https://github.com/HodeTech/Tyrne/blob/main/docs/decisions/0039-userland-build-pipeline.md + +// `no_std` / `no_main` apply on the real (aarch64 EL0) target. The crate is a +// `--workspace` member, so host tooling (`cargo check --workspace`, Miri) +// compiles it for the host; there it is an ordinary `std` binary with an empty +// `main` (see the host stub below) so it builds (it is never run on the host). +#![cfg_attr(target_arch = "aarch64", no_std, no_main)] + +#[cfg(target_arch = "aarch64")] +use tyrne_user::{console_write, task_exit, HELLO_CONSOLE_CAP}; + +/// The greeting emitted via `console_write`. A read-only `.rodata` literal that +/// lives inside the mapped image region (`USER | EXECUTE`) — gate #1 admits a +/// read of a `USER` page, so the buffer pointer translates and the bytes copy. +#[cfg(target_arch = "aarch64")] +static GREETING: &[u8] = b"hello from userspace\n"; + +/// Userspace entry point. Placed at **offset 0** of the raw-flat image (the +/// loader sets `ELR_EL1` to the image base, so the first instruction executed +/// is this function's first instruction). Greets, then exits — never returns. +/// +/// `#[no_mangle]` gives the linker the bare `_start` symbol (the `hello.ld` +/// `ENTRY`); `#[link_section = ".text._start"]` + the script's `KEEP` place it +/// first in `.text` so offset 0 is the entry. +#[cfg(target_arch = "aarch64")] +#[no_mangle] +#[link_section = ".text._start"] +pub extern "C" fn _start() -> ! { + // Ignore the result: the v1 demo has no fallback path if the console write + // is rejected — it exits cleanly either way (the kernel reports the exit). + let _ = console_write(HELLO_CONSOLE_CAP, GREETING); + task_exit(0) +} + +/// Panic handler (required for `#![no_std]`). Unwinding is disabled +/// (`panic=abort` for the bare-metal target), so this just exits the task with +/// a distinct non-zero code rather than attempting to unwind. +#[cfg(target_arch = "aarch64")] +#[panic_handler] +fn panic(_info: &core::panic::PanicInfo) -> ! { + task_exit(101) +} + +// Host stub: this program targets aarch64 EL0; the host build exists only so +// `cargo check --workspace` / Miri can include the crate (it is never run on +// the host — the real entry is `_start`, above). +#[cfg(not(target_arch = "aarch64"))] +fn main() {} diff --git a/userland/tyrne-user/Cargo.toml b/userland/tyrne-user/Cargo.toml new file mode 100644 index 0000000..9d69221 --- /dev/null +++ b/userland/tyrne-user/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "tyrne-user" +version.workspace = true +edition.workspace = true +authors.workspace = true +license.workspace = true +repository.workspace = true +description = "Tyrne userspace syscall wrappers — safe shims over the EL0 SVC ABI (ADR-0030/0031)." + +[lib] +name = "tyrne_user" + +[lints] +workspace = true diff --git a/userland/tyrne-user/src/lib.rs b/userland/tyrne-user/src/lib.rs new file mode 100644 index 0000000..f49439e --- /dev/null +++ b/userland/tyrne-user/src/lib.rs @@ -0,0 +1,156 @@ +//! # tyrne-user +//! +//! Safe userspace wrappers over the Tyrne EL0 syscall ABI. This crate is what a +//! userspace program (e.g. [`tyrne-userland-hello`]) links against to invoke +//! syscalls without writing its own `unsafe` inline assembly. +//! +//! Each wrapper packs the [ADR-0030][adr-0030] register convention (`x8` = +//! number, `x0`–`x5` = arguments; `x0` = status, `x1`–`x7` = payload) around a +//! single `svc #0`, the [ADR-0031][adr-0031] trap instruction. The `unsafe` +//! surface is confined to the `svc` shims here; callers stay in safe Rust. +//! +//! The crate is `#![no_std]` and carries **no dependency on the kernel** — the +//! syscall numbers are restated here per [ADR-0031][adr-0031] (the contract is +//! the ABI, not a shared type). The host-side authority is +//! `tyrne_kernel::syscall::abi::SyscallNumber::as_u64`; if these drift, the +//! kernel's ABI host tests and this crate's numbers must be reconciled. +//! +//! [adr-0030]: https://github.com/HodeTech/Tyrne/blob/main/docs/decisions/0030-syscall-abi.md +//! [adr-0031]: https://github.com/HodeTech/Tyrne/blob/main/docs/decisions/0031-initial-syscall-set.md + +#![no_std] + +// The `svc` shims are aarch64-only. The crate is a `--workspace` member, so +// host tooling (`cargo check --workspace`, Miri) compiles it for the host — +// where aarch64 inline asm does not assemble. Gate the asm to aarch64 and give +// the host degenerate stubs (never run; the crate only executes in EL0 on +// aarch64). Mirrors the kernel's `cfg_attr(not(test), no_std)` discipline. +#[cfg(target_arch = "aarch64")] +use core::arch::asm; + +/// `task_exit` syscall number (`x8`), per [ADR-0031][adr-0031]. Restated +/// userspace-side; the kernel authority is `SyscallNumber::TaskExit as 4`. +/// +/// [adr-0031]: https://github.com/HodeTech/Tyrne/blob/main/docs/decisions/0031-initial-syscall-set.md +#[cfg(target_arch = "aarch64")] +const SYS_TASK_EXIT: u64 = 4; + +/// `console_write` syscall number (`x8`), per [ADR-0031][adr-0031]. **Debug-gated** +/// in the kernel (number `5` decodes to a syscall only in a debug build; a +/// release kernel returns `BadSyscallNumber` and emits nothing). Restated +/// userspace-side; the kernel authority is `SyscallNumber::ConsoleWrite as 5`. +/// +/// [adr-0031]: https://github.com/HodeTech/Tyrne/blob/main/docs/decisions/0031-initial-syscall-set.md +#[cfg(target_arch = "aarch64")] +const SYS_CONSOLE_WRITE: u64 = 5; + +/// The capability word the first userspace task ([`tyrne-userland-hello`]) names +/// for its debug console — the **T-027 ↔ T-028 interface** (per +/// [ADR-0039][adr-0039]). It is the packed handle of the **root** capability of +/// a freshly created table: index `0`, generation `0`, so the ABI packing +/// `(generation << 16) | index` yields `0`. The EL0 wire-up (T-028) **must** +/// seed the task's `DebugConsole` capability so it resolves to this handle +/// (`insert_root` into a fresh table yields index 0 / generation 0). Defined +/// once here; not duplicated by value across the program and the seeding site. +/// +/// [adr-0039]: https://github.com/HodeTech/Tyrne/blob/main/docs/decisions/0039-userland-build-pipeline.md +pub const HELLO_CONSOLE_CAP: u64 = 0; + +/// A syscall rejection: the non-zero kernel status word returned in `x0`. `0` +/// means success and is never wrapped in this type (the wrappers return `Ok`). +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub struct SyscallError( + /// The raw kernel status word (`x0`); the low/high blocks encode the + /// kernel's `SyscallError` taxonomy ([ADR-0030][adr-0030]). + /// + /// [adr-0030]: https://github.com/HodeTech/Tyrne/blob/main/docs/decisions/0030-syscall-abi.md + pub u64, +); + +/// Write `buf` to the debug console named by the capability word `cap`. +/// +/// Returns the number of bytes the kernel accepted on success, or the raw +/// kernel status word on rejection (an absent/wrong capability, an +/// out-of-window or untranslatable buffer, etc. — the kernel dispatcher is +/// panic-free and fails closed). `cap` is a packed handle into the **caller's +/// own** capability table (e.g. [`HELLO_CONSOLE_CAP`]); it grants nothing the +/// task does not already hold. +/// +/// # Errors +/// +/// Returns [`SyscallError`] wrapping the non-zero `x0` status when the kernel +/// rejects the call. +#[cfg(target_arch = "aarch64")] +#[allow( + clippy::cast_possible_truncation, + reason = "the returned byte count is <= buf.len() <= usize::MAX; the u64 -> usize \ + cast is lossless on every supported (64-bit) target" +)] +pub fn console_write(cap: u64, buf: &[u8]) -> Result { + let status: u64; + let written: u64; + // SAFETY: `svc #0` is the EL0→EL1 syscall trap. We load the ABI registers + // exactly per ADR-0030 — x8 = the console_write number, x0 = the capability + // word, x1 = the buffer base pointer, x2 = the length — and read back + // x0 = status and x1 = the accepted byte count. The kernel dispatcher is + // panic-free and, post-gate-#1 (T-025), translates [x1, x1+x2) through the + // caller's own address space requiring USER, copying at most `buf.len()` + // bytes; it never writes through these pointers and never touches userspace + // memory outside `buf`. `buf` is only read (a shared borrow). x3..x7 are + // marked clobbered (caller-saved scratch/payload). No flags or stack state + // are relied upon across the trap. Audit: UNSAFE-2026-0033. + unsafe { + asm!( + "svc #0", + in("x8") SYS_CONSOLE_WRITE, + inout("x0") cap => status, + inout("x1") buf.as_ptr() as u64 => written, + in("x2") buf.len() as u64, + lateout("x3") _, + lateout("x4") _, + lateout("x5") _, + lateout("x6") _, + lateout("x7") _, + ); + } + if status == 0 { + Ok(written as usize) + } else { + Err(SyscallError(status)) + } +} + +// Host stub for `console_write` (see the crate-level note): aarch64-EL0-only; +// the host build exists solely so `cargo check --workspace` / Miri can compile +// the crate. Never run on the host. `#[doc(hidden)]` keeps it out of rustdoc. +#[cfg(not(target_arch = "aarch64"))] +#[doc(hidden)] +pub fn console_write(_cap: u64, _buf: &[u8]) -> Result { + unimplemented!("tyrne-user runs only at aarch64 EL0") +} + +/// Terminate the current task with exit `code`. Does not return — the kernel +/// removes the task from the scheduler and never re-enters it. +#[cfg(target_arch = "aarch64")] +pub fn task_exit(code: u64) -> ! { + // SAFETY: `svc #0` with x8 = the task_exit number and x0 = the exit code. + // task_exit acts on the caller's own task identity (no capability), and the + // kernel terminates the task without returning to EL0, so the + // `options(noreturn)` contract holds. Only x0 is passed; no memory is + // accessed. Audit: UNSAFE-2026-0033. + unsafe { + asm!( + "svc #0", + in("x8") SYS_TASK_EXIT, + in("x0") code, + options(noreturn), + ); + } +} + +// Host stub for `task_exit` (see the crate-level note): aarch64-EL0-only. +#[cfg(not(target_arch = "aarch64"))] +#[doc(hidden)] +pub fn task_exit(_code: u64) -> ! { + unimplemented!("tyrne-user runs only at aarch64 EL0") +}