Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
f8952c2
[slice-acl] Implementation of acl_core
williamwutq Sep 1, 2026
59765a5
[slice-acl] acl integration
williamwutq Sep 1, 2026
5f843c0
[slice-acl] acl integration in slice
williamwutq Sep 1, 2026
8266885
[slice-acl] Wire allocators to access control: mint-burn + dealloc re…
williamwutq Sep 3, 2026
47e8134
[slice-acl] Mark checked-slab header Alloc via "run generator as allo…
williamwutq Sep 3, 2026
4101fef
[slice-acl] Mark headers Alloc for the remaining allocators
williamwutq Sep 3, 2026
4c2fd9a
[slice-acl] Add _as siblings for swap/splice/replace/set_batched
williamwutq Sep 5, 2026
fb36f21
[chore] Cargo fmt
williamwutq Sep 5, 2026
ea030ce
[slice-acl] Add _as siblings for the frontier and batched-read ops
williamwutq Sep 5, 2026
c156b17
[slice-acl] Check resize/ensure; fix clippy across feature combos
williamwutq Sep 5, 2026
a88a9ff
[slice-acl] Document access control in CHANGELOG; drop PLANNED entry
williamwutq Sep 9, 2026
8d42b31
Merge master into expensive-slice-access-control
williamwutq Sep 9, 2026
59a1b2f
[slice-acl] Review pass 1: inline, drop protect wrapper, relocate tests
williamwutq Sep 9, 2026
36ea90a
[slice-acl] Review pass 2a: macro-ify the s_* and meta_* dispatch hel…
williamwutq Sep 9, 2026
61f0e27
[slice-acl] Review pass 2b: drop acl_active short-circuit; bitfield a…
williamwutq Sep 9, 2026
129883d
[slice-acl] Review pass 2c: give every _as method a # Errors section
williamwutq Sep 9, 2026
699108d
[slice-acl] Review pass 3: owned capability tokens via Option move-out
williamwutq Sep 9, 2026
109b278
[slice-acl] Allocators hold the real alloc token; drop meta_* + synth…
williamwutq Sep 9, 2026
a42fdcc
[slice-acl] Clear ACL policy on into_stack (fix reopen-on-same-object)
williamwutq Sep 9, 2026
7973cba
[slice-acl] Review: constructors acquire authority up front; drop hel…
williamwutq Sep 9, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- **Runtime range access control: the `expensive-slice-access-control` feature (implies `alloc` + `set`; Rust only; off by default).** A per-stack point table assigns an access mode to every payload offset, and each mutating/reading entry point (`set`/`get`/`push`/`pop`/`extend`/`resize`/`ensure`/`swap`/`splice`/`atrunc`/`cas`/`copy`/`process`/`process_gen`/`inplace_gen` and the batched/sparse/`try_*` variants) checks the touched range's required authority before committing, failing with `io::ErrorKind::PermissionDenied` on denial. Protection is armed through `BStackOwnedSlice::protect`/`protect_as` and carried by two one-shot, `!Clone`, pointer-identity-checked capability tokens — `BStackProtection` (guard authority, minted once via `take_protection`) and `BStackAllocAuthority` (allocator authority, `take_alloc_authority`) — which are mutually incomparable, so a token from a different stack or the wrong axis grants nothing. A holder reaches a protected range through the `_as` sibling of any checked method (`set_as`, `get_as`, `swap_as`, `resize_as`, `push_as`, …); a `BStackOwnedSlice`/`BStackSlice` granted an authority via `authorize` routes its region I/O through them automatically, and `merge`/`merge_adjacent` refuse when two views carry different authorities. New public types: `BStackAccess`, `AccessOp`, `BStackAccessRequirement`, `BStackAccessAuthorities`, `BStackProtection`, `BStackAllocAuthority`, `BStackAuthority`. The whole feature compiles away when off — the check macro folds to nothing and a build without the feature is byte-for-byte unchanged.
- **Allocators reserve their own metadata and refuse to free protected regions (`expensive-slice-access-control`; Rust only).** Every built-in allocator burns the alloc-authority mint on construction and marks its fixed header `Alloc`, routing its own metadata I/O through that authority so a protected header cannot be corrupted. `dealloc`/`dealloc_bulk` refuse to free any range still carrying a caller mode outside `{All, Alloc}`, returning `PermissionDenied` and handing the handle(s) back intact rather than silently dropping the caller's protection into the region's next owner; the caller must lift its own `protect` first.

### Changed

- **`SegregatedBStackAllocator` (Rust) / `segregated_bstack_allocator_*` (C) shrink/split heuristics tuned (`alloc` + `set` / `BSTACK_FEATURE_SET`; the tail-shrink reclaim additionally `atomic` / `BSTACK_FEATURE_ATOMIC`).** `SPLIT_MIN` / `ALSG_SPLIT_MIN` raised from `LINEAR_MAX` (256) to `MAX_CLASS` (4096): a smaller excess is now retained as slack rather than carved into the class free lists, where the pieces rarely reuse and strand as dead arena. Separately, a non-tail (interior) shrink now always retains its freed excess in place instead of carving it — only a *tail* shrink still reclaims, and only under `atomic` / `BSTACK_FEATURE_ATOMIC`, via the existing `Len` + `Atrunc` (`BSTACK_GEN_LEN` + `BSTACK_GEN_SPLICE`). Both cut durable syncs and file growth on skewed and realloc-heavy workloads. Heuristics only and the on-disk format is unchanged.
Expand Down
3 changes: 3 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ alloc = []
atomic = []
# Enables BStackGuardedSlice and related hook-based slice abstractions.
guarded = ["alloc"]
# Enables runtime range access control on BStack / BStackOwnedSlice (the
# BStackAccess point table). Off by default; See the `acl_core` module.
expensive-slice-access-control = ["alloc", "set"]
# Enables deterministic I/O-fault injection at the BStack API level, for testing
# allocator and downstream error-handling paths. Only takes effect in builds with
# `debug_assertions` on (dev/test); release builds are completely unaffected even
Expand Down
69 changes: 0 additions & 69 deletions PLANNED.md
Original file line number Diff line number Diff line change
Expand Up @@ -354,75 +354,6 @@ Cost is `n` staged bytes and `2k` syncs. Staging is independent of `k`, so rotat

---

## Range access control on `BStack` and `BStackOwnedSlice`

**Feature flag:** `expensive-slice-access-control` (implies `alloc` + `set`). Off by default.
**Breaking change:** No. A build without the flag compiles to exactly today's code.

### Motivation

`bstack` has one enforcement mechanism for "these bytes must not change": `lock_up_to`. It is shaped for the stack case — a consumer whose bottom `n` bytes are settled and whose later pushes build on them — and is right there. For anything else it is a prefix, it is all-or-nothing, and it conflates writing with truncating.

What nothing encodes is *who is asking*. Ownership and borrowing settle aliasing, but aliasing is not authority: two callers holding the same range are indistinguishable, and nothing can say that one may write it and the other may not. It is the axis an OS gives every page, where `rwx` belongs to the mapping rather than to any pointer into it. Three things follow:

- **Allocator metadata is protected only as far as the slice API.** No handle spans a block header, but an allocator hands out its stack, and `allocator.stack().set(..)` reaches any byte in the arena. The rule being broken — *only the allocator may write here* — is about the caller, not about aliasing.
- **Truncating cannot be separated from writing.** A tail region may be freely writable yet must not be discarded.
- **Reads cannot be denied.** Atomicity guarantees a read is never torn, not that the bytes are still *yours*: a slice held across a `dealloc` reads whatever the block was reused for.

None of this is a correction — used as documented, the APIs keep a stack intact. Access control is an **additional layer** for callers who would rather have an invariant checked at runtime.

### Design

#### Modes

```rust
pub enum BStackAccess { All, Rw, RwStrict, Prot, RwProt, Alloc, ReadOnly, Locked }
```

| Mode | Read | Write | Truncate |
|------------|-----------|-----------|--------------------|
| `All` | any | any | any |
| `Rw` | any | any | allocator or guard |
| `RwStrict` | any | any | none |
| `Prot` | guard | guard | guard |
| `RwProt` | guard | guard | none |
| `Alloc` | allocator | allocator | allocator |
| `ReadOnly` | any | none | none |
| `Locked` | none | none | none |

Each cell lists the authorities that satisfy it; `any` means no token is needed. The two tokens are **incomparable** — neither outranks the other — so `Prot` and `Alloc` are each private to their own holder on all three axes: a range marked `Alloc` cannot be read, written, or truncated by a guard holder, which is what makes metadata inviolable even to the policy owner. `All` is the default everywhere and is what an unprotected stack reports.

#### Authority

Two capability tokens, `BStackProtection<'a>` and `BStackAllocAuthority<'a>`, neither `Clone` nor `Copy`, each minted at most once per handle (`take_protection() -> Option<_>`, `None` thereafter). One-shot minting is what makes them mean anything. Allocator constructors claim the second naturally, since they already consume a `BStack` exclusively. Checked entry points gain a token-carrying sibling, `set_as(&self, auth, offset, data)`.

#### The point table

A sorted `Vec<(u64, BStackAccess)>` of change points: `(16, Alloc)` means `Alloc` from offset 16 until the next point, with an absent leading point implying `All` from 0. Adjacent equal modes coalesce, so the table is proportional to the number of distinct regions and one protected header is two entries. Deliberately **not** a `BTreeMap`: the table is read far more often than written, a read is a `partition_point` over a contiguous array.

- **Point lookup** — `partition_point(|p| p.0 <= off) - 1`.
- **Range check** for `[a, b)` — one `partition_point`, then a forward scan while `points[j].0 < b`, folding to the most restrictive mode. The common case spans one point and the scan does not run.
- **Setting** `[a, b)` to `M` — record the mode in effect at `b` as a point at `b`, insert `(a, M)`, drop points strictly inside, coalesce.

The table takes its own `RwLock`, separate from the stack lock, because the locked-region read fast path bypasses the stack lock and must still reject a `Locked` read. Mutation takes both, stack lock first, so in-flight writers drain before the new policy is published — the ordering and the reasoning of `lock_up_to`. An `AtomicBool` short-circuits every check on a stack that has never been protected.

#### Checks

Writes check their target range, truncations `[new_len, old_len)`, reads their read range. Batched paths check every block before the journal is armed. Points beyond `len` are retained rather than trimmed, so a range can be armed before its bytes arrive. Denials return `PermissionDenied`.

The locked prefix is checked first and stays out of the table, which can only further restrict it; folding the two would cost `lock_up_to` its lock-free read path. Protection is set through an owned handle — `BStackOwnedSlice::protect(mode)`, forwarded to the stack's table — never through `BStack` with an arbitrary range. A caller may set any range whose current mode already admits its token; one without a token may only tighten a range currently at `All`. Nothing is persisted, so reopening clears the table.

#### Cost

The flag is named for it. On a protected stack every checked call pays a relaxed load, an `RwLock` read acquisition, and a binary search before any I/O — a real fraction of a small `set`, whose fast path is one write and one sync. Batched ops pay per block, and every checked entry point grows a token-carrying sibling.

### Open questions

- **Named modes or an axis triple.** A `{ read, write, truncate }` triple of authorities is more expressive and no larger, at the cost of admitting nonsense (`read: none, write: any`). The enum is proposed because the curated eight are what callers want and a one-byte discriminant keeps the table compact.
- **What an allocator may do inside a `Prot` range.** Incomparability settles one direction — a guard holder cannot reach an `Alloc` range — but not the other. A caller may mark its own allocation `Prot` and then free it, leaving a mode over bytes the allocator is about to hand to someone else. Either `dealloc` resets the reclaimed range to `All`, which means an allocator overriding a mode it otherwise cannot touch, or the protection outlives the allocation and poisons the block for its next owner.

---

## `BStackGuardedUnit` and `BStackGuardedBuilder` — composable transform units for `guarded` (0.5.0)

**Feature flag:** `guarded`.
Expand Down
Loading
Loading