[slice-acl] Runtime range access control for BStack and BStackOwnedSlice - #74
williamwutq wants to merge 20 commits into
Conversation
…claim
Every allocator constructor now burns the public alloc-authority mint
(`acl_claim_alloc`), so no external caller can obtain `Alloc` authority
over an allocator's arena via `allocator.stack().take_alloc_authority()`.
Every `dealloc`/`dealloc_bulk` refuses (`PermissionDenied`, handle
returned) to free a range carrying any caller-set policy outside
`{All, Alloc}` — the resolved answer to PLANNED's open question — via
`acl_reclaim`/`acl_reclaimable`, checked before any metadata mutation and
short-circuited on an unprotected arena. Bulk validates the whole batch
up front so a refused free stays atomic.
Shared plumbing: `PointTable::reclaimable_by_alloc`, and
`BStack::{acl_claim_alloc,acl_reclaim,acl_reclaimable}` with
`alloc`-gated no-op shims so a build without the feature is byte-identical.
`acl_mark_alloc` is added as the metadata-marking hook; per-allocator
adoption (which requires routing each allocator's own metadata I/O
through the `_as(ALLOC)` siblings) is staged separately.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…cator" Adds `process_gen_as` / `inplace_gen_as` (and the `meta_*` metadata-I/O helpers) in acl.rs: journal engines run under a presented authority, so an allocator can drive its own `Alloc`-marked metadata (magic, geometry, the free-list head) through the crash-atomic engines without lifting the mark. The engines are duplicated from `lib.rs` rather than threading authority through the core (kept feature-clean; the two copies must stay in sync). CheckedSlabBStackAllocator now marks its whole header `[OFFSET_SIZE, ARENA_START)` `Alloc` permanently on new/open, and routes every free-list I/O through the `meta_*` helpers (`meta_set`/`meta_read_u64`/ `meta_cross_exchange`/`meta_process_gen`/`meta_inplace_gen`). Because the mark is never lifted, concurrent lock-free ops stay correct — the atomic concurrent tests pass. An external `stack().set(header)` is denied. Two recover tests that simulated an allocator mid-pop crash by writing `free_head` directly now use the authorized `meta_set`, matching how the real (interrupted) write would have been made. Without the feature the `meta_*` helpers are the plain ops, so the build stays byte-identical. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Applies the checked-slab pattern to slab, first_fit, ghost_tree, and segregated: each marks its fixed allocator header `Alloc` permanently on new/open and routes its own metadata I/O through the `meta_*` helpers, so an external `stack().set(header)` is denied while the allocator's own lock-free concurrent ops keep working (no mark is ever lifted). - slab: header [24,48) (magic, block_size, free_head). - first_fit: header [16,48) (magic, recovery flag, free_head); the recovery-flag CAS routes through a new `meta_cas` helper. - ghost_tree: header [32,48) (magic, AVL root pointer). - segregated: header [24,304) (magic + 33 free-list heads). Adds `meta_cas` (acl.rs). Tests that simulated an allocator mid-op crash by writing header fields (recovery flag, free-list heads) directly now use the authorized `meta_*` path, matching how the real interrupted write was made. Without the feature the `meta_*` helpers are the plain ops, so the build stays byte-identical. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Extend the authorized-access surface to the compound read-write and batched write primitives that previously had no token-presenting counterpart: swap_as, swap_into_as, splice_as, splice_into_as, replace_as, and set_batched_as. Each duplicates its BStack engine verbatim (leaving lib.rs untouched) and threads the resolved authority into the acl_check! sites, so a guard/alloc token holder can reach a Prot/Alloc range through an in-place exchange, a tail replacement, or a multi-region write. Gated on atomic, like the other atomic _as engines. Adds authorized_stack_compound_ops_reach_prot covering all six against both an interior Prot range and a tail Prot range. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Complete the authorized-access surface with the remaining checked primitives:
push_as, extend_as, extend_sparse_as, extend_sparse_batched_as, pop_as,
pop_into_as, peek_as, peek_into_as, atrunc_as, try_extend_as,
try_extend_zeros_as, try_extend_sparse_as, try_extend_sparse_batched_as,
try_discard_as, get_batched_as, get_batched_into_as, get_batched_gen_as.
Each duplicates its BStack engine verbatim (lib.rs untouched) and threads the
resolved authority into the acl_check! sites. The append and read siblings pass
through unchanged behaviour where the touched region is fresh tail or a plain
read; atrunc/pop/try_discard carry the same Truncate check over the existing
tail that splice/replace already do. Several io_core commit/read helpers move
from the atomic-gated import group to unconditional, since the non-atomic
frontier siblings (push/extend/pop/peek) mirror non-atomic BStack methods.
Adds authorized_stack_{append,removal,read}_ops_reach_prot covering all 17.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
resize and ensure mutated the payload (shrink/grow) with no acl_check, so a token holder — and worse, a tokenless caller — could truncate past or grow into a protected region unchecked. Add the checks (Truncate on the resize shrink branch, Write on both grow branches) and the resize_as/ensure_as siblings, covered by authorized_stack_resize_ops_reach_prot. Also fix two pre-existing warnings that clippy -D warnings surfaces in specific feature combos: - The meta_cross_exchange/process_gen/inplace_gen shim block was gated on set+atomic but only allocators call them, so it was dead code in a set+atomic build without alloc (CI's "set atomic" job). Gate it on alloc. - guarded.rs imported BStackOwnedSlice unconditionally and BStackAllocator in a test, both used only under `set`; gate/drop them so a plain `guarded` build and --all-features --tests are clean. Verified against CI's exact clippy matrix (--all-features, --no-default, set, atomic, "set atomic", alloc, "set alloc") plus the fault-injection cargo check --tests combos: all clean under -D warnings. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add an Unreleased CHANGELOG section for the expensive-slice-access-control feature (the point-table modes, capability tokens, _as siblings, and the allocator mint-burn + refuse-if-protected dealloc), and delete its now-built "Range access control on BStack and BStackOwnedSlice" entry from PLANNED.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
f4e58b1 to
a88a9ff
Compare
Reconcile the ACL feature with 8 PRs merged to master (FirstFit/Segregated allocator rewrites, BStackGenOp::Repeat, io_core OverlayData changes, Segregated stabilization). Conflicts and reconciliation: - CHANGELOG.md: keep both Unreleased sections (ACL Added + master's Changed). - first_fit.rs / segregated.rs: took master's rewritten free-list logic and re-applied the ACL header routing on top — all metadata I/O the allocators do to their (permanently Alloc-marked) headers now goes through the meta_* helpers, including master's new sites (validated free-list reads, the set_batched-based try_grow_into_next_free, the read-error-handling generators, and a new corrupt-head test poke). Added a meta_set_batched helper for the batched head writes. - acl.rs process_gen_as / inplace_gen_as: these duplicate lib.rs's crash-atomic engines, which master rewrote (Repeat op, inplace_validate_repeat, journaled_multi_overlay, OverlayData::Repeat). Regenerated both from master's current bodies with the authority threaded back in. Verified: builds clean on acl+atomic, acl-only, atomic-only, and all-features; first_fit/segregated/acl test modules pass under acl+atomic and under the normal feature set; CI clippy matrix and fmt clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
williamwutq
left a comment
There was a problem hiding this comment.
Rearchitecting needed
Address the mechanical, independent review comments: - Add #[inline] to the small public token-mint/inspection fns (take_protection, take_alloc_authority, access_at). - Drop the redundant pub(crate) BStack::protect wrapper; its callers (the slice protect path and the acl tests) now use protect_as((), ..) directly. - Move the acl_tests module out of lib.rs into acl.rs, off the core module. The two code-scanning findings (weak-RNG on the StdRng-seeded PointTable fuzz test, path-injection on the temp-file test helper) are test-only false positives and are left as-is. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…pers Per the review, collapse the two families of near-identical dispatch wrappers: - slice.rs: the eleven s_* helpers become one s_dispatch! macro invoked per method; each still routes to the token-carrying _as op (with this slice's stored auth) under the feature, or the plain op without it. - acl.rs: the meta_* family collapses to a single body-switching definition per method (no more separate real/shim impl blocks). meta_dispatch! generates the non-generic forwards (meta_set/meta_cross_exchange/meta_cas); the generic generator/set_batched helpers and the decode-carrying meta_read_u64 are written out. The macro is gated on alloc+set so it is never an unused macro. Behaviour unchanged: first_fit/segregated/acl tests pass under acl+atomic and the normal feature set; clippy matrix and fmt clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…uthorities - Remove the acl_active AtomicBool and its relaxed-load short-circuits from acl_check/access_at/acl_reclaim(able) and the store in protect_as. The feature is opt-in and named "expensive"; optimizing the unprotected path is unwarranted, and an unprotected stack has an empty point table so the check is a cheap miss anyway. - Back BStackAccessAuthorities with a u8 bit set (bit 0 guard, bit 1 alloc) via bitshifts, no dependency; the pub bool fields become const `guard()`/`alloc()` accessors. NONE/GUARD/ALLOC unchanged as consts. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Per the review, the terse `_as` docs that provided only a link now carry their own semantics line and an inlined # Errors — the PermissionDenied-under-`auth` condition plus the concrete InvalidInput/overflow/oob/size-mismatch cases each op can return — matching the fuller style set_as/get_as already used, so a reader need not follow the link to the base method to see the failure modes. Docs only; no behaviour change. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Rework the two one-shot mints (comment :921 and the follow-ups) so an external allocator can hold and present alloc authority, and drop the AtomicBool flags: - BStackProtection / BStackAllocAuthority are now owned (they record their origin stack's identity instead of borrowing it), so a holder that owns the stack by value can store one in a field. Neither is Clone/Copy. - BStack replaces protection_taken/alloc_authority_taken (AtomicBool) with a Mutex<Option<()>> permit each. take_protection/take_alloc_authority move the permit out (None while held = one-shot); the new return_protection/ return_alloc_authority hand it back so the next allocator on the same stack can mint again. Same one-per-stack shape for both authorities. - Identity is the fd/handle (matching Hash/PartialEq), recorded at mint and re-checked on present and on return, so a token authorizes — and re-arms — only the stack it came from. acl_claim_alloc still moves out and drops the alloc permit, so a built-in allocator's arena stays unmintable by outside callers. Adds returned_token_can_be_reminted and returning_a_foreign_token_does_not_rearm. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…etic ALLOC Replace the `meta_*` helpers on `BStack` (which fabricated a synthetic `BStackAccessAuthorities::ALLOC` for every crate-internal metadata write) with an `alloc_meta!` call-site macro: `alloc_meta!(self, op, op_as, args...)` expands to `self.stack.op_as(&self.alloc_auth, args...)` under the feature, or the plain `self.stack.op(args...)` without it. Each allocator now mints and holds its real `BStackAllocAuthority` (moved out of the stack at construction, via `take_alloc_authority`) and presents that token, rather than burning the permit through the removed `acl_claim_alloc` and conjuring authority. `into_stack` hands the token back to the stack (`return_alloc_authority`), so a caller that re-wraps the reclaimed stack can mint it again. `linear` holds the token too (it marks no metadata, so it never presents it — just holds and returns it) to preserve the "no outsider can mint over the arena" property. `self` is passed to the macro explicitly (it does not cross macro_rules hygiene, the same reason `acl_check!` takes its stack). Test pokes that wrote protected metadata directly go through the allocator's own token (`alloc_meta!`) or, in crate `mod test`, present `ALLOC` explicitly. Byte-identical without the feature. Config matrix (all-features, no-default, set, atomic, set+atomic, alloc, set+alloc) builds; clippy -D warnings and fmt clean; 39 acl tests pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
`into_stack` now resets the acl table (new `PointTable::clear` via
`BStack::acl_reset`) before returning the reclaimed stack, alongside handing
the alloc token back. Allocator `Alloc` header marks live only in memory and
are never persisted, so a stack reclaimed via `into_stack` must present the
same empty policy a real close/reopen from the file would — otherwise
re-wrapping that same BStack object (as the alloc_fuzz fault harness does:
into_stack() then make(stack)) leaves stale header marks that deny the reopen
constructor's plain header reads.
Pre-existing latent bug: it only bites under --all-features (the acl feature is
exercised nowhere else, and CI never runs --tests on it). Fixes the 17
alloc_fuzz::{init,inplace,uninit}_fault::*::fault_fuzz failures; the full
alloc_fuzz suite now passes (98). acl tests (39), config matrix, clippy
-D warnings, and fmt remain clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
| #[cfg(feature = "expensive-slice-access-control")] | ||
| alloc_auth: stack | ||
| .take_alloc_authority() | ||
| .expect("fresh stack owns its alloc permit"), |
There was a problem hiding this comment.
new returns io::Result<Self>, and returning io::Error with PermissionDenied is better here
| let mut free = Vec::new(); | ||
| let mut seen: HashSet<u64> = HashSet::new(); | ||
| let mut head = u64::from_le_bytes(read_bstack!(self.stack, Self::FREE_HEAD_OFFSET => u64)); | ||
| let mut head = alloc_meta!(self, read_u64, Self::FREE_HEAD_OFFSET)?; |
There was a problem hiding this comment.
We don't need a "read_u64" helper
| // Marks are in-memory only; clear them so the reclaimed stack matches | ||
| // a fresh reopen, then hand the capability back for re-minting. | ||
| self.stack.acl_reset(); |
There was a problem hiding this comment.
This is trying to solve the problem that should not exist. This would be needed since in new, the allocator did not immediately acquire authority with .take_alloc_authority() and use it for its io calls. Remote this line and instead change it to immediately aquire
| { | ||
| // Marks are in-memory only; clear them so the reclaimed stack matches | ||
| // a fresh reopen, then hand the capability back for re-minting. | ||
| self.stack.acl_reset(); |
There was a problem hiding this comment.
See previous comment on calling self.stack.acl_reset() and why it is not necessary
| /// Arm this slice's range with `mode` in the stack's access-control table. | ||
| /// | ||
| /// Crate-internal: the public protection entry is | ||
| /// [`BStackOwnedSlice::protect`], which forwards here. The range is this | ||
| /// slice's own `[start, end)` — a tokenless caller may only tighten a range | ||
| /// currently at [`All`](BStackAccess::All). | ||
| /// | ||
| /// Requires the `expensive-slice-access-control` feature. | ||
| #[cfg(feature = "expensive-slice-access-control")] | ||
| #[inline] | ||
| pub(crate) fn protect(&self, mode: BStackAccess) -> io::Result<()> { | ||
| self.stack.protect_as((), self.start(), self.len(), mode) | ||
| } |
There was a problem hiding this comment.
We have no need for these wrappers
| // Dispatch helpers, generated by `s_dispatch!`: route slice I/O through the | ||
| // stack's token-carrying `_as` entry point with this slice's stored authority, | ||
| // or the plain tokenless op without the access-control feature. | ||
| s_dispatch!(#[cfg(feature = "set")] fn s_set(offset: u64, data: &[u8]) -> io::Result<()> => set / set_as); | ||
| s_dispatch!(#[cfg(feature = "set")] fn s_zero(offset: u64, n: u64) -> io::Result<()> => zero / zero_as); | ||
| s_dispatch!(#[cfg(feature = "set")] fn s_repeat(offset: u64, pattern: impl AsRef<[u8]>, count: u64) -> io::Result<()> => repeat / repeat_as); | ||
| s_dispatch!(fn s_get(start: u64, end: u64) -> io::Result<Vec<u8>> => get / get_as); | ||
| s_dispatch!(fn s_get_into(start: u64, buf: &mut [u8]) -> io::Result<()> => get_into / get_into_as); | ||
| s_dispatch!(#[cfg(all(feature = "set", feature = "atomic"))] fn s_copy(from: u64, to: u64, n: u64) -> io::Result<()> => copy / copy_as); | ||
| s_dispatch!(#[cfg(all(feature = "set", feature = "atomic"))] fn s_process(start: u64, end: u64, f: impl FnOnce(&mut [u8])) -> io::Result<()> => process / process_as); | ||
| s_dispatch!(#[cfg(all(feature = "set", feature = "atomic"))] fn s_cross_exchange(a: u64, b: u64, n: u64) -> io::Result<()> => cross_exchange / cross_exchange_as); | ||
| s_dispatch!(#[cfg(all(feature = "set", feature = "atomic"))] fn s_eq_crds(a_offset: u64, a_expected: impl AsRef<[u8]>, b_offset: u64, b_buf: impl AsRef<[u8]>) -> io::Result<Option<Vec<u8>>> => eq_crds / eq_crds_as); | ||
| s_dispatch!(#[cfg(all(feature = "set", feature = "atomic"))] fn s_ne_crds(a_offset: u64, a_expected: impl AsRef<[u8]>, b_offset: u64, b_buf: impl AsRef<[u8]>) -> io::Result<Option<Vec<u8>>> => ne_crds / ne_crds_as); | ||
| s_dispatch!(#[cfg(all(feature = "set", feature = "atomic"))] fn s_masked_eq_crds(a_offset: u64, mask: impl AsRef<[u8]>, a_expected: impl AsRef<[u8]>, b_offset: u64, b_buf: impl AsRef<[u8]>) -> io::Result<Option<Vec<u8>>> => masked_eq_crds / masked_eq_crds_as); | ||
|
|
There was a problem hiding this comment.
We have no need for these s functions. Just macro at the call site instead of creating new ones
…pers Address the PR #74 review of the token refactor: - Constructors now `take_alloc_authority()` immediately and read their header through that token (`get_into_as`), instead of a plain read. A same-object reopen (into_stack -> re-wrap) therefore reads over its own persisted-only Alloc marks with authority, so `into_stack`'s `acl_reset` band-aid is gone (and with it BStack::acl_reset / PointTable::clear). into_stack now only hands the token back. - A constructor whose permit is already taken returns `io::Error(PermissionDenied)` rather than panicking via expect(). - Drop the `read_u64` arm from `alloc_meta!`: it is pure dispatch now; callers read into a buffer and decode at the call site. - Slices: remove the `s_*` wrapper methods (`s_dispatch!`) in favour of a call-site `slice_meta!` macro, and drop the `BStackSlice::protect`/`protect_as` wrappers — `BStackOwnedSlice` calls `stack.protect_as(..)` directly. slice:321 (carry parent auth by reference) intentionally not done: it conflicts with the documented sub-view-outlives-parent lifetime (subslice -> BStackSlice <'a>), and auth is a Copy u8 that already inherits at each derivation. All-features + config matrix build, clippy -D warnings, fmt clean; 39 acl and 109 slice tests pass; full alloc_fuzz suite (98) passes without acl_reset. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Description: Adds runtime range access control to
BStackandBStackOwnedSlice. A per-stackPointTable(sorted, coalesced change-points) records an accessmodeover every byte offset, and every I/O path consults it before touching payload. Modes spanAll(transparent),Rw/RwStrict,Prot/RwProt,ReadOnly,Locked, andAlloc. Protection is minted through one-shot capability tokens —BStackProtection(guard authority) andBStackAllocAuthority(allocator authority) — which are!Clone, pointer-identity-checked against the stack that issued them, and mutually incomparable, so a token from one stack (or the wrong axis) grants nothing. The whole feature folds to nothing when the flag is off: theacl_check!macro compiles away, the point table is absent, and the build is byte-identical, so there is zero cost for users who don't opt in.Every mutating and reading entry point (
set,get_into,cross_exchange,cas, and the crash-atomic journal enginesprocess_gen/inplace_gen/set_batched) checks the held authority against the required mode for its whole target range before committing, aborting the journal cleanly on denial. Allocators are wired end to end: each constructor burns its alloc mint and permanently marks its own fixed metadata headerAllocvia "run generator as allocator" — sibling_asengines duplicated intoacl.rsthat presentBStackAccessAuthorities::ALLOCthrough the same crash-atomic path, so the header mark is never lifted mid-flight and concurrency stays sound under theatomicfeature.deallocis refuse-if-protected: freeing a range that still carries any caller mode outside{All, Alloc}returnsPermissionDenied(the caller must un-protect first); the allocator only ever clears its ownAllocmarks, never silently dropping a caller's policy into the next owner.Important Feature: Yes
Type: Feature
Tests: Included
Feature Flags: expensive-slice-access-control (interacts with alloc, set, atomic)
Breaking change: No
New Types: BStackAccess, BStackAccessAuthorities, BStackAccessRequirement, AccessOp, BStackProtection, BStackAllocAuthority, BStackAuthority
Rust Only: Yes
Fuzz: No
Safety Review: Needed: Invariants, Correctness, Concurrency
🤖 Generated with Claude Code