Update Phase 6 design: dominance-based bounds verification
f583c4173f8a880dae8c16710003bede648917c1ea02ac365a791d93936dcea3
Replace the earlier vague Phase 6 sketch with a concrete design. Key decision: bounds checks stay in the lowerer (which has the most optimization context). The verifier proves safety through dominance analysis without inserting or moving checks. Two tiers: - Static offsets (field access): purely local, check const offset against known allocation size. No CFG analysis. - Dynamic offsets (array indexing): verifier computes dominator tree, tracks bounds facts from br.ult instructions, and connects them to pointer derivations via provenance table.
1 parent
c22390f5
.ai/RIL.TODO.md
added
+326 -0
| 1 | + | # RIL Verifiability: Proposed Lowerer and IL Changes |
|
| 2 | + | ||
| 3 | + | Based on `notes/ril-security-requirements.md`. The goal is to make RIL |
|
| 4 | + | statically verifiable for memory safety, supporting the exokernel's |
|
| 5 | + | trusted-domain guarantees. |
|
| 6 | + | ||
| 7 | + | ## Current State |
|
| 8 | + | ||
| 9 | + | Today, RIL has four scalar types: `W8`, `W16`, `W32`, `W64`. Pointers are |
|
| 10 | + | `W64` -- indistinguishable from integers. Memory instructions (`Load`, |
|
| 11 | + | `Store`, `Blit`) take plain `Reg` operands as addresses. There is no |
|
| 12 | + | provenance tracking, no bounds information, and nothing preventing an |
|
| 13 | + | integer from being used as a memory base. The `lowerCast` / `ilType` |
|
| 14 | + | functions in the lowerer collapse `resolver::Type::Pointer` to `il::Type::W64`, |
|
| 15 | + | erasing pointer identity entirely. |
|
| 16 | + | ||
| 17 | + | --- |
|
| 18 | + | ||
| 19 | + | ## Phase 1: Pointer/Integer Type Distinction in IL |
|
| 20 | + | ||
| 21 | + | ### 1.1 Add `Ptr` to `il::Type` |
|
| 22 | + | ||
| 23 | + | ``` |
|
| 24 | + | pub union Type { |
|
| 25 | + | W8, W16, W32, W64, |
|
| 26 | + | Ptr, // New: pointer-width value with provenance |
|
| 27 | + | } |
|
| 28 | + | ``` |
|
| 29 | + | ||
| 30 | + | `Ptr` is the same physical width as `W64` but carries different semantic |
|
| 31 | + | meaning. The verifier treats them as incompatible types. |
|
| 32 | + | ||
| 33 | + | **Files:** `lib/std/lang/il.rad` |
|
| 34 | + | ||
| 35 | + | - Add `Ptr` variant to `Type`. |
|
| 36 | + | - Update `typeSize` to return 8 for `Ptr`. |
|
| 37 | + | ||
| 38 | + | ### 1.2 Update `ilType` in the lowerer |
|
| 39 | + | ||
| 40 | + | Change the lowerer's `ilType` function so `resolver::Type::Pointer`, |
|
| 41 | + | `resolver::Type::Slice` (which contains a pointer), and |
|
| 42 | + | `resolver::Type::Fn` map to `il::Type::Ptr` instead of `il::Type::W64`. |
|
| 43 | + | ||
| 44 | + | **Files:** `lib/std/lang/lower.rad` (function `ilType`, ~line 6974) |
|
| 45 | + | ||
| 46 | + | ### 1.3 Update the IL printer |
|
| 47 | + | ||
| 48 | + | Teach the IL printer to emit `ptr` for the new type variant. |
|
| 49 | + | ||
| 50 | + | **Files:** `lib/std/lang/il/printer.rad` |
|
| 51 | + | ||
| 52 | + | ### 1.4 Update the backend |
|
| 53 | + | ||
| 54 | + | The RV64 backend (`isel.rad`, `emit.rad`) must treat `Ptr` identically to |
|
| 55 | + | `W64` for code generation -- same register class, same instructions. The |
|
| 56 | + | distinction is purely for the verifier, not for codegen. |
|
| 57 | + | ||
| 58 | + | **Files:** `lib/std/arch/rv64/isel.rad`, `lib/std/arch/rv64/emit.rad` |
|
| 59 | + | ||
| 60 | + | --- |
|
| 61 | + | ||
| 62 | + | ## Phase 2: Typed Memory Instruction Operands |
|
| 63 | + | ||
| 64 | + | ### 2.1 Enforce pointer-typed bases on memory ops |
|
| 65 | + | ||
| 66 | + | Currently: |
|
| 67 | + | ``` |
|
| 68 | + | Load { typ, dst: Reg, src: Reg, offset } |
|
| 69 | + | Store { typ, src: Val, dst: Reg, offset } |
|
| 70 | + | Blit { dst: Reg, src: Reg, size: Val } |
|
| 71 | + | ``` |
|
| 72 | + | ||
| 73 | + | The `src`/`dst` address registers are untyped. A verifier pass should |
|
| 74 | + | reject any `Load`/`Store`/`Blit` whose base register was not defined by |
|
| 75 | + | a pointer-producing instruction. |
|
| 76 | + | ||
| 77 | + | This does NOT require changing the `Instr` union itself (registers are |
|
| 78 | + | untyped in the encoding). Instead, a **type map** is maintained: |
|
| 79 | + | each SSA register is assigned either a word type or `Ptr`. The verifier |
|
| 80 | + | checks that `Load.src`, `Store.dst`, and `Blit.{dst,src}` registers |
|
| 81 | + | have type `Ptr`. |
|
| 82 | + | ||
| 83 | + | ### 2.2 Lowerer must produce consistently typed registers |
|
| 84 | + | ||
| 85 | + | Update the lowerer so that every register holding a pointer is produced by |
|
| 86 | + | a `Ptr`-typed instruction. Key sites: |
|
| 87 | + | ||
| 88 | + | - `emitReserve` -- `Reserve` produces a stack pointer: its result type |
|
| 89 | + | should be recorded as `Ptr`. |
|
| 90 | + | - `emitDataAddr` / `emitFnAddr` -- `Copy` of a `DataSym` or `FnAddr` |
|
| 91 | + | produces a `Ptr`. |
|
| 92 | + | - `emitLoad` for pointer-typed fields -- `Load` of a pointer field |
|
| 93 | + | returns a `Ptr`-typed register. |
|
| 94 | + | - Address arithmetic (field offsets, array indexing via `BinOp::Add` on |
|
| 95 | + | a pointer base) -- result is `Ptr`. |
|
| 96 | + | - `buildSliceValue` -- the pointer component is `Ptr`, the length/cap |
|
| 97 | + | components are `W32`. |
|
| 98 | + | ||
| 99 | + | **Files:** `lib/std/lang/lower.rad` (many functions; grep for |
|
| 100 | + | `emitReserve`, `emitDataAddr`, `emitFnAddr`, `emitLoad`, `emitRead`, |
|
| 101 | + | `buildSliceValue`, `lowerIndex`, `lowerFieldAccess`) |
|
| 102 | + | ||
| 103 | + | --- |
|
| 104 | + | ||
| 105 | + | ## Phase 3: Restrict Integer-to-Pointer Conversion |
|
| 106 | + | ||
| 107 | + | ### 3.1 Forbid `int as *T` in the lowerer |
|
| 108 | + | ||
| 109 | + | The lowerer's `lowerCast` currently delegates to `lowerNumericCast` for |
|
| 110 | + | all same-size casts, which is a no-op. For `int -> ptr`, it must instead |
|
| 111 | + | emit a dedicated **`IntToPtr`** IL instruction (or reject it outright). |
|
| 112 | + | ||
| 113 | + | Add to `il::Instr`: |
|
| 114 | + | ``` |
|
| 115 | + | IntToPtr { dst: Reg, val: Val }, // Explicit, auditable |
|
| 116 | + | PtrToInt { dst: Reg, val: Val }, // Pointer -> integer (one-way) |
|
| 117 | + | ``` |
|
| 118 | + | ||
| 119 | + | - `PtrToInt` produces a `W64`. The resulting integer CANNOT be fed back |
|
| 120 | + | into memory ops. |
|
| 121 | + | - `IntToPtr` produces a `Ptr`. The verifier rejects this in user code; |
|
| 122 | + | it is only permitted in `@trusted` functions (see Phase 5). |
|
| 123 | + | ||
| 124 | + | ### 3.2 Update `lowerCast` |
|
| 125 | + | ||
| 126 | + | When source is an integer type and destination is `Pointer`, emit |
|
| 127 | + | `IntToPtr` instead of silently treating the value as compatible. |
|
| 128 | + | When source is `Pointer` and destination is an integer, emit `PtrToInt`. |
|
| 129 | + | ||
| 130 | + | **Files:** `lib/std/lang/lower.rad` (`lowerCast`, `lowerNumericCast`) |
|
| 131 | + | ||
| 132 | + | --- |
|
| 133 | + | ||
| 134 | + | ## Phase 4: Provenance-Producing Instructions |
|
| 135 | + | ||
| 136 | + | ### 4.1 Define the closed set of pointer-producing operations |
|
| 137 | + | ||
| 138 | + | A verifier must be able to enumerate every instruction that can produce |
|
| 139 | + | a `Ptr`-typed result. The exhaustive list: |
|
| 140 | + | ||
| 141 | + | | Instruction | Provenance source | |
|
| 142 | + | |--------------------|------------------------------------------| |
|
| 143 | + | | `Reserve` | Stack allocation (local provenance) | |
|
| 144 | + | | `Copy(DataSym)` | Global/static data address | |
|
| 145 | + | | `Copy(FnAddr)` | Function address (code section) | |
|
| 146 | + | | `Load` of ptr field| Derived from an existing pointer | |
|
| 147 | + | | `BinOp::Add(ptr,i)`| Pointer arithmetic (derived) | |
|
| 148 | + | | `Call` returning ptr| Callee-produced pointer | |
|
| 149 | + | | Block parameter | Merges pointer values from predecessors | |
|
| 150 | + | | `IntToPtr` | Trusted-only escape hatch | |
|
| 151 | + | ||
| 152 | + | Any other instruction producing a register used as a memory base is a |
|
| 153 | + | verifier error. |
|
| 154 | + | ||
| 155 | + | ### 4.2 Pointer arithmetic constraints |
|
| 156 | + | ||
| 157 | + | When emitting `BinOp::Add` for pointer offset computation (field access, |
|
| 158 | + | array indexing), the lowerer should tag the result as `Ptr`. The verifier |
|
| 159 | + | checks: |
|
| 160 | + | - Exactly one operand is `Ptr`, the other is an integer. |
|
| 161 | + | - The result is `Ptr` with the same provenance as the pointer operand. |
|
| 162 | + | ||
| 163 | + | `BinOp::Sub` between two pointers produces an integer (`W64`), not a |
|
| 164 | + | pointer. This is the `ptr - ptr -> usize` pattern. |
|
| 165 | + | ||
| 166 | + | **Files:** `lib/std/lang/lower.rad` (field/index lowering), |
|
| 167 | + | `lib/std/lang/il.rad` (documentation) |
|
| 168 | + | ||
| 169 | + | --- |
|
| 170 | + | ||
| 171 | + | ## Phase 5: Trusted Boundary Annotation |
|
| 172 | + | ||
| 173 | + | ### 5.1 Add `@trusted` function attribute |
|
| 174 | + | ||
| 175 | + | Functions that must perform raw address manipulation (syscall wrappers, |
|
| 176 | + | allocator internals, kernel glue) are annotated `@trusted`. The verifier |
|
| 177 | + | relaxes pointer-provenance checks inside `@trusted` functions but tracks |
|
| 178 | + | them as trust boundaries. |
|
| 179 | + | ||
| 180 | + | This requires: |
|
| 181 | + | - Scanner/parser support for the `@trusted` attribute. |
|
| 182 | + | - A `trusted: bool` field on `il::Fn`. |
|
| 183 | + | - The verifier skipping `IntToPtr` rejection inside trusted functions. |
|
| 184 | + | ||
| 185 | + | **Files:** `lib/std/lang/scanner.rad`, `lib/std/lang/parser.rad`, |
|
| 186 | + | `lib/std/lang/resolver.rad`, `lib/std/lang/lower.rad`, `lib/std/lang/il.rad` |
|
| 187 | + | ||
| 188 | + | ### 5.2 Confine `ecall` to trusted functions |
|
| 189 | + | ||
| 190 | + | `Ecall` passes raw register values to the environment. A verifier should |
|
| 191 | + | require that `Ecall` only appears in `@trusted` functions, since the |
|
| 192 | + | syscall interface is an inherent trust boundary. |
|
| 193 | + | ||
| 194 | + | --- |
|
| 195 | + | ||
| 196 | + | ## Phase 6: Bounds Verification (Future) |
|
| 197 | + | ||
| 198 | + | The lowerer emits bounds checks as explicit IL (compare + branch to |
|
| 199 | + | trap). The verifier must prove that every memory access is covered by |
|
| 200 | + | a bounds check, without moving or inserting checks itself. |
|
| 201 | + | ||
| 202 | + | ### Design Principle |
|
| 203 | + | ||
| 204 | + | Bounds checks stay in the lowerer. The lowerer has the most context |
|
| 205 | + | for optimization: it knows when a check is redundant (constant index |
|
| 206 | + | into a known-size array), when to hoist (loop over `0..len`), when |
|
| 207 | + | two accesses share one check. The verifier is purely an analysis |
|
| 208 | + | pass that confirms the lowerer's output is safe. |
|
| 209 | + | ||
| 210 | + | ### 6.1 Static offsets (no CFG analysis needed) |
|
| 211 | + | ||
| 212 | + | Field access produces `add ptr %field %base <const>`. The verifier |
|
| 213 | + | tracks each `Ptr` register's allocation size (from `Reserve`). |
|
| 214 | + | For constant offsets: check `offset + access_size <= alloc_size`. |
|
| 215 | + | This covers record fields, tagged union payloads, slice header |
|
| 216 | + | fields -- the bulk of pointer derivations. |
|
| 217 | + | ||
| 218 | + | ### 6.2 Dynamic offsets (requires dominance analysis) |
|
| 219 | + | ||
| 220 | + | Array/slice indexing produces: |
|
| 221 | + | ``` |
|
| 222 | + | load w32 %len %slice 8 ;; load length |
|
| 223 | + | br.ult %idx %len @ok @trap ;; bounds check |
|
| 224 | + | @ok |
|
| 225 | + | mul w64 %off %idx %stride |
|
| 226 | + | add ptr %elem %base %off ;; derive element pointer |
|
| 227 | + | ``` |
|
| 228 | + | ||
| 229 | + | The verifier must prove that the `add ptr` is dominated by a |
|
| 230 | + | `br.ult` that establishes `%idx < %len`, and that `%base` is |
|
| 231 | + | valid for `%len * %stride` bytes. |
|
| 232 | + | ||
| 233 | + | This requires: |
|
| 234 | + | 1. **Dominator tree** -- computed from the block predecessor |
|
| 235 | + | lists already in the IL. Standard algorithm, linear time. |
|
| 236 | + | 2. **Bounds facts** -- at each `br.ult %a %b @pass @fail`, the |
|
| 237 | + | verifier records that `%a < %b` holds on the @pass edge. |
|
| 238 | + | 3. **Provenance table** -- each `Ptr` register carries |
|
| 239 | + | `(origin, bound)` where origin is a `Reserve`/`DataSym`/ |
|
| 240 | + | parameter ID and bound is the allocation size or a register |
|
| 241 | + | holding the dynamic length. |
|
| 242 | + | 4. **Access check** -- for each `Load`/`Store`/`Blit`, verify |
|
| 243 | + | that the base pointer's derived offset is covered by a |
|
| 244 | + | dominating bounds fact against the same bound. |
|
| 245 | + | ||
| 246 | + | For a `Reserve { size: N }`, the bound is the constant `N`. |
|
| 247 | + | For a slice data pointer, the bound is `len * stride` where |
|
| 248 | + | `len` was loaded from the same slice header. |
|
| 249 | + | ||
| 250 | + | ### 6.3 Provenance tracking |
|
| 251 | + | ||
| 252 | + | The verifier maintains a side-table mapping each `Ptr` register |
|
| 253 | + | to its provenance: |
|
| 254 | + | - **Origin**: which allocation or parameter it derives from. |
|
| 255 | + | - **Bound**: static size (for `Reserve`/globals) or the register |
|
| 256 | + | holding the dynamic bound (for slice data pointers). |
|
| 257 | + | - **Offset**: accumulated constant offset from the origin base. |
|
| 258 | + | ||
| 259 | + | Derived pointers inherit their parent's provenance: |
|
| 260 | + | - `add ptr %dst %base <imm>` -- same origin, offset += imm. |
|
| 261 | + | - `add ptr %dst %base %dynoff` -- same origin, offset is dynamic |
|
| 262 | + | (must be covered by a dominating bounds check). |
|
| 263 | + | ||
| 264 | + | ### 6.4 Explicit region types (exokernel) |
|
| 265 | + | ||
| 266 | + | For the exokernel use case, extend the provenance model: |
|
| 267 | + | - `PagePtr(handle, offset)` -- pointer into a granted page. |
|
| 268 | + | - `DevicePtr(handle, offset)` -- pointer to a device mapping. |
|
| 269 | + | - `PortalPtr(handle, field)` -- pointer to a cross-domain portal. |
|
| 270 | + | ||
| 271 | + | These would be additional provenance classes that the kernel |
|
| 272 | + | verifier checks against the domain's capability set. |
|
| 273 | + | ||
| 274 | + | ### Implementation cost |
|
| 275 | + | ||
| 276 | + | Dominance computation and dataflow tracking are standard SSA |
|
| 277 | + | algorithms. The main engineering work is connecting bounds facts |
|
| 278 | + | to pointer derivations across blocks. This is a substantial but |
|
| 279 | + | bounded effort -- probably 500-1000 lines of verifier code. |
|
| 280 | + | ||
| 281 | + | --- |
|
| 282 | + | ||
| 283 | + | ## Phase 7: Verifier Implementation |
|
| 284 | + | ||
| 285 | + | ### 7.1 SSA type checker |
|
| 286 | + | ||
| 287 | + | Walk each function's blocks in order. Maintain a type map |
|
| 288 | + | `Reg -> Type` (where `Type` includes `Ptr`). For each instruction: |
|
| 289 | + | 1. Record the destination register's type. |
|
| 290 | + | 2. For `Load`/`Store`/`Blit`, check that the base register has type `Ptr`. |
|
| 291 | + | 3. For `IntToPtr`, check that the function is `@trusted`. |
|
| 292 | + | 4. For `BinOp` with a `Ptr` operand, check the arithmetic constraints. |
|
| 293 | + | ||
| 294 | + | ### 7.2 Provenance checker (future) |
|
| 295 | + | ||
| 296 | + | Extend the type checker with provenance sets. Each `Ptr` register carries |
|
| 297 | + | a provenance tag. Derived pointers inherit their parent's tag. The |
|
| 298 | + | verifier rejects memory access through a pointer whose provenance is not |
|
| 299 | + | in the current domain's authorized set. |
|
| 300 | + | ||
| 301 | + | --- |
|
| 302 | + | ||
| 303 | + | ## Implementation Order |
|
| 304 | + | ||
| 305 | + | Recommended sequence, each step independently useful: |
|
| 306 | + | ||
| 307 | + | 1. **Phase 1** (type distinction) -- smallest change, enables all later work. |
|
| 308 | + | 2. **Phase 2** (typed memory ops) -- makes the lowerer provenance-aware. |
|
| 309 | + | 3. **Phase 3** (cast restrictions) -- closes the int-to-ptr hole. |
|
| 310 | + | 4. **Phase 7.1** (basic verifier) -- can now check real programs. |
|
| 311 | + | 5. **Phase 5** (trusted boundaries) -- needed before restricting stdlib. |
|
| 312 | + | 6. **Phase 4** (formalize pointer producers) -- refine verifier rules. |
|
| 313 | + | 7. **Phase 6** (bounds/provenance) -- full exokernel support. |
|
| 314 | + | ||
| 315 | + | --- |
|
| 316 | + | ||
| 317 | + | ## Key Invariant |
|
| 318 | + | ||
| 319 | + | After all phases, the following must hold: |
|
| 320 | + | ||
| 321 | + | > Every `Load`, `Store`, or `Blit` instruction operates through a register |
|
| 322 | + | > of type `Ptr`, whose provenance traces back to an authorized allocation |
|
| 323 | + | > or grant, and whose derived offset is within the bounds of that |
|
| 324 | + | > allocation. |
|
| 325 | + | ||
| 326 | + | This is the RIL-level encoding of the exokernel's security guarantee. |